Mobile System Design

Mobile System Design

Stop Fighting Your UI Tests

How abstraction patterns eliminate the pain of hunting for UI elements

Tjeerd in 't Veen's avatar
Tjeerd in 't Veen
Sep 22, 2025
∙ Paid

Not all teams test, and if they do, they usually focus on unit tests.

The most common argument against UI tests is that they're slower to run, write, and harder to debug. That's fair.

But there are ways to make the process much smoother. UI tests offer real value and scale well. You can have all critical flows ready as automated tests, then run them against multiple devices and OS versions every day. Try asking that from one QA person.

This article shows you how to speed up your UI testing and get more coverage. You don't even need CI setup: locally running UI tests can help tremendously.

I'll cover the basic abstraction patterns that anyone can implement, plus advanced techniques for handling real-world scenarios with different users and data sets.

1 How we usually write tests

Let's start with how we'd "normally" write UI tests and take it from there.

We'll use Swift and iOS as an example, but this is a universal concept that applies to Android, too.

In the example below, we are working with an app that sells learning courses.

We find a button in a scroll view and tap it to open a new screen, after which the test presses the back button in the navigation-bar to return. Then we check we're back on an overview screen by verifying that overviewLabel exists.

func testMakeSureCourseNavigationWorks() {
    let app = XCUIApplication()
    // Find button and tap it
    app.scrollViews["Courses"].children(matching: .button).tap()
    // Press back button
    app.navigationBars.buttons.element(boundBy: 0).tap()
    // Verify we're on the overview screen
    XCTAssert(app.staticTexts["overviewLabel"].exists)
}

But, this "regular" way of writing UI tests becomes hard to comprehend once they grow. Searching and finding elements through identifiers all the time can be a pain and it's slower to make adjustments since a developer would have to read and deduce what each step is doing.

In the end, the cognitive complexity is high for something so small, and the readability becomes worse once we're testing larger flows.

Instead, what if we write UI Tests on a higher abstraction? Something more like a script that's readable for programmers and non-programmers alike.

We can achieve this by putting the element matching and verification methods in tiny methods. Let's call ours verifyUserIsOnCourseOverviewScreen() or navigateToCourseOverview().

We'll update the test into a collection of static methods defining each step.

func testMakeSureCourseNavigationWorks() {
    Navigation.navigateToCourseOverview()
    Navigation.navigateToPreviousScreen()
    Course.verifyUserIsOnCourseOverviewScreen()
}

After our change, the test reads more like English sentences, which makes it easier to understand what is going on without adding comments or playing the mental compiler.

The UI Test details are abstracted. We find and match on the elements once and hide the matching logic into their own functions, so we don't have to worry about that anymore.

1.1 Creating a language

We can create this "language" by placing the element-hunting and their actions inside tiny methods. This prevents us from having to keep searching for elements and it gives more context to the "why" we're testing something.

In our case, these methods can have tiny bodies where we define the actions inside Navigation and Course.

We use enums to mimic a namespace in Swift. Since enums cannot be instantiated in Swift, they are good candidates.

// We define enum namespaces
enum Navigation {
    static func navigateToCourseOverview() {
        XCUIApplication().scrollViews["Courses"].children(matching: .button).tap()
    }

    static func navigateToPreviousScreen() {
        XCUIApplication().navigationBars.buttons.element(boundBy: 0).tap()
    }

}

enum Course {
    static func verifyUserIsOnCourseOverviewScreen() {
        XCTAssert(XCUIApplication().staticTexts["overviewLabel"].exists)
    }
}

With little effort, we end up with a simple, yet expressive, script language.

We can reference the currently tested app via XCUIApplication(). Because of that, we can define these small methods without requiring (many) dependencies, which improves the readability of our test scripts.

1.2 Easy to change

Within this language, it's easier to change things around or add and remove elements, without having to keep track of element matching mentally.

For instance, let's say we want to grow the test-script by adding a few more checks. Reading and writing tests this way is friendlier than a low-level language. Below, we easily grow the script yet it remains readable.

The only work we have to perform is match on the elements and put that in small methods again.

func testMakeSureCourseNavigationWorks() {
    Navigation.navigateToCourseOverview()
    Course.waitUntilDetailScreenIsOpen() // new 
    Navigation.goToPreviousScreen()
    Course.verifyUserIsOnCourseOverviewScreen()
    Course.openNewCourse() // new 
    Course.verifyNewCourseScreenIsOpen() // new
}

These new methods would be tiny methods on the enums we defined earlier.

1.3 Going higher-level

We can take this idea and go even more high-level as an expressive language. We can take the test-script from above, and abstract that method into its own scenario, such as Course.makeSureCourseNavigationWorks().

enum Course {
    // ... rest is omitted
    
    // We move this to the Course namespace
    func makeSureCourseNavigationWorks() {
        Navigation.navigateToCourseOverview()
        Course.waitUntilDetailScreenIsOpen()
        Navigation.goToPreviousScreen()
        Course.verifyUserIsOnCourseOverviewScreen()
        Course.openNewCourse()
        Course.verifyNewCourseScreenIsOpen()
    }
}

Then we can combine this scenario with other scenarios into an even larger one. For instance, we can verify that a new user can sign up for the app, and then browse and pay for a course. We'll call this test scenario testMakeSureNewUserCanSignUpAndPayForCourse().

Notice how we combine testing scenarios from Registration, Course, and Account into one huge testing script.

func testMakeSureUserCanSignUpAndPayForCourse() {
    Registration.registerUser()
    Registration.login()
    Navigation.navigateToCourseOverview()
    Course.makeSureCourseNavigationWorks() // The method we just defined
    Course.signUpForCourse()
    Navigation.navigateToAccountDetails()
    Account.makeSureUserHasOneCourse()
}

With just a few lines, the UI test script can run a giant flow throughout the entire app, yet remains readable and expressive. And if we were to deep-dive into these methods, we'll end up with element matching or other methods, nothing more.

The time investment here is to wrap element-hunting into methods, and wrapping those methods into scenarios. But it's not too much work, and this way you'd be making an expressive language with little effort.

By composing tiny methods, writing scripts for UI tests will be a breeze.

As your app grows, however, you'll face new challenges: testing the same flows with different user types, handling multiple data sets, and maintaining tests as requirements change. Hardcoded values become a maintenance nightmare, and team members spend hours updating scattered test data instead of building features.

The next section shows how to make these patterns truly scalable.

2 Testing with Real-World Data

While our abstraction layer makes tests more readable, real applications often require testing with different data, user types, or configurations. What happens when you need to test the same flow with free vs premium users? Or run the same scenarios against multiple courses?

Let's extend our approach to handle these real-world complexities and learn how to compose these patterns for complete user journeys that can test your entire app with just a few lines of code.

Keep reading with a 7-day free trial

Subscribe to Mobile System Design to keep reading this post and get 7 days of free access to the full post archives.

Already a paid subscriber? Sign in
© 2025 Tjeerd in 't Veen
Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture