Level Up Your Career by Adding UI Tests to Your SwiftUI App

Chase
10 min readApr 8, 2024

Adding tests to your app is a great way to grow yourself and your career. In this tutorial, we will go over how to add User Interface — sometimes called end-to-end tests — to your app.

An image of the testing pyramid with UI highlighted next to the word Testing

Before we get started, please take a couple of seconds to follow me and 👏 clap for the article so that we can help more people learn about this useful content.

First, why should we write UI tests

Talking to many developers or testing engineers, you may learn about the testing pyramid (as seen in the image below). The theory they may tell you is that we want a lot of test coverage for unit tests, a little less for integration tests, and still fewer for UI tests. They may also say that UI tests are the most expensive tests we write, which is why we want fewer of them. I however, have a different opinion.

While those statements may be true for more senior developers or more mature products, if you are like most developers you may not have written many (or any) tests. Don’t worry, UI tests are an easy way to get started, plus being able to add testing to your résumé can help you get interviews in higher level developer roles.

Most developers will go through their code and manually make sure everything is working as expected. UI tests do the same things you do to test your app (by clicking buttons, filling out fields, and seeing what works and what doesn’t), it just does it faster since it’s a computer. They only require a minimal change to your code (as simple as adding a modifier to a view) and they don’t require any major changes to your app. This makes UI tests the perfect place to start adding tests to your app.

An image of the testing pyramid. UI Tests are on top, followed by Integration Tests, followed by Unit Tests
A simple representation of the testing pyramid

Initial Setup

Before we start writing any tests, we will need something to test. In this tutorial we will be using the following simple bill splitter app. In the code you may notice that we are using our currency formatting style from a previous tutorial. I have also added all the views and logic that we will need to make this code work. We want the calculate button to be disabled if the number is negative and we want to be made aware if we accidentally remove or change the disabled modifier on the calculate button.

//  ContentView.swift
import SwiftUI

struct ContentView: View {
@State var amount: Decimal = 0
@State var numberOfPeople = 1
@State var totalPerPerson: Decimal = 0

var body: some View {
NavigationStack {
VStack {
Form {
Section {
TextField("Total", value: $amount, format: .currency(code: "USD"))
Picker("Number of People", selection: $numberOfPeople) {
ForEach(1..<11) { number in
Text(number, format: .number)
.tag(number)
}
}
}

Section {
Text(totalPerPerson, format: .currency(code: "USD"))
} header: {
Text("Total per person")
}
}

HStack {
Spacer()
Button("Calculate") {
totalPerPerson = calculateTotalPerPerson(amount, numberOfPeople)
}
.buttonStyle(.borderedProminent)
.disabled(amount <= 0)
}.padding()
}
.navigationTitle("Bill Splitter")
}
}

func calculateTotalPerPerson(_ amount: Decimal, _ numberOfPeople: Int) -> Decimal {
return amount / Decimal(numberOfPeople)
}
}

#Preview {
ContentView()
}

When we run this code the app works, and our screen looks like the following image:

a screenshot of the app we are testing in this tutorial

Basics of UI Tests

We will need to add the UI Testing bundle to our project. To do this, we will go the the Xcode menu at the top of the screen and choose “File” then “New” then “Target”. Once the window opens, we will search for “UI” and choose UI Testing Bundle (see the example image below).

A screenshot of the UI Testing bundle that we are about to add to our project

Now we have a new folder in our project navigator, and we are ready to add our first test. If we go into the first test file that gets created for us, we will see that we already have tests we can run (even though they don’t do much yet).

A screenshot of the default tests that you get for free when we add a UI testing bundle

We can add our own tests by right clicking on the UI Testing Bundle folder, and choosing “New File” then searching again for “UI” to create our own custom UI testing file.

A screenshot of what it looks like to add a new UI Test class to our project

For now though, let’s stick with the default file we were given and look at some of the code and the interface. Starting at the top of the image below, the purple diamond is highlighting the Test Navigator button. Clicking this button will show us all the tests in our project.

The blue square is highlighting the button that we can click to run a single test. Xcode will recognize a function as a test it can run if the function starts with the word “test”. Clicking the diamond on line 10 next to the words “final class” will run all the tests in the class.

Clicking the green circle at the bottom of the screenshot will launch a simulator that will record our interactions with the app and add that code to whatever test our cursor is currently in. This option has been known to have issues in the past. If you can’t click the button or if the recording starts putting things in the wrong order you can try closing and reopening Xcode to try the recording again.

The testExample function simply launches the app. Every UI test will launch the app, and proceed through the app testing the various components that we want to test.

a screenshot of the UI testing interface in Xcode

Let’s click the Test Navigator icon (purple diamond) to run all our tests. If we hover our cursor over the first item in the list (which in our project is a Test Plan, if you are using an older project you may have to update your project to include a test plan), you will see a small diamond appear next to the name. Clicking that diamond will run the sample tests that were automatically generated, and all those tests should pass as indicated in the image below by the diamond turning green and a check mark appearing inside the diamond.

A screenshot of our tests passing

After the tests have run, we can click the Report Navigator button (the one that looks like a list of items) to see what the coverage was for our tests. We can even expand the sections to see the coverage for an individual block of code or go back into our code and see the lines that were covered and which ones were not.

A screenshot of the coverage report with some of the sections expanded showing the coverage for each section of code

Now that we have walked through what we get for free, let’s add a few of our own tests.

Writing our own UI Tests

When I am testing this app manually (some times referred to as a smoke test), I will run the app, enter an amount, and make sure that the displayed split amount is what I expect it to be. Before we get to writing a test that does exactly that, we will add a simple modifier to each of the views we want to either click on or inspect. This modifier is the accessibilityIdentifier.

Even though it has accessibility in its name, you don’t have to worry about this negatively affecting the accessibility functions of your app. This identifier is only used by the system to target specific views in our UI. It isn’t read by screen readers and isn’t made available to any kind of voice controls.

Since we want to interact with the form, total field, values of our picker, calculate button, and make sure the total per person is correct, we will add the accessibilityIdentifier to these fields. The values we place inside this modifier (like any other ID) should be unique. Placing a non unique ID in these modifiers will possibly result in a failed test. The code below has already been updated to include unique IDs for each item we want to interact with. Notice that none of our code has changed, and we didn’t have to modify any functions or do any major architecture changes. All we did was add a simple modifier to a view.

//  ContentView.swift
import SwiftUI

struct ContentView: View {
@State var amount: Decimal = 0
@State var numberOfPeople = 1
@State var totalPerPerson: Decimal = 0

var body: some View {
NavigationStack {
VStack {
Form {
Section {
TextField("Total", value: $amount, format: .currency(code: "USD"))
.accessibilityIdentifier("total")
Picker("Number of People", selection: $numberOfPeople) {
ForEach(1..<11) { number in
Text(number, format: .number)
.tag(number)
.accessibilityIdentifier("numberOfPeople\(number)")
}
}
}

Section {
Text(totalPerPerson, format: .currency(code: "USD"))
.accessibilityIdentifier("totalPerPerson")
} header: {
Text("Total per person")
}
}
.accessibilityIdentifier("billSplitterForm")

HStack {
Spacer()
Button("Calculate") {
totalPerPerson = calculateTotalPerPerson(amount, numberOfPeople)
}
.buttonStyle(.borderedProminent)
.disabled(amount <= 0)
.accessibilityIdentifier("calculate")
}.padding()
}
.navigationTitle("Bill Splitter")
}
}

func calculateTotalPerPerson(_ amount: Decimal, _ numberOfPeople: Int) -> Decimal {
return amount / Decimal(numberOfPeople)
}
}

#Preview {
ContentView()
}

Now that we have a way to access our views in our tests, we are ready to write the first test. We will start out writing a simple test to make sure we are on the correct screen.

func test_ensureWeAreOnTheCorrectScreen() throws {
let app = XCUIApplication()
app.launch()

XCTAssertTrue(app.navigationBars["Bill Splitter"].exists)
}

Another simple test is to make sure that the form is displayed on the page.

func test_ensureTheFormIsOnThePage() throws {
let app = XCUIApplication()
app.launch()

XCTAssertTrue(app.collectionViews["billSplitterForm"].exists)
}

Now that we have some very easy test out of the way, let’s check the basic operation of our view and make sure that the base functionality works as expected when we have valid inputs (this is referred to as Happy Path Testing).

func test_enteringAValidAmountAndClickingCalculate_displaysTheExpectedTotalPerPerson() throws {
// launch the app
let app = XCUIApplication()
app.launch()

// setup the keys we want the software keyboard to click
let collectionViewsQuery = app.collectionViews
let deleteKey = app.keys["delete"]
let moreKey = app.keys["more"] // this will change the keyboard from ABC to 123 or vice versa
let oneKey = app.keys["1"]
let zeroKey = app.keys["0"]

// select the total field
collectionViewsQuery.textFields["total"].tap()

// remove the existing text from the total field
deleteKey.tap()
deleteKey.tap()
deleteKey.tap()
deleteKey.tap()
deleteKey.tap()

// enter a new amount (of 100) in the total field
moreKey.tap()
oneKey.tap()
zeroKey.tap()
zeroKey.tap()

// change the number of people selected from 1 to 2
collectionViewsQuery.buttons["numberOfPeople1"].tap()
collectionViewsQuery.buttons["numberOfPeople2"].tap()

// click the calculate button
app.buttons["calculate"].tap()

// check the total per person amount to see if it equals the expected amount
XCTAssertEqual(collectionViewsQuery.staticTexts["totalPerPerson"].label, "$50.00")
}

That test covers how we expect the app should work under perfect conditions (the happy path), but what about under not so perfect conditions (the sad path)? What if the user tries to enter letters in the total field or negative numbers (since we have prevented that case in our calculate function)? We could write a couple tests to cover those scenarios to ensure our app works as expected.

func test_enteringANegativeAmountAndClickingCalculate_keepsTheSplitAtZeroAndCalcButtonIsDisabled() throws {
// launch the app
let app = XCUIApplication()
app.launch()

// setup the keys we want the software keyboard to click
let collectionViewsQuery = app.collectionViews
let deleteKey = app.keys["delete"]
let moreKey = app.keys["more"] // this will change the keyboard from ABC to 123 or vice versa
let oneKey = app.keys["1"]
let zeroKey = app.keys["0"]
let negativeKey = app.keys["-"]

// select the total field
collectionViewsQuery.textFields["total"].tap()

// remove the existing text from the total field
deleteKey.tap()
deleteKey.tap()
deleteKey.tap()
deleteKey.tap()
deleteKey.tap()

// enter a new amount (of -100) in the total field
moreKey.tap()
negativeKey.tap()
oneKey.tap()
zeroKey.tap()
zeroKey.tap()

// change the number of people selected from 1 to 2
collectionViewsQuery.buttons["numberOfPeople1"].tap()
collectionViewsQuery.buttons["numberOfPeople2"].tap()

// click the calculate button
app.buttons["calculate"].tap()

// ensure the calculate button is disabled
XCTAssertFalse(app.buttons["calculate"].isEnabled)

// check the total per person amount to see if it equals the expected amount
XCTAssertEqual(collectionViewsQuery.staticTexts["totalPerPerson"].label, "$0.00")
}

func test_enteringALetterInTheAmountAndClickingCalculate_keepsTheSplitAtZeroAndCalcButtonIsDisabled() throws {
// launch the app
let app = XCUIApplication()
app.launch()

// setup the keys we want the software keyboard to click
let collectionViewsQuery = app.collectionViews
let deleteKey = app.keys["delete"]
let aKey = app.keys["A"]

// select the total field
collectionViewsQuery.textFields["total"].tap()

// remove the existing text from the total field
deleteKey.tap()
deleteKey.tap()
deleteKey.tap()
deleteKey.tap()
deleteKey.tap()

// enter the letter "A" in the total field
aKey.tap()

// change the number of people selected from 1 to 2
collectionViewsQuery.buttons["numberOfPeople1"].tap()
collectionViewsQuery.buttons["numberOfPeople2"].tap()

// click the calculate button
app.buttons["calculate"].tap()

// ensure the calculate button is disabled
XCTAssertFalse(app.buttons["calculate"].isEnabled)

// check the total per person amount to see if it equals the expected amount
XCTAssertEqual(collectionViewsQuery.staticTexts["totalPerPerson"].label, "$0.00")
}

By adding only 3 tests to our UI testing bundle, we were able to bump our test coverage up from an already great 94% coverage to an amazing 100% test coverage! We were even able to cover the calculate function that was written in our view file all without any unit or integration tests.

A screenshot of our test results showing 100% code coverage

Now we can rest easy knowing that our app does what we expect it to, we can check that everything works by running the tests (the default shortcut is CMD+U to run all the tests in a project), other developers can more easily see what our app is expected to do, we can add testing to the list of skills on our resumes, and we did it all with almost no changes to our the way our app previously existed.

If you got value from this article, please consider following me, 👏 clapping for this article, or sharing it to help others more easily find it.

If you have any questions on the topic or know of another way to accomplish the same task, feel free to respond to the post or share it with a friend and get their opinion on it. If you want to learn more about native mobile development, you can check out the other articles I have written here: https://medium.com/@jpmtech. If you want to see apps that have been built with native mobile development, you can check out my apps here: https://jpmtech.io/apps. Thank you for taking the time to check out my work!

--

--