UI Tests, but easier

Tjeerd in 't Veen

Tjeerd in 't Veen

— 3 min read

One gripe with writing UI Tests is that they are slow to write. A time-sink is that we have to search and hunt for each element, and sometimes we have to run a mental compiler to comprehend the steps that a test is taking.

In this article, we will explore one technique to write UI Tests easier and faster.

We will achieve this by creating a tiny expressive language that enables us to comprehend our tests more quickly.

We could think of UI Tests as a script that reads more like English, facilitating us to make it quicker to read and write UI tests.

First, we’ll start with how we’d “normally” write UI tests and take it from there.

In the example below, 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. Following that, we ensure the test returns to 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.

Thinking of UI Tests as an English-readable script

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’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 methods, so we don’t have to worry about that anymore.

Defining 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 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)
  }
}

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

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 (m)any dependencies, which improves the readability of our test scripts.

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()
  Navigation.goToPreviousScreen()
  Course.verifyUserIsOnCourseOverviewScreen()
  Course.openNewCourse()
  Course.verifyNewCourseScreenIsOpen()
}

Combining test-scripts into full-scale scenarios

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.

Below we’ll move the code to makeSureCourseNavigationWorks() inside Course.

enum Course {
  // ... rest is omitted
  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.

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()
  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.

Want to learn more?

From the author of Swift in Depth

Buy the Mobile System Design Book.

Learn about:

  • Passing system design interviews
  • Large app architectures
  • Delivering reusable components
  • How to avoid overengineering
  • Dependency injection without fancy frameworks
  • Saving time by delivering features faster
  • And much more!

Suited for mobile engineers of all mobile platforms.

Book cover of Swift in Depth


Written by

Tjeerd in 't Veen has a background in product development inside startups, agencies, and enterprises. His roles included being a staff engineer at Twitter 1.0 and iOS Tech Lead at ING Bank.