axelhodler

Writing Unit Tests for SwiftUI Views

Unit tests help us to make sure our app works as intended. Without having to start the app and navigating 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 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.

Testing Static Views

In test test we first create the subject under test. In our case 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.

xcodeerror

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.

We add a test:

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

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

Both our GreetingsViewTests and ContentViewTests pass. 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 UserState adopts 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!

The post has been cross-posted on Medium