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.
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.
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:
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!
.
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