SwiftUI Testing -- Part the Second
We’re continuing our look at unit testing with SwiftUI. In Part One, we looked back at how we tested UIKit and what approach makes sense to validate our SwiftUI projects.
For unit testing, test coverage is an important metric. How much of your code base are you exercising during unit tests? Xcode is, unfortunately, not too bright when it comes to measuring coverage. It doesn’t have the intelligence to know if you’re “testing” a line of code, just that the line of code was executed. This is a problem right off the bat.
I’ve got a new app project in Xcode. The app doesn’t do much yet and I want to start adding tests before it gets too big. So I add a unit test target and Xcode plops in some empty tests. I run the unit tests, and they all pass since they’re empty. I check the coverage and it’s at 36%! How can that be, I didn’t test anything?
Xcode, the blissful idiot, is really reporting that 36% of the code was executed while running the unit tests. Usually, when an app starts up, it does some bootstrapping. You might set up your persistent storage, draw a couple of Views on screen, and maybe talk to the network. Xcode counts all that as “testing” because it ran during a unit test.
To make the test coverage more accurate, we need to do as little as possible outside of our actual unit tests. Jon Reid has a write-up of how to do this with a UIKit app by swapping out the app delegate during tests. But SwiftUI introduces a whole new startup sequence so we need a new approach for SwiftUI’s new app lifecycle.
After some futzing around, turns out it’s easy!
1struct TestApp: App { // 1
2 var body: some Scene {
3 WindowGroup {
4 Text("I'm running tests!")
5 }
6 }
7}
8
9@main // 2
10struct TestDriver {
11 static func main() {
12 if NSClassFromString("XCTestCase") != nil { // 3
13 TestApp.main()
14 } else {
15 MyRealApp.main()
16 }
17 }
18}
There are three key points that make this work:
- We need a dummy `App` struct to use instead of the real app. This simple stand-in circumvents all your usual app startup machinery. Instead of all the normal bootstrapping, we'll just get a window with the text "I'm running tests!".
- Remove the `@main` from your `App` implementation and add it here to `TestDriver`. Swift uses `@main` to figure out how to start your app. The `App` protocol provides a default implementation that, according to the docs, 'manages the launch process in a platform-appropriate way'. But by inserting our own wrapper layer here around, we can control _which_ `main()` is called.
- That brings us to the final point, use the good old `NSClassFromString` to decide if the testing bundle has been injected into our process. `XCTestCase` is only available during testing, so this is a reliable way to decide if unit testing is underway. Based on that, we can call the `main` method of either our real app or our testing stand-in. It turns out that the default implementation of `main` knows to use its parent struct to bootstrap the SwiftUI app.
Now when I run unit tests, my coverage is at 0.8%! That’s more like it. In order to boost my test coverage, I now have to actually test code. And the code coverage metric really starts to mean something.
Follow us on Mastodon and LinkedIn for more info Swift, SwiftUI and software testability. And if you’re interested in help with testing your existing app or building a whole new app, don’t hesitate to get in touch. We’d love to hear from you.