How to Implement iOS UI Testing

Written by borysenko | Published 2021/09/18
Tech Story Tags: programming | ui-testing | fintech-apps | end-to-end-testing | coding | ios-app-development | testing-ios-apps | testing

TLDRHow to Implement iOS UI Testing in a Fintech App -Pros and cons of adding UI tests to the project -Page Object pattern as a way to clean up the code -Communication with API: implementation of a mock servicevia the TL;DR App

Photo by Kristopher Roller on Unsplash.

During their Developer careers, coders may encounter a Wirex fintech app that combines traditional money and cryptocurrencies onto one platform.

For us in the know, it is crucial to understand what’s going on with the app from the user’s perspective; Especially when 4 million active clients trust it with their money.

While a unit test checks one particular place (scene/function/module), end-to-end tests check the whole flow that the user goes through.

User Interface (UI) Tests are a convenient tool that can be used for this purpose. They launch an app and start interacting with all the visible elements going from one screen to another the same way as the user does.

Pros and Cons of Adding UI Tests

Advantages of UI testing:

  • It helps to check UI functionality and find critical bugs🪲.
  • Combined with Unit tests, UI tests can give us maximum code coverage.
  • It checks the app from a user’s perspective.
  • UI tests can be shown to customers to explain the necessity of testing.

Concerns Regarding UI testing:

  • Harder to build: Building a UI Test scenario requires preparation of the UI Elements and takes considerable time to create all the dependencies between our screen flows.

  • Longer running time: Each test takes about 13–34 seconds to run in total, all 21 UI tests take 8 minutes, whereas all our 42 Unit tests only take 2 seconds.

  • Harder to fix and maintain: Detecting the issue is a good thing, but when you want to catch a bug that’s far away from the code, it’s much harder to fix. When a unit test fails, it shows you the exact way it was broken. And yes, all changes in the UI require us to modify the UI test as well.

Page Object Pattern

Once we started to write UI tests, one thing became obvious — a vast usage of string constants will clutter the code and make it difficult to maintain.

All components visible on the screen are represented as XCUIElement objects and the most common way to identify them is to use string as an identifier.

Page Object pattern is an effective solution for this problem. This is the description of our implementation.

Every screen is represented by one PageObject and every PageObject conforms to Page protocol:

import XCTest

protocol Page {
    var app: XCUIApplication { get }
    var view: XCUIElement { get }

    init(app: XCUIApplication)
}

This is how the PageObject looks like👍🏻:

import XCTest

class LoginPage: Page {
    var app: XCUIApplication
    var view: XCUIElement

    private let loginButton: XCUIElement
    private let emailTextField: XCUIElement
    private let passwordTextField: XCUIElement

    required init(app: XCUIApplication) {
        self.app = app
        view = app.otherElements["Login_Scene"]
        emailTextField = app.textFields.firstMatch
        passwordTextField = app.secureTextFields.firstMatch
        loginButton = app.buttons["Log In"].firstMatch
    }

    @discardableResult
    func tapEmailTextField() -> Self {
        emailTextField.tap()
        return self
    }

    @discardableResult
    func typeInEmailTextField(_ text: String) -> Self {
        emailTextField.typeText(text)
        return self
    }

    @discardableResult
    func tapLoginButton() -> DashboardPage {
        loginButton.tap()
        return DashboardPage(app: app)
    }
}

Pay attention to the view = app.otherElements[“Login_Scene”] line. Unlike buttons, images, or text fields, the main view should have an explicitly set identifier.

We set it in the UIViewController of every scene, in the viewDidLoad method:

override func viewDidLoad() {
    super.viewDidLoad()
    view.accessibilityIdentifier = "Login_Scene"
}

Another thing to mention about PageObject is the return type of every function — it is either Self or another PageObject.

As a result, we will be able to chain our methods. Here is what the resulting test may look like:

func testLogIn() {
    app.launch()
    // let's assume that login page is the first one that is shown on start
    let loginPage = LoginPage(app: app)

    let dashboardPage = loginPage
        .checkLoginButtonIsDisabled()
        .tapEmailTextField()
        .typeInEmailTextField("newUser@gmail.com")
        .tapPasswordTextField()
        .typeInPasswordTextField("password")
        .checkLoginButtonIsEnabled()
        .tapLoginButton()

    guard dashboardPage.view.waitForExistence(timeout: 20) else { 
      return XCTFail("Dashboard Scene must have been shown") 
    }
}

So it’s really quite simple, isn’t it?

We can chain our screens to create concise, readable UI Tests that are also easier to maintain.

We can reuse these Page Objects for different flows, we want to check in our UI Test, and if we make some changes in our UI, we only need to fix it in one place.

If you want to start writing UITests in your project, start by creating Page Objects. It will save you a lot of time and mental resources 😌 in the future.

Communication With API

Should you use a real server or a mocked one?

The second problem we’ve faced was the UI test’s communication with the backend API. We had to choose either to use our development server or to create mocks that imitate API requests.

We decided to implement a mock service because of the following reasons:

  • We didn’t have a dedicated server that could be used for running tests only.

  • Our existing development servers had their state updated and often changed.

  • A server could be off or it could be used to test a new API, and some APIs might be broken at the time.

  • Supporting a dedicated server in an up-to-date state would require more time to communicate with the backend team.

  • The network request execution takes time on a real server, but with mocks, we receive the response almost instantly.

We created an entity called MockServer that basically contained one function:

func handleRequest(request: NetworkRequest, completion: @escaping RequestCompletion)

It accepts the NetworkRequest object, and closure is used for passing the request’s response.

In our case, NetworkRequest is a simple structure containing all the necessary data to make requests (URL, HTTP method, parameters, body, etc.) and conforms to Equatable protocol (to be able to use it in the switch statement).

This function contains the logic that decides whether the request should return a successful response or error:

typealias RequestCompletion = (Data?, WirexAPIError?) -> Void

final class MockServer {
    static let shared = MockServer()

    private init() {
        // function that clears local database on app start. I did not include it in the code snippet.
        self.clearDataIfNeeded()
    }

    func handleRequest(request: NetworkRequest, completion: @escaping RequestCompletion) {
        // Convenience method. Use it for error mocking
        let sendError: (WirexAPIError) -> Void = { error in
            self.processResponse(request: request, data: nil, error: error, completion: completion)
        }

        // Convenience method. Use it to mock response. Pass nil if only 200 is needed
        let sendSuccess: (Data?) -> Void = { data in
            self.processResponse(request: request, data: data, error: nil, completion: completion)
        }

        switch request {
        case .postSignIn():
            if !isDeviceConfirmed {
                sendError(WirexAPIError.confirmSMSError)
            } else {
                sendSuccess(nil)
            }
        // all other cases
        default:
            sendSuccess(nil)
            // This way we detect the requests, we forgot to mock or mocked wrong while writing ui test
            print("Here request url, body and any needed info is printed")
        }
    }

    private func processResponse(request: NetworkRequest,
                                 data: Data?,
                                 error: WirexAPIError?,
                                 completion: @escaping RequestCompletion) {
        if let data = data, let onSuccess = request.onSuccess, error == nil {
            onSuccess(data, { completion(nil, $0) })
        } else {
            completion(data, error)
        }
    }
}

Mocked data is passed like this sendSuccess(ConfirmLoginMock.response). The mock itself is just converted to the data JSON string:

class ConfirmLoginMock {
    static let response = """
    {
        "token": "mockedToken",
        "expires_at": "2021-03-19T16:38:13.3490000+00:00"
    }
    """.data(using: .utf8)!
}

All that is left to do is to inject the handleRequest function into the app’s network layer. In our project, we have an entity called NetworkManager that has a single point for all incoming requests. It accepts the same parameters as the above-described function:

func runRequest(request: NetworkRequest, completion: @escaping RequestCompletion) {

#if DEBUG
if isRunningUITests {
    MockServer.shared.handleRequest(request: request, completion: completion)
    return
}
#endif

// Here goes the code that makes actual API request and handles response
}

Any request that will be passed to it will be either mocked or sent to an actual server.

We use a constant isRunningUITests to detect whether to run tests or not. And since we’re not able to pass the data between the main project and UI tests directly (UI tests are run in isolation), we need to use the launch arguments of the app.

We can do it in two steps. The first is to set an argument before the start of the UI test:

// Every UI test can set up it's own launchArguments if needed
override func setUp() {
    super.setUp()
    continueAfterFailure = false
    app = XCUIApplication()
    app.launchArguments.append("UITests")
}

The second step is getting this argument somewhere in the main project.

let isRunningUITests = ProcessInfo.processInfo.arguments.contains("UITests")

Lessons we Learned

  • Make sure you clear your local persistent storage, User Defaults, local caches, or any temporary data that influences your app behavior before the UI test is run.

  • Every test needs to start from the same app state because it may still succeed when run independently but may fail when run together with the rest of the tests if they share the same state.

  • Double-check your mock data, it may save you a lot of time and effort. We used the raw JSON data for the mocks. We always checked that it was a valid JSON before passing it into the codebase.

  • Keep your tests code clean. Although it’s not a production code, you may need to return to it in the future — if it’s a mess, it will be hard to work on.


Written by borysenko | B2C iOS Developer @ Wirex
Published by HackerNoon on 2021/09/18