The quality of a product is often judged by its ability to meet, or exceed, customer expectations whilst providing an experience free from deficiencies or defects. As a team we want to deliver value faster without compromising quality. As Engineers we want to improve the quality of our software by continuously improving our testing capabilities.

At Treatwell our unit test coverage is good but felt that we could benefit from testing the integration of components and screens. This post is about how we used iOS UITests to help us achieve this. Specifically we’ll cover the following:

How we passed launch parameters to our app on every test launch. How we mocked our API responses. How we structured our UITests and the supporting code.

Preparation

We started by adding UITests as a target of the Xcode app project. There are plenty of good articles about setting up UITests in Xcode (E.g. Swift by Sundell) so we won’t go into too much detail about it.

In our project we have two separate targets, App (MyApp) and UITests (MyAppUITests). MyApp should be set as “Target application” in UITests general settings.

We chose to use the native XCTest framework in favour of a 3rd party solution. XCTest makes debugging whilst running tests easier, is flexible, is well documented and is less likely to break when updating Xcode due to it being native.

To be in full control of our app whilst it’s running our tests we need to imagine it as a black box with the following inputs:

Launch parameters (initial app state). User interactions. API responses (network). Other events (e.g. push notifications).

We’ll focus on the first three inputs as they are essential for almost all test scenarios. We’ll control inputs using launch parameters, accessibility identifiers and local API stubs.

Start building

Launching an app is as simple as:

Is the test class. Is the test function. Is a handy custom function which helps us launch an app with its initial configuration. We use a configuration struct to define and hold all necessary launch parameters so the app launches in a controlled state.

Launch parameters

Launch parameters should specify the state of a system (app) and should be passed whilst launching, imagine them as GIVEN step in Gherkin syntax.

They can be passed using either environment variables as key-value expressions 👍 or launch arguments as an array of strings.

To pass parameters whilst launching our application we’ve used a dictionary which supports different parameters types. In this example we’ve used XCUIApplication property var launchEnvironment: [String : String]:

Stops execution after a failure occurs. Creates a proxy for the application specified as the “Target Application”. Adds the launch parameters dictionary. Launches the application, it’s launched for every UI test (when func start() is executed). Returns instance type XCUIApplication to support chaining.

In the previous example we passed a Configuration object to the start function which is a dictionary provider. Both keys and values should be strings to be compliant with launchEnvironment dictionary.

The dictionary which holds launch parameters, we’ve added some default key-values. The ConfigurationKey that holds key strings ( e.g. static let isFirstTimeUser = “is_first_time_user”). ConfigurationKey is in a separate file which is shared between both App and UITests targets. It’s the only file which needs to be shared between targets and there is no need to duplicate keys which helps to avoid typo related bugs.

Configuration can be extended to have additional functions.

1. Function which can be called (and chained) to set property isFirstTimeUser to true (default is false). Return self to support chaining.

Configuration functions can be grouped by categories and moved into separate files and extensions dependent on your code structure. To keep your code readable you can chain your functions whilst modifying your default configuration. It also means you’ll get auto completion which is handy! E.g:

let configuration = Configuration()

.isTestService()

.isCountryNL()

.isFirstTimeUser()

The initial app state (configuration) should be set up as soon as the app starts. Launch parameters should update internal app state and override data and parameters should not be accessed later. App state usually is defined as collection of specific values. UserDefaults can be used as storage to keep state which reduces the complexity of propagating injected parameters through the app components and screens.

Injected configuration in AppDelegate can be handled like this:

1. Call launch parameters helper class to set up configuration (app state).

Below you can see where the LaunchEnvironment class overrides UserDefault and singleton (example of legacy code) values. The parameters in the launchEnvironment dictionary are accessed and the app state is configured accordingly.

Checks if the app is running in the desired UITests mode. Sets the app state to “first time user”. A hardcoded string is used for simplicity. Deals with custom case (singleton). Sets up a country and a service using values found in the injected launch dictionary. Disables animations to speed up running of the UITests.

Tip: make sure these values are not unintentionally overridden later in test 😉 to avoid undesired behaviour.

Writing a test

XCTest has built in support for user interactions and system events.

Let’s add first UI test. Xcode has a handy feature to record user actions and then convert it into code. the example below shows how we created func testSmth() in XCTestCase and selected our app scheme. By putting our cursor in our test function we should now see that the Record button is enabled.

Start recording by clicking on record button. Once you have interacted with your app you should be able to observe how interactions are converted to code:

Starts the test and injects your configuration. Taps the “continue” button. Checks if the main view was displayed.

Note: that user interactions are created using Label values, we can improve this code by introducing accessibility identifiers.

Accessibility identifiers

Accessibility Identifiers help us to access UI elements in a unified way. They are not dependant on localisation.

A UI element can be accessed using the accessibilityIdentifier property and can be declared in a separate file. You should include the file into both App and UITests targets to avoid repetition. An AccessibilityIdentifiers file should look similar to this:

And here is how you would assign an accessibility identifier to a UI element in code:

Finally, you can then replace any hardcoded strings by introducing identifiers:

Page object model

We use the Page Object Model (POM) pattern which allows us to access UI elements in a readable and easy way. For a more in depth explanation please read this article by Martin Fowler.

Here is how we created a onboarding Page model:

Here is another example of how we created the home screen page model:

Tip: move actions to a separate class (Step) to split responsibilities. This helps to separate responsibilities between elements and actions objects. For example, this is how we moved our onboarding actions into a step object:

References our page object. Step methods return Self (2a) or XCUIApplication (2b) instance. This way we can achieve useful autocompletion when chaining functions.

Self is returned when execution of an action (function) does not navigate to a new page, instead it remains in the same page and allows calls to other Step actions.

XCUIApplication type is returned when execution of an action navigates you to a new page. Another page is not specified in this case because it can introduce complexity while maintaining existing step classes later.

3. Encapsulates simple actions into one method.

Now that we have a page object we can create our first test:

Runs the application as a first time user. Accesses onboarding step and skips all onboarding screens. Accesses home screen page and asserts its view exists.

We now have control of user interactions.

As an Easter egg 🎁 in the Github TWUITests project, we have some useful templates that help you to generate boilerplate code for Page, Step and Test classes. Necessary classes can be created with one click.

Local server

Server API is the last important input source which needs to be controlled.

There are different ways how this can be achieved: inject results, switch to prepared hardcoded stubs, use test server etc. Let’s keep a full app network layer operating and just feed API responses using local server. The server will be embedded into our test driver (runner). There are plenty of different options but we’ll use HTTP server engine Swifter, a simple Swift based solution.

Our test flow should be:

Launch a local server. Launch our app. Set our server responses. We should be able to do this on test startup or whilst running tests.

Full code is in the same Github project, a detailed explanation of implementation requires a separate blog post 😉.

API stubs should be placed in LIBRARY_DIR + “/Developer/CoreSimulator/Devices/” + DEVICE_ID + “/data/Library/Caches/ApiStubs/”. Example how stubs are copied to this location in build phase can be found in TWUITestsExample.

Here is our final test example:

Adds a Netherlands market stub by providing stub details (APIStubInfo struct), the server will return JSON which path is in HTTPDynamicStubsList.Channel.NL struct. Calls a method which will skip onboarding.

Summary

In this post we have:

Created an easy to use and maintainable native solution for writing UITests.

Used the Page Object Model pattern for a cleaner code structure.

Created a local server which we can we dynamically inject mock API responses into.

Leveraged chaining to give us auto completion whilst writing tests.

We have created an open source, lightweight framework and posted it on Github as TWUITests 🎉 (There is an example how to implement the whole solution called TWUITestsExample). TWUITests project consists of Page Model Object wrapper, API mocking, templates to generate necessary classes and other supporting code.

Hopefully you have found some useful tips and techniques in this post 😉. I’m very open to discussions about any improvements!

Big thanks to my colleague and framework co-author Marius Kažemėkaitis @ Treatwell.

What’s next?

Add tests 💪!