The ultimate guide to unit and UI testing for beginners in Swift

Learn how to write real-world Unit/UI test cases for your iOS app. This one is a practical 101 testing article for absolute beginners.

Bitrise

Automated testing using Xcode

If you are working with Xcode you can set up two kinds of testing bundles:

Unit tests

Testing individual software components is called unit testing. This kind of testing is usually done by the programmer and not by testers, because it requires an in-depth knowledge of the appโ€™s internal design.

UI (user interface) tests

UI testing lets you test interactions with the graphical user interface in order to validate the GUI against the business requirements, so basically checking if the buttons actually work or not.

Other types of testing

There are lots of other types of testing, but in this article I'm not going to focus too much on them since I'm working with Xcode bundles. It's worth mentioning though that you can make your own e.g. regression test bundle by using the built-in test bundles. If you are curious about some other ways of validating your software you can find a brief summary here.


Fake, mock, stub, oh my spy... ๐Ÿ˜

Before we dive into practical testing, we need to clarify some concepts. Lots of developers are confused with these terms, and I'm not judging anybody, since they can be easily misinterpreted. In my dictionary:

Fake

A fake has the same behavior as the thing that it replaces.

Fake objects are closer to the real world implementation than stubs. This means that fake objects usually have the same behavior as the thing that they replace. For example a fake login can return a fake token just like an actual API service would do (with real data of course). They are useful if you'd like to run tests, since an entire service layer can be replaced using these objects.

Stub

A stub has a "fixed" set of "canned" responses that are specific to your test(s).

Stub objects are usually hardcoded values that are expected in some kind of test cases. They are really dumb, they don't contain much logic, they are just pre-programmed objects with some fixed set of data. If I go back to our fake authentication example, the response can be a stub authorization token object including some random user data.

Mock

A mock has a set of expectations for calls that are made. If these expectations are not met, the test fails.

Mock objects can replace entire classes and they provide the ability to check if particular methods or properties have been called or not. They can also return stub values as defaults, errors or throw exceptions if needed. A mock has a set of expectations for calls, you can check these expectations in your tests, if those are not met, your test should fail.

Spy

When you use a spy then the real methods are called.

Spy objects can literally spy on existing objects or methods. This is useful when you have a bigger class and you don't want to mock everything, but you are curious about some smaller piece of the internal behavior of that object. You can imagine spy objects as wrappers around an existing class. They do some spying and they call the original implementation afterwards.


Unit testing in practice

Now that you know the basics, let's write some practical UI/Unit tests in Swift. โŒจ๏ธ

Wait... what the heck is a test?

Well the short answer a test is a function that can have two distinct outcomes:

  • โœ… - success
  • โŒ - failure

Defining good a unit test is hard, but fortunately Vadim has some excellent articles about this topic, so you should definitely check his blog as well. ๐Ÿ˜‰

The anatomy of a unit test target in Xcode

Multiple test cases can be grouped together inside a class in Xcode. This is a neat way to write related tests for a software component. Just import the XCTest framework, (which is also available for Linux), subclass the XCTestCase class and prefix your test methods with the test keyword to be able to test them. ๐Ÿ”จ

The setUp and tearDown methods will be called before every single test function, but usually I don't like to use them, I always initiate a new environment for my tests for every single case. The reason behind this is that tests can run in parallel, which is really amazing if you have lots of them. Also you don't want to end up with some broken test case, just because of some stupid shared state or side effect.

Also you should note that you have to import your app target as @testable import Target in your test target in order to be able to reach your objects & methods. This way you'll be able to test your internal classes and methods as well. ๐Ÿ“

import XCTest
@testable import Example

class ExampleTests: XCTestCase {

    override func setUp() {
        XCTAssertTrue(true)
        // Put setup code here. This method is called before the invocation of each test method in the class.
    }

    override func tearDown() {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    func testExample() {
        // This is an example of a functional test case.
        // Use XCTAssert and related functions to verify your tests produce the correct results.
    }

    func testPerformanceExample() {
        // This is an example of a performance test case.
        self.measure {
            // Put the code you want to measure the time of here.
        }
    }

}

How should I write a test?

Imagine a method that validates an email address, which has an input parameter (String), and a return value (Bool). We don't care about the internals right now, this method is a black box, maybe someone else will write it, maybe you will, from the point of unit testing it doesn't matter. Just think about the interface for now:

func validate(email: String) -> Bool

Ok, so we have our function prototype, now think about what kind of inputs can we give this function, and what's going to be the expected output:

  • "example@address.com" -> true
  • "my.personal.address@google.co.uk" -> true
  • "" -> false
  • "invalid@address." -> false
  • "lorem ipsum dolor sit amet" -> false
  • "injection@example.com; DROP TABLE users" -> false
  • "โœ‰๏ธ" -> false
  • "23983ujnvmfbbg73209refagjklnku129@adf@asdf.com" -> false

The purpose of a unit test is to catch all the edge cases. The built-in XCTest framework has some helpful methods to evaluate your tests. So for example, if you want to make a simple unit test to check the result against the input examples from above, you could use the following functions to evaluate your results:

XCTAssert(validator.validate(email: "my.personal.address@google.co.uk") == true)
XCTAssertTrue(validator.validate(email: "example@address.com"))
XCTAssertFalse(validator.validate(email: ""))
XCTAssertEqual(validator.validate(email: "invalid@address."), false)
XCTAssertNotEqual(validator.validate(email: "lorem ipsum dolor sit amet"), true)

You can also provide a custom failure message as a last parameter for every single assertion method, but usually I'm just fine with the default value. ๐Ÿคทโ€โ™‚๏ธ

What kind of unit tests should I write?

Well my quick answer is to think about the following scenarios first:

  • a case that'll match your expectation (valid case)
  • invalid case (something that should raise an error / exception)
  • edge cases (limitations like upper bounds of an integer)
  • dangerous cases (special inputs that might break your code)
  • monkey tests (test with completely random values)

As you can see, for the email validation example I mostly followed these basic rules. Just think about the fairly simple use cases, the main goal here is not to cover every single one of them, but to eliminate the most critical scenarios.

What about async methods?

You can test async methods as well using an expectation. You might ask now:

What is an expectation?

If you heard about futures and promises, you'll see that expectations are somewhat similar, except that you don't have to provide a fulfillment value and they can never fail, but timeout. Vadim also has some nice articles about unit testing async code in Swift and the busy assertion pattern. I'm not going into the details, since we have to cover a lot more, but here is how you can wait for something to happen:

func testAsyncMethod() {
    let expectation = XCTestExpectation(description: "We should wait for the sample async method.")

    mySampleAysncMethod(delay: 2, response: "Hello Async!") { [weak expectation] result in
        XCTAssertEqual(result, "Hello Async!")
        expectation?.fulfill()
    }
    self.wait(for: [expectation], timeout: 3)
}

As you can see, you just have to define your expectation(s) first, then you wait for them using the wait method with a given timeout parameter. When your async method returns with a callback, you can call fulfill on your expectation to mark it ready. If your expectations are not ready before the timeout... well the test will fail. โ˜น๏ธ

Measure your code

Measurements are a great way to speed up slower parts of your application. If you have a method that seems to be slow, you can put it inside a measure block to check its timings. You can also iterate the method a few (hundred) times, this way you can get a better baseline number.

func testSlowMethod() {
    self.measure {
        for _ in 0..<100 {
            slowMethodCall()
        }
    }
}

If you want to know more about measurements you should read this amazing article by Paul Hudson about how to optimize slow code.

Run your test & set a baseline by clicking the little gray indicator on the left. Next time your test runs you'll see how much the code improved or depraved since last time.

When to write tests?

Writing a test takes time. Time is money. โณ = ๐Ÿ’ฐ

I don't have time for tests (press CMD+R to test a feature...)

On the other hand it can also save you a lot of time, since you don't have to rebuild / rerun your entire application just to see if a function works or not. In the long term, it's definitely worth it to have those tests for at least the business logic of your application. In the short term you might say you don't have the time, but after a few hundred manual tests you might find yourself in a situation like this:

Oh man here we go again... (presses CMD+R to test a feature...)

TDD vs the regular approach?

I'd say if you have a really good specification and you already know how you are going to build up your application it's safe to go with TDD or even BDD. Otherwise if the project is "agile" (that means you'll have constant changes in your requirements) it can lead you to put a lot of work into fixing your code AND your test cases.

When to test?

Anyway, if you made the decision and you are ready to write your tests, here is my nr.1 tip: don't move on developing the next feature until youโ€™ve reached your desired coverage, otherwise you'll have a huge tech debt in the future. I know this for sure, I've been there, you don't want to make this mistake, because it's not fun to write hundreds of unit tests at once. Even a few really basic tests for a single functionality is better than zero.

What to test?

I'd say that you should test mainly your business layer using unit tests. This can be a presenter, or specific functions inside a view controller, a manager or a service. It really doesn't matter where the implementation is, but until it does some kind of "calculation" it's always good to have test cases to validate that piece of code.

Also you can't simply cover everything with unit tests, sometimes you want to check some features that require user interactions...


UI testing in practice

Now that you have a better understanding about how unit testing works, let's talk about UI tests. These kinds of tests are really useful if you don't want to spend your time with the boring repetitive task of trying out stuff on your phone all the time during development. Good news is that the iOS simulator can take care of most of that.

The anatomy of a UI test target in Xcode

In order to be able to run UI tests, you should set up a new target for your project. Here is a quick guide that'll show you the process if you don't know how to do it.

import XCTest

class TestUITests: XCTestCase {

    override func setUp() {
        // Put setup code here. This method is called before the invocation of each test method in the class.

        // In UI tests it is usually best to stop immediately when a failure occurs.
        continueAfterFailure = false

        // In UI tests itโ€™s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
    }

    override func tearDown() {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    func testExample() {
        // UI tests must launch the application that they test.
        let app = XCUIApplication()
        app.launch()

        // Use recording to get started writing UI tests.
        // Use XCTAssert and related functions to verify your tests produce the correct results.
    }

    func testLaunchPerformance() {
        if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) {
            // This measures how long it takes to launch your application.
            measure(metrics: [XCTOSSignpostMetric.applicationLaunch]) {
                XCUIApplication().launch()
            }
        }
    }
}

A UI test bundle is dynamically injected into your application, so you can't simply initiate a view controller instance for example, but you have to use a special way to reach your UI elements and perform actions on them through:

Accessibility

Every single UI element has an accessibilityIdentifier property by default. It's a string that uniquely identifies that view. Later on you can query the view instance by using the previously associated accessibility identifier, which comes in really handy since you are not allowed to initiate views directly.

There is a special XCUIApplication (a reference to your running application) object that has some helpful properties to query references to various user interface elements, such as buttons, images, collection views, etc., you can check the entire accessibility cheat-sheet here, but I'll show you some examples later on.

Ok, but what happens when I don't want to use accessibilityIdentifiers? Can I simply capture the UI hierarchy somehow and do some actions without coding?

Should I record my UI tests?

Well, the thing is that you can just press the record button in Xcode and the system will capture all your user interactions automatically, but please don't do that.

Why do I prefer coding? Well:

  • the UI testing API is pretty simple
  • writing complex UI tests will be way faster if you learn the accessibility API
  • sometimes you won't be able to capture what you want using the recorder
  • using identifiers instead of captured labels are better (for localized tests)
  • you don't always want to get through the same process again and again
  • learning new things is fun, a new API means more knowledge! ๐Ÿ˜€

Let's check out how simple it is to write some UI tests...

Writing iOS UI tests programmatically

I prefer to use accessibility identifiers for multiple reasons. It's not just a really nice thing to make your app available for a much wider audience, but if you set up every element properly accessible, writing UI tests will be a piece of cake, since you can query everything by its unique identifier. Let me show you a quick example.

// in your application target...
class ViewController: UIViewController {

    @IBOutlet weak var resultLabel: UILabel!
    @IBOutlet weak var inputField: UITextField!
    @IBOutlet weak var submitButton: UIButton!

    override func viewDidLoad() {
        super.viewDidLoad()

        self.resultLabel.accessibilityIdentifier = "result-label"
        self.inputField.accessibilityIdentifier = "input-field"
        self.submitButton.accessibilityIdentifier = "submit-button"
    }
}

// in your ui test target...
private extension XCUIApplication {
    var inputField: XCUIElement { self.textFields["input-field"] }
    var submitButton: XCUIElement { self.buttons["submit-button"] }
}

class TestUITests: XCTestCase {

    func testSubmitValue() {
        let app = XCUIApplication()
        app.launch()

        let expectation = "Hello world"
        app.inputField.tap()
        app.inputField.typeText(expectation)
        app.submitButton.tap()

        XCTAssertTrue(app.staticTexts[expectation].exists)
    }
}

As you can see I extended the XCUIApplication class, since I don't want to deal with identifiers again and again. It's one of those good / bad habits I picked up since I had to write lots of UI test cases. I'm not 100% sure about it yet, maybe there is a better approach but for me it was quite convenient and turned out to be really helpful. It's also private anyway so no one else can see it. ๐Ÿคซ

Querying available user interface elements is as simple as using these extension properties, which is ridiculously convenient. You can use the available methods and properties on these XCUIElement instances, such as exists, tap, typeText, however you can find some other challenges during the road:

Handle alerts

The first obstacle for me was interacting with iOS alert windows. Fortunately Keith Harrison has a great article about handling system alerts in UI tests. You should definitely check if you are running into the same issue.

Scroll to cell

Another deal breaker is to simulate scrolling behavior. Since accessibility actions are limited to taps and basic UI actions, this snippet helped me a lot.

User input

Entering user input can be quite challenging, since you have to focus on the input field first, but only if the field is not selected yet, so be careful. You should also note that plain text fields and secure text fields are separated into two distinct properties in the XCUIAppliaction object. Also placeholders are kind of tricky ones, because the placeholderValue property changes if you enter some text. โš ๏ธ

Change system preferences

One funny thing that you can do with UI testing is to change a system preference by altering iOS settings. Here you can check how to interact with the settings app.

How to write UI tests?

Well, there is no definitive answer, because your user interface is unique to your application. Really it all depends on you and your designer, but for me the key factor was using accessibility identifiers and getting used to the accessibility queries and APIs. I think it was worth learning it, I can only encourage you to get familiar with the framework and play around just a few hours, you won't regret it. ๐Ÿ˜‰

When to write UI tests?

It depends (don't hate me). Nowadays, I prefer to have a test for all the features I'm working on, but sometimes I realize that I'm simply tapping through my app doing manual testing. Old habits die hard, right? ๐Ÿ˜‚

Ah forget it, just write tests if you want to save time in the long term. In the short term you'll only see that everything takes longer to achieve, but trust me if it comes to bugs or unexpected behaviours, having those tests will pay out hugely. ๐Ÿ’ต


Continuous integration and testing

In this section I'm going to focus (a little bit) on Bitrise, since I think they provide the best CI service on the market for iOS developers right now. ๐Ÿ“ฑ

Test reports

They are working on some cool new (beta) features called Add-ons. One of them is focusing on test reports. This means that you can see the outcome of your tests straight from the build dashboard. The report screen will provide you with a quick summary of all the successful and failed tests results, but you can filter them by status manually or check individual test cases as well. Super nice, I love it. โค๏ธ

Bitrise test results

Code coverage

In computer science, test coverage is a measure used to describe the degree to which the source code of a program is executed when a particular test suite runs.

Some say it's nothing just a number or a bunch of numbers. Well, if it's true then why do people use analytics software all the time? I think code coverage should be enabled in every single Xcode project by default.

There are some tools called xccov and slather. If you run them on the CI and make the report available for your QA team, they can literally see which part of the app needs more testing and what is already checked by the automated tests. I think this is a really valuable insight, but unfortunately most companies don't "have the resources" for dedicated QA (and UX) teams. ๐Ÿง 

Pull requests & automated code review

Another nice thing is that you can run your tests before you merge your changes into a specific branch. This way you can ensure that nothing serious is broken in the project. Automated tests and code review is a must when it comes to teamwork.

It really doesn't matter if your team is very little or a huge one working on an enormous codebase, safety is always safety. Start utilizing the power of automation today, don't waste your time on boring, repetitive tasks. It'll help your team a lot. ๐Ÿ‘


Unit / UI testing best practices

Again I have to give some credit to Vadim, since he collected a nice list of unit testing best practices on iOS with Swift. My list will be a little bit different...

Always run tests in parallel

As I mentioned before, you should use parallel testing in order to speed up the whole process. This means that you can't share states between tests, which is a good thing. Don't be afraid, just initialize a new "environment" for every single time for your SUT (system under testing). If you don't know how to set up parallel testing, you should read this article.

Use the new test plans format

There is a new thing in Xcode 11 called test plans. It adds better support for testing multiple localisations, arguments, environment and so much more. I don't want to write down the method of converting to the new format, because there is another blog post written about Test Plans in Xcode 11, I highly recommend it.

Use mock / fake / stub / spy objects

Don't use development / production environments, but implement your own simulated service layers (API, sensors e.g. CoreLocation, Bluetooth, etc.). You can use a factory design pattern or dependency injection to achieve this behavior. This way you can control the returned data and you don't have to wait for laggy networks. Using a controlled environment for tests is the right way to go. Feel free to use as much mock / fake / stub / spy object as you need, return errors or failures if needed and keep your delays and timeouts low to finish your tests faster.

Only launch a specific screen for UI testing

If you have a gigantic application with lots of screens you don't usually want to run through the entire login / onboarding procedure that you usually have to during a clean start, but you just want to test the screen that you are working on. Is this possible? Can I do that? Is that even legal? ๐Ÿค”

Haha, totally! In order to launch a custom screen first you have to prepare your app target to support it. You can use the launch arguments or the process info property to check if a specific key exists and present a screen based on that. This requires a little bit more work, also you should be able to identify all your screens somehow if you want to do this, but that's why we have routers, am I right? #viper

Let me show you really quickly how to make it work.

import UIKit

func scene(_ scene: UIScene,
           willConnectTo session: UISceneSession,
           options connectionOptions: UIScene.ConnectionOptions) {

    guard let windowScene = scene as? UIWindowScene else {
        return
    }
    let window = UIWindow(windowScene: windowScene)
    let processInfo = ProcessInfo.processInfo
    var moduleName = "Main"
    if processInfo.arguments.contains("-test") {
        if let name = processInfo.environment["module"] {
            moduleName = name
        }
    }
    window.rootViewController = UIStoryboard(name: moduleName, bundle: nil).instantiateInitialViewController()
    self.window = window
    self.window?.makeKeyAndVisible()
}

In your SceneDelegate.swift file you have to instantiate your own screen (or should I call it module?) and pass it to the window. The snippet above does the exact same thing. First it checks if the processInfo contains the -test flag, if that's true it'll try to load the given module from the environment.

It's pretty simple, but still we have to add support for launching our application from the UI test target with these parameters. Let's make an extension for that.

import XCTest

extension XCUIApplication {

    static func launchTest(module: String = "Main") -> XCUIApplication {
        let app = XCUIApplication()
        app.launchArguments = ["-test"]
        app.launchEnvironment = ["module": module]
        app.launch()
        return app
    }
}

Here is how to use the extension in practice:

func testHello() {
    let app = XCUIApplication.launchTest(module: "Hello")

    XCTAssertTrue(app.staticTexts["Hello"].exists)
}

By using this approach you can start with a given screen, but still you might need to fake / mock / stub some services or properties, since it's not an ordinary application launch. Keep in mind that you can pass multiple environment variables around, and you can always check the arguments if it contains a given flag. ๐Ÿ˜‰

Speed up your UI tests by disabling animations

This one can really save you a lot of time and it's very easy to implement.

if processInfo.arguments.contains("-test") {
    UIView.setAnimationsEnabled(false)
    self.window?.layer.speed = 100
}

See? Just a few lines of code, but the speed impact can be vast. ๐Ÿš€

Designing your code for testability

Anything is better than spaghetti code. MVP can be well tested, but some nicely architected clean MVC pattern can work as well. I like the concept of presenters, since they encapsulate business logic and usually the presentation logic can be tested without a hassle. Moving one step further...

VIPER

Here comes the "insert abbreviation here" vs the overcomplicated VIPER architecture holy war again... nope, not today. I really don't care about your architecture of choice. What I care about is testability, but you can't really test a method that has a high complexity rate.

If you want to have good test coverage, it's important to separate your code into smaller chunks that can work together well. I always see bad sample codes with so many side effects, bad function APIs and many more ugly practices. I know that piece of code sometimes has historical reasons, but other times the programmer was just simply a lazy bastard. ๐Ÿ˜ด

Always think about your future self (and other programmers). Imagine that you have to go back to your code in a year or two... if you write lines after lines without thinking (and I could also say comments, docs, test cases here as well) you might have a lot of problems later on, because no one will be able to figure out what you wanted to achieve.

Breaking your code into smaller functional pieces is the most important takeaway here. This approach will also be beneficial for your API designer mindset and the testability of your codebase.

If you are using VIPER you have a really easy task to do. Write unit tests only for your presenter and interactor methods. Of course if you are following my service layer based VIPER approach (Vapor is doing this as well and it's amazing) you should test your entire service layer API. It doesn't make too much sense to unit test a UI component, or a routing behavior, you can use the UI test bundle for those tasks.

Work & test locally, "regression" test on CI

I prefer to only run those tests locally that I'm working on. Everything else belongs to the continuous integration server (CI). I don't want to waste my time and hardware by running 500 tests on my mac, there is no need for that. Setup a CI service and run your tests there whether it's regression, acceptance or integration.

You can also run tests from the command line, plus if you need to, you can pass around environment variables, for example if you have to deal with sensitive data.


Conclusion

Some people love to write tests and some people hate, but one thing is for sure: tests will make your code safer and more reliable. If you have never written any tests before I'd say you should start forming a habit and start it now. The entire continuous integration and delivery industry was born to help you with this, so don't waste your precious time... oh and start using Bitrise! ๐Ÿค–

Picture of Tibor Bรถdecs

Tibor Bรถdecs

Creator of https://theswiftdev.com (weekly Swift articles), server side Swift enthusiast, full-time dad. -- Follow me & feel free to say hi. ๐Ÿค˜๐Ÿป -- #iOSDev #SwiftLang

Twitter · GitHub

๐Ÿ“ฌ

100% Swift news, delivered right into your mailbox

Subscribe to my monthly newsletter. On the first Monday of every month, you'll get an update about the latest Swift news including my articles and everything what happend in the Swift community as well.