During the last few months, I’ve been reading “Unit Testing Principles, Practices, and Patterns” by Vladimir Khorikov. It’s definitely one of the best books I’ve read about testing. One of the things I’ve liked the most is that the author offers a “framework of reference” to analyze how good a test is based on the following traits:
- Protection against regressions.
- Resistance to refactoring.
- Fast feedback.
- Maintainability.
Those traits are what the author calls “the four pillars of a good unit test”. And the interesting part is that we cannot maximize all of them. Some types of tests provide the best protection against regression and resistance to refactoring but with very slow feedback (like UI testing). At the end of the day, we have to think and decide the best trade-offs for our specific application and use cases.
I’ve been thinking a lot about this and how to apply that framework to improve my testing, especially applied to the view layer, where things are especially tricky. And more specifically to SwiftUI, where we are still rethinking good practices and patterns to better fit this new exciting platform.
This article will not tell you “the best way to test SwiftUI code”. Rather, I’ll walk you through a simple example and the different ways we have at our disposal on iOS to test it, its trade-offs, and my subjective thoughts and observations.
Let’s go!
Initial code
The example application is simple but complex enough to showcase interesting testing techniques. It loads a bunch of todos from an API, shows them on the screen, and saves them to disk.
The code looks like this:
import SwiftUI
struct TodoListView: View {
@State private var state: ListViewState = .idle
private let databaseManager: DatabaseManager = .shared
var body: some View {
Group {
switch state {
case .idle:
Button("Start") {
Task {
await refreshTodos()
}
}
case .loading:
Text("Loading…")
case .error:
VStack {
Text("Oops")
Button("Try again") {
Task {
await refreshTodos()
}
}
}
case .loaded(let todos):
VStack {
List(todos) {
Text("($0.title)")
}
}
}
}.onChange(of: state) {
guard case .loaded(let todos) = $0 else {
return
}
databaseManager.save(data: todos)
}
}
private func refreshTodos() async {
state = .loading
do {
let todos = try await loadTodos().sorted { $0.title < $1.title }
state = .loaded(todos)
} catch {
state = .error
}
}
private func loadTodos() async throws -> [Todo] {
let url = URL(string: "https://jsonplaceholder.typicode.com/todos/")!
let (data, _) = try await URLSession.shared.data(from: url)
let todos = try JSONDecoder().decode([Todo].self, from: data)
return todos
}
}
enum ListViewState: Equatable {
case idle
case loading
case loaded([Todo])
case error
}
struct Todo: Codable, Identifiable, Equatable {
var userId: Int
var id: Int
var title: String
var completed: Bool
}
final class DatabaseManager {
static let shared: DatabaseManager = .init()
private init() {}
func save<T>(data: T) where T: Encodable {
// TBD
}
}
That code is far from ideal, but it gets the job done without overcomplicating things for now.
We want to test that:
- The initial screen layout is correct.
- The screen shows the correct feedback to the user while the information is being loaded.
- The screen shows feedback to the user in case an error arises.
- The screen shows the sorted list of todos to the user after downloading them from the network.
- The todos are correctly saved to disk.
We’ll test all those use cases with different tools, starting with the simpler one: using XCUITest.
UI testing via XCUITest
XCUITest is Apple’s first-party framework for creating UI tests. Using UI tests as our first approach in legacy code bases without tests is usually a smart choice. It doesn’t require any changes in the application, and it provides a safety net that allows further refactoring afterward, something that will be needed to have better unit tests.
The code looks like this:
import SnapshotTesting
import XCTest
final class TodoListViewUITests: XCTestCase {
func testIdle() {
// Given
let app = XCUIApplication()
app.launch()
// Then
assertSnapshot(
matching: app.screenshot().withoutStatusBarAndHomeIndicator,
as: .image
)
}
func testLoading() {
// Given
let app = XCUIApplication()
app.launch()
// When
app.buttons.element.tap()
// Then
assertSnapshot(
matching: app.screenshot().withoutStatusBarAndHomeIndicator,
as: .image
)
}
func testLoaded() {
// Given
let app = XCUIApplication()
app.launch()
// When
app.buttons.element.tap()
// Then
guard app.collectionViews.element.waitForExistence(timeout: 5) else {
XCTFail()
return
}
assertSnapshot(
matching: app.screenshot().withoutStatusBarAndHomeIndicator,
as: .image
)
}
}
private extension XCUIScreenshot {
// Let's get rid of both the status bar and home indicator to have deterministic results.
var withoutStatusBarAndHomeIndicator: UIImage {
let statusBarOffset = 40.0
let homeIndicatorOffset = 20.0
let image = image
return .init(
cgImage: image.cgImage!.cropping(
to: .init(
x: 0,
y: Int(statusBarOffset * image.scale),
width: image.cgImage!.width,
height: image.cgImage!.height - Int((statusBarOffset + homeIndicatorOffset) * image.scale)
)
)!,
scale: image.scale,
orientation: image.imageOrientation
)
}
}
Some interesting remarks:
- Combining UI tests with screenshot testing is really powerful, as it simplifies quite a lot the view assertion part. Screenshot testing has quite a few downsides that are out of the scope of this article, but they are extremely convenient.
- To make them work deterministically, we must remove both the status bar and the home indicator (yes, the home indicator sometimes changes between test runs ¯_(ツ)_/¯).
- We cannot test the error case as we don’t have an easy way to control the network.
- Not controlling the network means that tests are slow, relying on timeouts. If we run the tests without having an internet connection, they will fail, etc.
- The loading test is not deterministic. Depending on how fast the network and API response is, the snapshot will be done in the “Loading…” screen or in the loaded screen.
- The loaded test might as well not be deterministic, depending on the network result (loadTodos endpoint). At the moment, it returns the very same data every time, but that might change.
- We cannot test that data was stored in the database. The UI tests run in a different process than the test runner, so accessing the underlying FileManager‘s data is impossible.
- It’s really hard and cumbersome to prepare tests to go to the specific screen we want to test. The app opens from scratch, and we need to navigate to the screen under test first before performing assertions.
As you can see, we have many important problems with using XCUITest. It doesn’t seem like the best approach. And, of course, it shouldn’t. UI tests should cover just a small part of very critical business paths, as it’s the tests that more reliably mimic the user behavior.
But the most important problem is the time it takes to run the test suite, 15 seconds for just four tests. And that’s with a very good internet connection.
Yes, there are ways to use launchArguments and ProcessInfo to know that we are running UI tests and performing different tasks, like controlling the network and providing a less flaky experience. But that not only couples the main code with testing code, but it also worsens one of the other pillars of good unit tests, maintainability.
In summary, while UI tests are extremely good at catching regressions and resisting code changes and refactors, the slow feedback and bad maintainability make them only a good choice for a small number of tests, ideally, those covering really important, high-level use cases.
UI testing via EarlGrey 2.0
In the past, we have KIF, a good white-box UI testing alternative to XCUITest. While KIF still works, it has some limitations. The main one is that we cannot interact with alerts and other windows different from the main one. AFAIK this was the main reason why Google abandoned their first version of EarlGrey, which used a similar white-boxed approach to UI testing, in favor of leveraging the existing XCUITest platform, and adding some “magic” on top. In 2023, I think EarlGrey 2.0 is the only good alternative to XCUITest worth considering, IMO.
With EarlGrey 2.0, we can create a specific, properly configured view and set it as the window’s root view controller. To control the network and database, we need to do a small refactor of TodoListView.
- We’ll inject an async function to control the API call.
- We’ll inject an abstraction of the database manager, allowing us to inject the proper mock later.
import SwiftUI
struct TodoListView: View {
@State private var state: ListViewState = .idle
private let databaseManager: DatabaseManagerProtocol
private let loadTodos: () async throws -> [Todo]
init(
databaseManager: DatabaseManagerProtocol,
loadTodos: @escaping () async throws -> [Todo] = loadTodos
) {
self.databaseManager = databaseManager
self.loadTodos = loadTodos
}
var body: some View { … }
private static func loadTodos() async throws -> [Todo] { … }
}
protocol DatabaseManagerProtocol {
func save<T>(data: T) where T: Encodable
}
final class DatabaseManager: DatabaseManagerProtocol {
static let shared: DatabaseManager = .init()
private init() {}
func save<T>(data: T) where T: Encodable {
// TBD
}
}
With that in place, the tests look similar to the previous ones. The given methods will configure the TodoListView with the correct todos and a database spy that we can use to double-check that they have been saved correctly.
import XCTest
import SnapshotTesting
final class TodoListViewUITests: XCTestCase {
func testIdle() {
// Given
let app = XCUIApplication()
app.launch()
// Then
assertSnapshot(
matching: app.screenshot().withoutStatusBarAndHomeIndicator,
as: .image
)
}
func testLoading() {
// Given
let app = XCUIApplication()
app.launch()
let todosSaved = todoListViewHost().givenLoadingTodoListView()
// When
app.buttons.element.tap()
// Then
assertSnapshot(
matching: app.screenshot().withoutStatusBarAndHomeIndicator,
as: .image
)
XCTAssertFalse(todosSaved())
}
func testLoaded() {
// Given
let app = XCUIApplication()
app.launch()
let todosSaved = todoListViewHost().givenLoadedTodoListView()
// When
app.buttons.element.tap()
// Then
assertSnapshot(
matching: app.screenshot().withoutStatusBarAndHomeIndicator,
as: .image
)
XCTAssertTrue(todosSaved())
}
func testError() {
// Given
let app = XCUIApplication()
app.launch()
let todosSaved = todoListViewHost().givenErrorTodoListView()
// When
app.buttons.element.tap()
// Then
assertSnapshot(
matching: app.screenshot().withoutStatusBarAndHomeIndicator,
as: .image
)
XCTAssertFalse(todosSaved())
}
}
EarlGrey requires the notion of a “host”, which is a kind of proxy that lets us move between the two worlds: the “test runner process” world and the “app process” world.
As you can imagine, those two worlds cannot be crossed wildly. There are some limitations. We can only use types that are visible to the Objective-C runtime, as you can see from the @objc keyword in the TodoListViewHost protocol. Also, the different files must belong to very specific targets. The whole configuration is quite cumbersome.
This article is not intended to be an EarlGrey’s tutorial. There’s extensive documentation and examples on their web page if you are interested.
These are the two files needed for our example.
@objc
protocol TodoListViewHost {
func givenLoadedTodoListView() -> () -> Bool
func givenLoadingTodoListView() -> () -> Bool
func givenErrorTodoListView() -> () -> Bool
}
// Hosts cannot be reused across tests. Make sure to create a new one each time.
let todoListViewHost: () -> TodoListViewHost = {
unsafeBitCast(
GREYHostApplicationDistantObject.sharedInstance,
to: TodoListViewHost.self
)
}
import SwiftUI
@testable import Testing
extension GREYHostApplicationDistantObject: TodoListViewHost {
func givenLoadingTodoListView() -> () -> Bool {
givenTodoListView {
try await Task.sleep(nanoseconds: 1_000_000 * NSEC_PER_SEC)
fatalError("Should never happen")
}
}
func givenLoadedTodoListView() -> () -> Bool {
givenTodoListView {
// Load unsorted todos so we can verify that they are properly sorted in the view.
Set(0...9).map { .init(userId: $0, id: $0, title: "($0)", completed: false) }
}
}
func givenErrorTodoListView() -> () -> Bool {
givenTodoListView {
struct SomeError: Error {}
throw SomeError()
}
}
private func givenTodoListView(loadTodos: @escaping () async throws -> [Todo]) -> () -> Bool {
let databaseSpy = DatabaseManagerSpy()
let view = TodoListView(databaseManager: databaseSpy, loadTodos: loadTodos)
UIWindow.current.rootViewController = UIHostingController(rootView: view)
return { databaseSpy.called }
}
}
private extension UIWindow {
static var current: UIWindow {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap(.windows)
.filter(.isKeyWindow)
.first!
}
}
private class DatabaseManagerSpy: DatabaseManagerProtocol {
var called: Bool = false
func save<T>(data: T) where T: Encodable {
called = true
}
}
We have improved the initial UI tests quite a lot:
- Tests are now much more deterministic. They never go to the network, and they take less time to execute because of that.
- We can reliably test the loading and loaded cases, as well as the error case.
- We can test that the todos have been properly saved (or not saved) into the database (well, at least we can test that the message is properly sent to the database manager).
- We can set a specific view as the window’s root view without having to navigate to the specific screen as we have to do with normal UI tests.
But…
- Maintainability is still hard. The “given” is scattered across different files and targets.
- The bridge to cross both processes rely on @objc, which is not very convenient and an important limitation for the types we can use.
- We still have a very slow feedback loop. 12 seconds is still quite a lot of time for just four tests.
- We are coupled now with EarlGrey, a Google’s framework whose future is unknown… That’s something we should take into account. What’s the impact of Google abandoning that framework?
Let’s keep looking for better alternatives to test our view. Let’s finally move to unit tests.
Unit testing the view
We can hardly justify those big numbers for our main test suite. One of the most important aspects of tests is that they allow refactoring with confidence, and for that, we need to execute tests a lot while performing changes. UI tests don’t allow that. They are kind of a last-resort safety net to make sure we haven’t broken anything, but we should have better solutions.
If we want to unit test the view, we need to have a way to perform actions on it. In UIKit, this was as easy as exposing the subviews and exercising those actions via APIs like button.sendActions(for: .touchUpInside)(even if that’s not exactly correct, as subviews should be implementation details of the parent view).
But SwiftUI is different. We don’t have access to the underlying subviews, as they are implementation details that the framework decides based on a View value.
Fortunately, there are tools like ViewInspector that we can use to access view elements and mimic those view interactions.
A unit test of the view looks like this:
func testLoading() throws {
// Given
let databaseSpy = DatabaseManagerSpy()
let sut = TodoListView(databaseManager: databaseSpy) {
try await Task.sleep(nanoseconds: 1_000_000 * NSEC_PER_SEC)
fatalError("Should never happen")
}
// When
try sut.inspect().find(button: "Start").tap()
// Then
assertSnapshot(matching: sut, as: .wait(for: 0.01, on: .image))
XCTAssertFalse(databaseSpy.called)
}
While the ViewInspector library correctly retrieves and taps the button via try sut.inspect().find(button: “Start”).tap(), the final snapshot is not the loading view but the idle view. Why?
If we set some logs in the view body, we can see that the view body is correctly recomputed but always with the idle state… 🤔
Let’s see what happens if, instead of handling the state via an @State property wrapper, we have a view model via a @StateObject.
Let’s create the view model first, extracting all the logic from the view.
@MainActor
class TodoListViewModel: ObservableObject {
@Published private(set) var state: ListViewState = .idle {
didSet {
guard case .loaded(let todos) = state else {
return
}
databaseManager.save(data: todos)
}
}
private let databaseManager: DatabaseManagerProtocol
private let loadTodos: () async throws -> [Todo]
private var tasks: [Task<Void, Never>] = .init()
deinit {
for task in tasks {
task.cancel()
}
}
init(
databaseManager: DatabaseManagerProtocol,
loadTodos: @escaping () async throws -> [Todo] = loadTodos
) {
self.databaseManager = databaseManager
self.loadTodos = loadTodos
}
enum Message {
case startButtonTapped
case tryAgainButtonTapped
}
func send(_ message: Message) {
switch message {
case .startButtonTapped, .tryAgainButtonTapped:
tasks.append(
Task {
await refreshTodos()
}
)
}
}
private func refreshTodos() async {
state = .loading
do {
let todos = try await loadTodos().sorted { $0.title < $1.title }
state = .loaded(todos)
} catch {
state = .error
}
}
static func loadTodos() async throws -> [Todo] {
let url = URL(string: "https://jsonplaceholder.typicode.com/todos/")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Todo].self, from: data)
}
}
We have extracted all the important logic in the view model, having the view layer dumb, only forwarding events to the view model and rendering itself based on its state.
The view layer’s API hasn’t changed, though. It’s still created by injecting a database and a way to load todos, keeping the view model private.
struct TodoListView: View {
@StateObject private var viewModel: TodoListViewModel
init(
databaseManager: DatabaseManagerProtocol,
loadTodos: @escaping () async throws -> [Todo] = TodoListViewModel.loadTodos
) {
_viewModel = .init(wrappedValue: .init(databaseManager: databaseManager, loadTodos: loadTodos))
}
var body: some View {
Group {
switch viewModel.state {
case .idle:
Button("Start") {
viewModel.send(.startButtonTapped)
}
case .loading:
Text("Loading…")
case .error:
VStack {
Text("Oops")
Button("Try again") {
viewModel.send(.tryAgainButtonTapped)
}
}
case .loaded(let todos):
VStack {
List(todos) {
Text("($0.title)")
}
}
}
}
}
}
As the view API didn’t change, the test looks the same:
func testLoading() throws {
// Given
let databaseSpy = DatabaseManagerSpy()
let sut = TodoListView(databaseManager: databaseSpy) {
try await Task.sleep(nanoseconds: 1_000_000 * NSEC_PER_SEC)
fatalError("Should never happen")
}
// When
try sut.inspect().find(button: "Start").tap()
// Then
assertSnapshot(matching: sut, as: .wait(for: 0.01, on: .image))
XCTAssertFalse(databaseSpy.called)
}
As you might expect, the test still fails, but having the view model gives us much more insights than before.
try sut.inspect().find(button: “Start”).tap() is causing the view model to be initialized three times. Then assertSnapshot causes the final initialization of the view model. In total, while the view is initialized only once, its underlying view model is initialized four times. For some reason, the view model is not correctly retained by the underlying view when running on tests. It’s destroyed and recreated when accessed several times. In fact, we can see the following runtime warnings…
Let’s now try something different. Let’s inject the view model, so we have a way to retain the view model in the test scope ourselves.
struct TodoListView: View {
@StateObject private var viewModel: TodoListViewModel
init(viewModel: @escaping @autoclosure () -> TodoListViewModel) {
_viewModel = .init(wrappedValue: viewModel())
}
…
}
Now, the test looks like this:
func testLoading() throws {
// Given
let databaseSpy = DatabaseManagerSpy()
let viewModel = TodoListViewModel(databaseManager: databaseSpy) {
try await Task.sleep(nanoseconds: 1_000_000 * NSEC_PER_SEC)
fatalError("Should never happen")
}
let sut = TodoListView(viewModel: viewModel)
// When
try sut.inspect().find(button: "Start").tap()
// Then
assertSnapshot(matching: sut, as: .wait(for: 0.01, on: .image))
XCTAssertFalse(databaseSpy.called)
}
We still get the Accessing StateObject’s object without being installed on a View. This will create a new instance each time. error, but the view model is only initialized once, as expected, so everything works correctly. To avoid the aforementioned runtime warning, we can send the messages directly to the view model to “simulate” the interaction with the UI and remove the ViewInspector usage (which kind of feels like a hack to me…).
Having the view model around in the test can also be handy to assert its state if needed.
assertSnapshot(
matching: viewModel.state,
as: .dump
)
The dump strategy can be more fragile than expected sometimes, coupling ourselves with the specific shape of the underlying types we use for our state. Sometimes, it’s a little bit more future-proof to use a different strategy, like the JSON one, when our state conforms to Encodable.
The final test suite looks like this:
@MainActor
final class TodoListViewUnitTests: XCTestCase {
func testIdle() {
// Given
let (viewModel, _) = givenTodoListViewModel {
fatalError("Should never happen")
}
let sut = TodoListView(viewModel: viewModel)
// Then
assertSnapshot(matching: sut, as: .image)
assertSnapshot(matching: viewModel.state, as: .json)
}
func testLoading() async throws {
// Given
let (viewModel, todosSaved) = givenTodoListViewModel {
try await Task.sleep(nanoseconds: 1_000_000 * NSEC_PER_SEC)
fatalError("Should never happen")
}
let sut = TodoListView(viewModel: viewModel)
// When
viewModel.send(.startButtonTapped)
try await Task.sleep(nanoseconds: 1_000_000) // Wait for Tasks...
// Then
assertSnapshot(matching: sut, as: .image)
assertSnapshot(matching: viewModel.state, as: .json)
XCTAssertFalse(todosSaved())
}
func testLoaded() async throws {
// Given
let (viewModel, todosSaved) = givenTodoListViewModel {
// Load unsorted todos so we can verify that they are properly sorted in the view.
Set(0...9).map { .init(userId: $0, id: $0, title: "($0)", completed: false) }
}
let sut = TodoListView(viewModel: viewModel)
// When
viewModel.send(.startButtonTapped)
try await Task.sleep(nanoseconds: 1_000_000) // Wait for Tasks...
// Then
assertSnapshot(matching: sut, as: .image)
assertSnapshot(matching: viewModel.state, as: .json)
XCTAssertTrue(todosSaved())
}
func testError() async throws {
// Given
let (viewModel, todosSaved) = givenTodoListViewModel {
struct SomeError: Error {}
throw SomeError()
}
let sut = TodoListView(viewModel: viewModel)
// When
viewModel.send(.startButtonTapped)
try await Task.sleep(nanoseconds: 1_000_000) // Wait for Tasks...
// Then
assertSnapshot(matching: sut, as: .image)
assertSnapshot(matching: viewModel.state, as: .json)
XCTAssertFalse(todosSaved())
}
}
private class DatabaseManagerSpy: DatabaseManagerProtocol {
var called: Bool = false
func save<T>(data: T) where T: Encodable {
called = true
}
}
@MainActor private func givenTodoListViewModel(loadTodos: @escaping () async throws -> [Todo]) -> (
viewModel: TodoListViewModel,
todosSaved: () -> Bool
) {
let databaseSpy = DatabaseManagerSpy()
let viewModel = TodoListViewModel(databaseManager: databaseSpy, loadTodos: loadTodos)
return (viewModel, { databaseSpy.called })
}
So, we’ve gone from 12 seconds to 0,11 seconds. Still, 110 ms for just four unit tests could be considered a big number, especially when we have a big team with many developers and screens, where those numbers start to add up quickly, ending up with a test suite that needs several minutes to run.
For simple apps, this approach is a good trade-off. They are “fast enough”, and easy to read and maintain while covering quite a lot of surface area (view + view model) to maximize the protection against regressions and resistance to refactoring traits.
Testing the view layout and view model separately
Testing the view layout and the view model separately means that we could have our main test suite running just our view models, with our most important business logic running super fast, while the view layout tests, which run slower, could be run in a different target, at a different pace, maybe only by our CI, etc. They wouldn’t be integrated into the development process (in our continuous CMD+U while changing code). In a way, they would more like “UI tests”. Even if they run much faster than UI tests, they still run slow.
The view model tests look like this:
@MainActor
final class TodoListViewModelUnitTests: XCTestCase {
func testIdle() {
// Given
let (sut, _) = givenTodoListViewModel {
fatalError("Should never happen")
}
// Then
assertSnapshot(matching: sut.state, as: .json)
}
func testLoading() async throws {
// Given
let (sut, todosSaved) = givenTodoListViewModel {
try await Task.sleep(nanoseconds: 1_000_000 * NSEC_PER_SEC)
fatalError("Should never happen")
}
// When
sut.send(.startButtonTapped)
try await Task.sleep(nanoseconds: 1_000_000) // Wait for Tasks...
// Then
assertSnapshot(matching: sut.state, as: .json)
XCTAssertFalse(todosSaved())
}
func testLoaded() async throws {
// Given
let (sut, todosSaved) = givenTodoListViewModel {
// Load unsorted todos so we can verify that they are properly sorted in the view.
Set(0...9).map { .init(userId: $0, id: $0, title: "($0)", completed: false) }
}
// When
sut.send(.startButtonTapped)
try await Task.sleep(nanoseconds: 1_000_000) // Wait for Tasks...
// Then
assertSnapshot(matching: sut.state, as: .json)
XCTAssertTrue(todosSaved())
}
func testError() async throws {
// Given
let (sut, todosSaved) = givenTodoListViewModel {
struct SomeError: Error {}
throw SomeError()
}
// When
sut.send(.startButtonTapped)
try await Task.sleep(nanoseconds: 1_000_000) // Wait for Tasks...
// Then
assertSnapshot(matching: sut.state, as: .json)
XCTAssertFalse(todosSaved())
}
}
Removing the screenshot of the view has decreased the time quite a lot. From 110 ms to just 20 ms.
These tests are still not ideal, though. We have those Task.sleep due to the underlying asynchronicity of the view model that could eventually lead to flaky tests. We have two ways to avoid that:
1. Making the view model’s API async
Having the send method asynchronous will lead to changes in the view model’s API, breaking the view. Also, we now have to manage the Task lifecycle inside the view layer if we need to cancel it, for instance.
@MainActor
class TodoListViewModel: ObservableObject {
func send(_ message: Message) async {
switch message {
case .startButtonTapped, .tryAgainButtonTapped:
await refreshTodos()
}
}
…
}
struct TodoListView: View {
@StateObject private var viewModel: TodoListViewModel
init(viewModel: @escaping @autoclosure () -> TodoListViewModel) {
_viewModel = .init(wrappedValue: viewModel())
}
var body: some View {
Group {
switch viewModel.state {
case .idle:
Button("Start") {
Task {
await viewModel.send(.startButtonTapped)
}
}
…
}
…
}
But the tests will be simpler, without waits.
func testLoaded() async {
// Given
let (sut, todosSaved) = givenTodoListViewModel {
// Load unsorted todos so we can verify that they are properly sorted in the view.
Set(0...9).map { .init(userId: $0, id: $0, title: "($0)", completed: false) }
}
// When
await sut.send(.startButtonTapped)
// Then
assertSnapshot(matching: sut.state, as: .json)
XCTAssertTrue(todosSaved())
}
Unfortunately, not all tests will be as simple as that. The loading test will lead to an infinite wait.
func testLoading() async {
// Given
let (sut, todosSaved) = givenTodoListViewModel {
try await Task.sleep(nanoseconds: 1_000_000 * NSEC_PER_SEC)
fatalError("Should never happen")
}
// When
await viewModel.send(.startButtonTapped) // we wait forever here
// Then
assertSnapshot(
matching: sut.state,
as: .json
)
XCTAssertFalse(todosSaved())
}
Also, I’m not sure it’s the correct API, though. It seems that we are only exposing the send method async because of tests… 🤔.
2. Separating logic from effects
I have talked extensively about this approach in the past. We can extract the decision-making from the view model to the state type.
enum ListViewState: Equatable, Codable {
case idle
case loading
case loaded([Todo])
case error
enum Message {
case input(Input)
case feedback(Feedback)
enum Input {
case startButtonTapped
case tryAgainButtonTapped
}
enum Feedback {
case didFinishReceivingTodos(Result<[Todo], Error>)
}
}
enum Effect: Equatable {
case loadTodos
case saveTodos([Todo])
}
mutating func handle(message: Message) -> Effect? {
switch message {
case .input(.startButtonTapped), .input(.tryAgainButtonTapped):
self = .loading
return .loadTodos
case .feedback(.didFinishReceivingTodos(.success(let todos))):
self = .loaded(todos.sorted { $0.title < $1.title })
return .saveTodos(todos)
case .feedback(.didFinishReceivingTodos(.failure)):
self = .error
return nil
}
}
}
The view model, which becomes a humble object after extracting all the logic, looks like this now (just forwarding messages to the state type and interpreting effects).
@MainActor
class TodoListViewModel: ObservableObject {
@Published private(set) var state: ListViewState = .idle
private let databaseManager: DatabaseManagerProtocol
private let loadTodos: () async throws -> [Todo]
private var tasks: [Task<Void, Never>] = .init()
deinit {
for task in tasks {
task.cancel()
}
}
init(
databaseManager: DatabaseManagerProtocol,
loadTodos: @escaping () async throws -> [Todo] = loadTodos
) {
self.databaseManager = databaseManager
self.loadTodos = loadTodos
}
func send(_ message: ListViewState.Message.Input) {
send(.input(message))
}
private func send(_ message: ListViewState.Message) {
guard let effect = state.handle(message: message) else { return }
tasks.append(
Task {
await perform(effect: effect)
}
)
}
private func perform(effect: ListViewState.Effect) async {
switch effect {
case .loadTodos:
do {
let todos = try await loadTodos()
send(.feedback(.didFinishReceivingTodos(.success(todos))))
} catch {
send(.feedback(.didFinishReceivingTodos(.failure(error))))
}
case .saveTodos(let todos):
databaseManager.save(data: todos)
}
}
private static func loadTodos() async throws -> [Todo] {
let url = URL(string: "https://jsonplaceholder.typicode.com/todos/")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([Todo].self, from: data)
}
}
The tests are now simpler than ever.
@MainActor
final class TodoListViewModelUnitTests: XCTestCase {
func testLoading() {
// Given
var sut = ListViewState.idle
// When
let effect = sut.handle(message: .input(.startButtonTapped))
// Then
assertSnapshot(matching: (sut, effect), as: .dump)
}
func testLoaded() {
// Given
var sut = ListViewState.loading
// When
let todos: [Todo] = Set(0...9).map { .init(userId: $0, id: $0, title: "($0)", completed: false) }
let effect = sut.handle(message: .feedback(.didFinishReceivingTodos(.success(todos))))
// Then
let expectedTodos = todos.sorted { $0.title < $1.title }
XCTAssertEqual(effect, .saveTodos(expectedTodos))
XCTAssertEqual(sut, .loaded(expectedTodos))
}
func testError() async throws {
// Given
var sut = ListViewState.loading
// When
struct SomeError: Error {}
let effect = sut.handle(message: .feedback(.didFinishReceivingTodos(.failure(SomeError()))))
// Then
XCTAssertNil(effect)
XCTAssertEqual(sut, .error)
}
}
As you can see, we can have the normal XCTAssertEqual assertions, or we can assert the tuple (state, effect) snapshot (which can be quite handy when building the final assertion value is cumbersome). Take into account that assertSnapshot is several orders of magnitude slower than using XCTAssertEqual, so use it with caution.
Also, we have reduced the time to 10 ms. We don’t have waits, everything is deterministic, and we are testing the view logic, which has the highest ROI in unit testing.
Unit testing the state this way allows us to test all the corner cases very easily. Then, we can have a few “higher level” tests of the view model, with all its dependencies, verifying just some happy cases to double-check that everything works correctly together.
In his book, Vladimir Khorikov says that the most important parts of our code, the ones with the most domain significance, should have no dependencies. And that’s exactly what we’ve done by extracting the logic to the ListViewState type.
A note about refactoring the view model
In his book, Vladimir considered resistance to refactoring as the most important trait of a test. Quite a special one as well, as a test either has resistance to refactoring or not. It’s a binary choice. There are no trade-offs here. To do that, we should see our components as black boxes with inputs and outputs and avoid white-box testing and testing interactions as much as possible. Let’s see an example with the view model.
- Input: messages/events sent from the view.
- Output: state (to be rendered by the view) and observable behavior.
In this case, the observable behavior is the messages saved to the database (a side effect).
Let’s imagine we want to refactor the view model by extracting some piece of functionality, the sorting algorithm, for instance, to a standalone component, like a use case. That’s perfectly reasonable. What might not be a good idea is to test that the view model sends certain messages to that use case. The use case is just an implementation detail of how the view model has decided to achieve its business goal. We can decide to split all the responsibilities of the view model into several collaborators, which should be tested separately. But when testing the view model, those collaborators (the stable dependencies) should be considered implementation details. If we decide in the future that the use case should talk to a repository instead of directly to the API, that shouldn’t break the test because the inputs and outputs of the “view model black box” are the same. We should still assert:
- That the final state is correct.
- That the observable behavior is correct.
Testing the view layout
After testing the view model, arguably the most important part, we still have to test that the view layer looks correct. We want to test the view layout comfortably without creating the view model and all its dependencies. We have two ways to do that
1. Container/presentational views
It was Dan Abramov who first popularized this pattern in React back in 2015 with this article. It’s fairly simple. We should split our “normal SwiftUI views” in two:
- Container view: performs the logic and side effects and communicates with the presentation view for display purposes.
- Presentational view: it displays data and notifies of view events.
In our example, it would be as simple as creating an internal Presentation type. The input would be ListViewState, and the output would be the messages sent from the view.
struct TodoListView: View {
@StateObject private var viewModel: TodoListViewModel
init(viewModel: @escaping @autoclosure () -> TodoListViewModel) {
_viewModel = .init(wrappedValue: viewModel())
}
var body: some View {
Presentation(input: viewModel.state, output: viewModel.send)
}
struct Presentation: View {
var input: ListViewState
var output: (ListViewState.Message.Input) -> Void
var body: some View {
Group {
switch input {
case .idle:
Button("Start") {
output(.startButtonTapped)
}
case .loading:
Text("Loading…")
case .error:
VStack {
Text("Oops")
Button("Try again") {
output(.tryAgainButtonTapped)
}
}
case .loaded(let todos):
VStack {
List(todos) {
Text("($0.title)")
}
}
}
}
}
}
}
Take into account that Presentation should be internal to be accessible from tests but shouldn’t be included as the public API of the container view.
The tests look like this.
final class TodoListViewTests: XCTestCase {
func testIdle() {
// Given
let sut = TodoListView.Presentation(input: .idle) { _ in }
// Then
assertSnapshot(matching: sut, as: .image)
}
func testLoading() {
// Given
let sut = TodoListView.Presentation(input: .loading) { _ in }
// Then
assertSnapshot(matching: sut, as: .image)
}
func testLoaded() {
// Given
let todos: [Todo] = (0...9).map { .init(userId: $0, id: $0, title: "($0)", completed: false) }
let sut = TodoListView.Presentation(input: .loaded(todos)) { _ in }
// Then
assertSnapshot(matching: sut, as: .image)
}
func testError() {
// Given
let sut = TodoListView.Presentation(input: .error) { _ in }
// Then
assertSnapshot(matching: sut, as: .image)
}
}
We can even combine the state tests with the view layout tests.
func testLoading() {
// Given (a initial state)
var state = ListViewState.idle
// When (the user taps the start button)
let effect = state.handle(message: .input(.startButtonTapped))
// Then (both state and view layout are in the correct loading state)
assertSnapshot(
matching: (state, effect),
as: .dump
)
assertSnapshot(
matching: TodoListView.Presentation(input: state) { _ in },
as: .image
)
}
Take into account that having the Presentation type is also very convenient for previews, where we can easily have different previews configured with different states without mocking the view model.
2. Frozen view models
Because sometimes, we don’t want to create that Presentation type… To do that, we’d need to inject a “dummy” view model that does nothing and can be created with a specific initial state. This is important because the view lifecycle can call view model methods under the hood. We can always “freeze” a view model by creating an abstraction of the view model ad-hoc with a no-op implementation, but it’s much easier and ergonomic (without incurring test-induced design damage) when using architectures where we fully control the side effects, like the one described here.
Creating a “dummy reducer” that does nothing is as simple as this.
extension ViewModel {
static func freeze(with state: State) -> ViewModel<State, Input, Feedback, Output> {
.init(state: state, reducer: DummyReducer())
}
}
private class DummyReducer<State, Input, Feedback, Output>: Reducer where State: Equatable {
func reduce(
message: Message<Input, Feedback>,
into state: inout State
) -> Effect<Feedback, Output> {
.none
}
}
And the test looks like this.
func testLoading() {
// Given
let sut = TodoListView(viewModel: .freeze(state: .loading))
// Then
assertSnapshot(
matching: sut,
as: .image
)
}
Conclusion
Thanks a lot for reaching the end of the article. It was another long read 😅.
We’ve seen different ways to test our view layer, both the view logic and also the layout.
While it’s quite clear that most of our tests should be unit tests and not UI tests, it’s unclear which approach to take.
- Should we test the view and view model together and only verify the view layout?
- Should we test the view model and view layout separately?
- Should we move the view logic outside the view model, to a value layer with no dependencies where it can easily be tested?
All approaches are perfectly valid, and depending on your project and your needs, you might find one more appropriate than the other.
- Do you have a simple app with just a few screens and developers? The first approach may be the most reasonable.
- Do you have a big app with lots of developers and screens? Consider moving the view layout testing to a different target so the main test suite remains fast.
- Do you have quite complex business logic and requirements with lots of corner cases? Extracting the logic into a value type with no dependencies could simplify testing.
As always, it depends 😄. As much as we always like to have “our way of doing things”, we should think critically, understand the app domain and requirements, and develop testing solutions that best fit our needs.
I’m really interested in knowing what you think. Do you have strong opinions about how we should test our view layer? Let me know in the comments.
Thanks!
SwiftUI Testing: a Pragmatic Approach was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.