Writing Unit Tests for SwiftUI Views

Ensure your apps are working perfectly fine

Axel Hodler
Better Programming
Published in
4 min readJan 28, 2021

hand holding a card labelled “run a usability test”
Photo by David Travis on Unsplash

Unit tests help us to make sure our app works as intended without having to start the app and navigate manually through it after every change. In comparison with UI tests, they test single views in isolation instead of a full user flow. They are a lot faster and more focused.

To start off easy we use ContentView. It’s initially generated when creating a new project and draws the string “Hello, world!” in the center of the screen. It’s SwiftUI code.

How would we test whether it draws the string “Hello, world!”? One way is to create a UI test as one could have done for UIKit.

Another approach is to use the library ViewInspector.

ViewInspector is a library for unit testing SwiftUI views. It allows for traversing a view hierarchy at runtime, providing direct access to the underlying View structs.

Test Static View

In a test, we first create the subject under test. In our case, that’s ContentView. We access the text of the Text view via inspect().text().string() and are able to assert on it. The test passes.

ContentView adopts the Inspectable protocol to enable the magic of the library to do its work.

Let’s add some additional elements to our ContentView. Aside from “Hello, world!”, there will be a navigationTitle stating “Greetings”.

If we rerun our initial test, it will fail.

error message

Indeed inspect().text() will fail. The view hierarchy has changed. It should be inspect().navigationView().text(0).

text(0) means the Text view is the first element in our NavigationView. The test passes.

Let’s take a step back. The user was still greeted with "Hello, world!", but the test needed to be changed because the view hierarchy has changed. The test is tightly coupled to the view. The act of changing the test when the view changes is something often used as an argument against testing.

To alleviate the issue, we extract a GreetingsView.

Breaking up our views into smaller building blocks is a great practice — for readability, composability, and as we will soon see, for testing purposes.

The extracted GreetingsView

We add a test:

It looks pretty much the same as our initial ContentView and ContentViewTests. We start using the new GreetingsView inside ofContentView.

Our Text view is now part of GreetingsView. It’s time to adapt the tests for ContentView.

Both our GreetingsViewTests and ContentViewTestspass. One issue though: We test the same thing twice. Both ContentViewTests and GreetingsViewTests test whether the user is greeted with "Hello, world!”, with a test greetsWithHelloWorld.

It’s test duplication. Instead, we can test whether the GreetingView is present in ContentView in ContentViewTests. Then we can test the wording the user is greeted with in GreetingsViewTests. We change the ContentViewTests to reflect that change.

To sum it up, we:

  1. Arrange: Create the view under test
  2. Act: Navigate the view hierarchy to get the element we are interested in
  3. Assert: Use assertions on the element

Adding Logic

Until now we have only tested static views. There might be an argument to make that testing static views makes no sense. After all, we can trust SwiftUI to display the text "Hello, world!" when using the view Text("Hello, world"). We could still use the tests as specifications to tell us what the view does, though. Instead of rendering the view or reading the implementation, we can read the names of the tests.

Besides, the views might not stay static for long. Logic is introduced and the view becomes dynamic.

Say we need two greetings, one for logged-in users and another for guests. UserState is created to store whether the user is loggedIn and the userName.

We use the new UserState in GreetingsView. Let’s start with passing it into the constructor for testability.

And we change the tests to adapt to the new UserState.

If we have a loggedIn user with the name Peter, he will be greeted with Hello, Peter!. If the app is used by a non-authenticated user, a guest, we will display Hello, world!.

Using EnvironmentObject

What if we move the state into an @EnvironmentObject because we will use it in multiple views in our app?

Now UserStateadopts ObservableObject.

The EnvironmentObject is created in our @main.

Then it’s used in GreetingsView.

We need to add the didAppear functionality to allow ViewInspector to test the view. A synchronous test would not work. The tests look as follows:

Having to use asynchronous test syntax seems weird at first but is required. We would need to use an asynchronous test for @State and @Environment too. It’s not required for @ObservedObject or @Binding but should still be used for test consistency reasons.

Some might have an issue with having extra code for testability in the GreetingsView. Personally I think testability is a lot more important than the implementation aesthetics of the view.

Maybe Apple will offer a native testing solution for SwiftUI views in the future? Maybe we will have new tooling creating an initial test for every new view we create? Let’s see!

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Axel Hodler
Axel Hodler

Written by Axel Hodler

Building things. Usually by writing code. www.hodler.co. Software Engineering @porschedigital

Responses (2)

Write a response

you can try my improvements on view testing. i managed to simplify approach from Alex, no inspection needed, and also I managed to make async/await tests

What is the benefit of unit testing a view that may go through iterations in the future. You’d always have to manually update tests when the UI changed. I think screenshots tests would be the best approach here, as you can easily update the source of truth as the UI changes and visually see a diff.