Preamble To celebrate the conclusion of our 7-part series on “Modern SwiftUI,” we are releasing a blog post each day this week exploring a modern, best practice for SwiftUI development. In the final installment we will show that when the advice is followed from the previous 4 posts, you get the ability to write deep, nuanced tests.
We conclude our week of “Modern SwiftUI” blog posts by discussing what we feel is the most important topic when it comes to maintaining a modern code base: testing. Thanks to the work of integrating parent and child features together, concisely modeling our domains, and controlling our dependencies, it is possible to write deep, nuanced tests quite easily.
In our series on “Modern SwiftUI” we rebuilt Apple’s “Scrumdinger” application from scratch, and we made sure to write an extensive suite of unit tests, exercising many nuanced user flows that execute effects and complex logic.
For example, we have a test that determines what happens when the application starts up and the previously saved data on disk can’t be loaded. This helps us get test coverage on if the data file got corrupted somehow.
We can can accomplish this thanks to us having controlled our dependence on the file system by modeling a dataManager
interface. We can override this dependency in tests to force it to load nonsensical data:
func testLoadingDataDecodingFailed() throws {
let model = withDependencies {
$0.mainQueue = .immediate
$0.dataManager = .mock(
initialData: Data("!@#$ BAD DATA %^&*()".utf8)
)
} operation: {
StandupsListModel()
}
…
}
The StandupsListModel
will now execute in an altered environment where loading data from the disk fails. To confirm our feature’s logic handles this correctly we can confirm that an alert it shown. Recall that navigable destinations for a feature are modeled as an enum, and note that our case paths library comes with a testing tool for extracting a specific case from an enum:
let alert = try XCTUnwrap(
model.destination,
case: /StandupsListModel.Destination.alert
)
XCTAssertNoDifference(alert, .dataFailedToLoad)
If this assertion passes then it is proof that the alert showed to the user, and that its contents matches what is held in .dataFailedToLoad
.
Further, that alert gives the user an option to load some mock data just to get something back on the screen, and we can confirm that functions properly too:
model.alertButtonTapped(.confirmLoadMockData)
XCTAssertNoDifference(
model.standups, [.mock, .designMock, .engineeringMock]
)
For a more complicated example, the following test exercises the flow of drilling down to a standup, tapping its delete button, confirming an alert is shown, and then confirming deletion. The test will confirm that we are popped back to the root and the standup is deleted from the root list:
func testDelete() async throws {
let model = try withDependencies { dependencies in
dependencies.dataManager = .mock(
initialData: try JSONEncoder().encode([Standup.mock])
)
dependencies.mainQueue = mainQueue.eraseToAnyScheduler()
} operation: {
StandupsListModel()
}
// 1️⃣ Simulate the user tapping on a row in the standups list
model.standupTapped(standup: model.standups[0])
// 🗣️ Assert the detail screen for the standup appears
let detailModel = try XCTUnwrap(
model.destination, case: /StandupsListModel.Destination.detail
)
// 2️⃣ Simulate the user tapping the delete button
detailModel.deleteButtonTapped()
// 🗣️ Assert an alert shows asking the user to confirm deleting
// the standup
let alert = try XCTUnwrap(
detailModel.destination,
case: /StandupDetailModel.Destination.alert
)
XCTAssertNoDifference(alert, .deleteStandup)
// 3️⃣ Simulate the user confirming deletion
await detailModel.alertButtonTapped(.confirmDeletion)
// 🗣️ Assert the detail screen is popped off the stack and that
// the standup is removed from the list.
XCTAssertNil(model.destination)
XCTAssertEqual(model.standups, [])
XCTAssertEqual(detailModel.isDismissed, true)
}
This test is incredibly nuanced and covers what the user will actually see on the screen since state drives navigation (as long as the view is hooked up properly).
And it runs in a fraction of a second (usually less than 0.01 seconds!). Typically you can run hundreds (if not thousands) of these kinds of tests in the time it takes to run a single UI test.
Speaking of UI tests, we also have one of those. We don’t recommend focusing all of your attention on UI tests, since they are slow and flakey, but it can be good to have a bit of full integration testing, and so we wanted to show how it is possible.
To run a UI test with controlled dependencies you need to somehow communicate to the app host, which unfortunately runs in a fully separate process. One way to do this is to set an environment variable in the setUp
of the UI test:
override func setUpWithError() throws {
continueAfterFailure = false
app.launchEnvironment = [
"UITesting": "true"
]
}
Then check for the presence of that environment variable in the entry point of your application so that you can override the dependencies used, such as the data manager:
@main
struct StandupsApp: App {
var body: some Scene {
WindowGroup {
if ProcessInfo.processInfo.environment["UITesting"] == "true"
{
withDependencies {
$0.dataManager = .mock()
} operation: {
StandupsList(model: StandupsListModel())
}
} else {
StandupsList(model: StandupsListModel())
}
}
}
}
With that setup we were able to write a test that exercises the flow of adding a new standup from a modal sheet. Sadly it takes about 10 seconds to run, whereas the corresponding unit is about 400 times faster at just 0.025 seconds. But, having some test coverage on the true integration layer of SwiftUI can help round out your suite.
We hope that you find some of the topics discussed above exciting, and if you want to learn more, be sure to check out our 7-part series on “Modern SwiftUI.”
We do have a favor to ask you. While we have built the Standups application in the style that makes the most sense to us, we know that some of these ideas aren’t for everyone. We would love if others fork the Standups code base and rebuild it in the style of their choice. We even have a dedicated repo with the codebase ready to go. 😁
Don’t like to use an ObservableObject
for each screen? Prefer to use @StateObject
instead of @ObservedObject
? Want to use an architectural pattern such as VIPER? Have a different way of handling dependencies? Please show us!
We will collect links to the other ports so that there can be a single place to reference many different approaches for building the same application.
Well, that’s the end of our blog-post-a-day covering modern, best practices in SwiftUI application development. We highly recommend checking out our Standups open source application to see how all of the ideas can be put to use in a real world, complex application.
And if you want even more in-depth coverage of these topics, then consider subscribing today to get access to the full series, as well as our entire back catalog of episodes!