Unit Testing UI in Swift

I’ve been working on a lot more UI testing in the past few years, but never really believed in the UI XCTest framework by Apple, nor the third-party framework options available in the open-source community. In my opinion, UI tests are slow, unreliable, and a time investment for developers that is most of the time not worth it.

I worked on several teams using UI testing in different platforms and saw the many pain-points they were experiencing. Many times, they would have to go back to old tests that would break for the wrong reasons and be distracted from building features for the end-users. Tests would fail all of the sudden with no reason, making the testing experience frustrating.

Recently, my favorite way of writing UI tests has been through unit tests and integration tests. Those tests have been very reliable, easy to write, and maintain, without having to familiarize myself with a different framework for UI tests. They also run much faster than UI tests. Quick and Nimble have been very convenient to organize my tests in a behavior-oriented way, with neat helpers to test asynchronous code.

Setup Tests

We first need to setup our test with our view controller. You can create a view controller in a unit test environment very easily by doing the following:

import Quick
import Nimble

class MyTests: QuickSpec {
    override func spec() {
    
        describe("MyTests") {
            var sut: MyViewController!
        
            beforeEach {
              sut = MyViewController()
              // Configure your view controller with dependencies if necessary

              // Preloads the view to trigger the rendering. This is necessary to test elements of a view controller.
              sut.preloadView()
            }
        }
    }
}

The line sut.preloadView() is a neat trick I learned from Natasha The Robot to trigger the UIViewController rendering, which will fully set up the UIViewController class and allow you to access all its UIKit elements.

The preloadView function lives in a UIViewController extension and does 2 things:

  • It triggers the view rendering by calling self.view.
  • It tests that the view exists, meaning the UIViewController is created and set up.
import Nimble

extension UIViewController {
    /// Preloads the view to trigger the rendering. This is necessary to test elements of a view controller.
    func preloadView() {
        expect(self.view).toNot(beNil())
    }
}

At this point, our UIViewController is ready to be tested.

Navigation

Navigation is a core part of every iOS application. We can easily make sure that our app flows as expected by unit testing that the correct UIViewController is at the top of the navigation stack.

App Setup

A first test we can write is making sure the correct root view controller is set at app start:

it("should setup the app window") {
    // given
    let window = UIApplication.shared.delegate?.window!!
    // then
    expect(window?.rootViewController).toEventually(beAKindOf(MyRootViewController.self))
}

Button triggering navigation

Let’s now take the example of a button that triggers the navigation to a different view controller:

beforeEach {
    sut.preloadView()
    // We assign a UINavigationController with the tested view controller to allow navigation during the test
    _ = UINavigationController(rootViewController: sut)
}

it("should navigate to the expected view") {
    // when
    sut.button.sendActions(for: .touchUpInside)
    // then
    expect(sut.navigationController?.visibleViewController).toEventually(beAKindOf(MyNextViewController.self))
}

We can unit test that the next visible view controller in the navigation stack is the correct one.

Notice the use of the sendActions(for:) function, which allows our unit test to simulate a button tap in our UI. This is extremely helpful to trigger UI actions and unit test the expected behavior.

It essentially replaces the UI triggers used by XCTest such as app.buttons["MyButton"].tap(). I do appreciate the syntax for having a tap() function, this is something we can easily replicate for our unit test by making an extension on UIButton:

extension UIButton {
    func tap() {
        sendActions(for: .touchUpInside)
    }
}

And replace our code with sut.button.tap().

I find these unit tests very easy to write and maintain. They run much faster than UI tests and don’t require as much setup as UI tests.

Snapshot Testing

Another technique I’ve been using more and more is snapshot testing. The concept is simple: your unit test will take a snapshot of the UI on the first time run, and save it locally. The next time the unit test runs, a new snapshot will be taken and compared with the previously recorded snapshot. If the two snapshots match, the test pass, if they don’t, the test fails.

With snapshot testing, you can take a screenshot of your view controller’s UI in different states, and make sure that over time the UI remains the same. This is especially useful when you have an intricate UI that is very dynamic depending on the content. You can feed your view controller with different data sets and snapshot the UI to make sure everything renders as expected. You can snapshot multi-language support to make sure your UI handles languages with longer text. You can snapshot accessibility by making sure the color contrast is correct, Dynamic Type is supported with support for larger text, etc.

Snapshot testing is a great way to test that all the different UI scenarios are handled correctly. I also found snapshot testing very convenient to debug the implementation of a view controller’s UI, especially when the view controller needs to handle a specific data state or is located deeper into a navigation stack.

I’ve been using Swift Snapshot Testing extensively in the past few months with great results. The library has all the features you need and is very reliable.

With the Swift Snapshot Testing library, I can provide specific data to my view controller and test the UI on different devices and with specific accessibility traits:

it("should render the UI when the view doesn't have any data") {
    // given
    let model = Model()
    sut.model = model
    // when
    sut.viewDidLoad()
    sut.viewWillAppear(false)
    // then
    assertSnapshot(matching: sut, as: .image(on: .iPhoneSe), named: "MyViewController_dataEmpty_iPhoneSE")
    assertSnapshot(matching: sut,
                   as: .image(on: .iPhoneSe, traits: .init(preferredContentSizeCategory: .accessibilityExtraExtraLarge)),
                   named: "MyViewController_dataEmpty_accessibilityExtraExtraLarge_iPhoneSE")
    assertSnapshot(matching: sut, as: .image(on: .iPhoneXsMax), named: "MyViewController_dataEmpty_iPhoneXSMax")
    assertSnapshot(matching: sut,
                   as: .image(on: .iPhoneXsMax, traits: .init(preferredContentSizeCategory: .accessibilityExtraExtraLarge)),
                   named: "MyViewController_dataEmpty_accessibilityExtraExtraLarge_iPhoneXSMax")
}

With snapshot testing, you’ll need to keep a couple things in mind:

  • Snapshots need to be taken and compared on the same simulator due to simulators having different pixel densities and screen definitions which will result in your test failing because the snapshots don’t match.
  • Snapshots recorded and added to your git repository can start to add up in storage space. If your git provider limits how large a repository can be, you’ll need to look into git-lfs to store the screenshots.

Wait For Events Using Expectations

You can also wait for events to happen or elements to appear in your unit tests before asserting. This is really useful when an asynchronous task needs to complete before assertion when a loading spinner needs to disappear before you to take a snapshot of your UI, or when a property needs to change before the UI can refresh and you can assert.

The most known API for waiting during a test is XCTestExpectation. You can use it to wait for an asynchronous task to complete before asserting something. Here is an example from Apple’s documentation:

// Create an expectation for a background download task.
let expectation = XCTestExpectation(description: "Download apple.com home page")

// Create a URL for a web page to be downloaded.
let url = URL(string: "https://apple.com")!

// Create a background task to download the web page.
let dataTask = URLSession.shared.dataTask(with: url) { (data, _, _) in
    
    // Make sure we downloaded some data.
    XCTAssertNotNil(data, "No data was downloaded.")
    
    // Fulfill the expectation to indicate that the background task has finished successfully.
    expectation.fulfill()
    
}

// Start the download task.
dataTask.resume()

// Wait until the expectation is fulfilled, with a timeout of 10 seconds.
wait(for: [expectation], timeout: 10.0)

This API is great for asynchronous tasks where you have a completion block that you control, so you can wait for its completion and assert inside.

If you don’t control the completion block, or want different kinds of expectations, you can use Apple’s built-in subclasses:

You can define those expectations and then use XCTWaiter to wait for a group of expectations to return. This is where XCWaiter is really powerful. It can take any kind of expectations in an array and wait for all of them to complete before returning.

Here is how I use XCTKVOExpectation to wait for a property change before asserting.

First, I create a global function in my test target to wait for a KVO change given a KVO key path, owner, and timeout value:

func waitForKVOChange(keyPath: String, object: Any, timeout: TimeInterval = 1) {
    let expectation = XCTKVOExpectation(keyPath: keyPath, object: object)
    XCTWaiter().wait(for: [expectation], timeout: timeout)
}

Then, I use the function to wait for a KVO change before asserting:

it("should render the UI correctly when the data model updates") {
    // given
    // I mock my networker response with a custom completion block where I return my data
    networker.getData = { completion in
        completion(.success(MyData()))
    }
    
    // when
    // viewWillAppear calls networker.getData and will receive the mocked data
    sut.viewWillAppear(false)
    // We wait for the property 'myPropertyName' to update using the KVO observer pattern
    waitForKVOChange(keyPath: "myPropertyName", object: sut!)
    
    // then
    // 'myPropertyName' changed, we can assert knowing the data is in place in the view controller
    assertSnapshot(matching: sut, as: .image(on: .iPhoneSe), named: "MyViewController_iPhoneSE")
}

The assertSnapshot line will not execute until the KVO expectation is fulfilled or times out. This behavior allows for asynchronous tests to be written in a synchronous fashion, and make sure that all the data is in place before asserting.

Regarding XCTKVOExpectation, I wish the API could support native Swift key paths notation instead of plain string. This is due to being an Objective-C API. Maybe in the future, Apple will provide a native equivalent so we can write the expectation as follow:

let expectation = XCTKVOExpectation(keyPath: \MyViewController.dataModel, object: object)

Conclusion

Unit testing your UI is very straightforward, making your test code easy to write and maintain. I apply those techniques daily in all the production apps I’m working on, and they serve me really well. I found that skipping UI testing for unit testing UIs saved me a lot of time. I’ve seen a lot of hacky workarounds online on how to make UI tests work and tried myself to implement some UI tests only to get frustrated at the tools and the unreliable experience.

I encourage all of you to try unit testing your UI. It has helped me tremendously so far writing better apps, and I hope it will help you as well!