Get Even More Visitors To Your Blog, Upgrade To A Business Listing >>

SwiftUI Testing: a Pragmatic Approach

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 codeThe 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 SwiftUIstruct 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 [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(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 XCUITestXCUITest 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 SnapshotTestingimport XCTestfinal 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.0In 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 SwiftUIstruct 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(data: T) where T: Encodable}final class DatabaseManager: DatabaseManagerProtocol { static let shared: DatabaseManager = .init() private init() {} func save(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 XCTestimport SnapshotTestingfinal 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.@objcprotocol 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 Testingextension 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(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 viewWe 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.@MainActorclass 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] = .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 [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:@MainActorfinal 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(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 separatelyTesting 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:@MainActorfinal 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 asyncHaving 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.@MainActorclass 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 effectsI 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) } } 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 [Todo] private var tasks: [Task] = .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.@MainActorfinal 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 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 modelsBecause 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 { .init(state: state, reducer: DummyReducer()) }}private class DummyReducer: Reducer where State: Equatable { func reduce( message: Message, into state: inout State ) -> Effect { .none }}And the test looks like this.func testLoading() { // Given let sut = TodoListView(viewModel: .freeze(state: .loading)) // Then assertSnapshot( matching: sut, as: .image )}ConclusionThanks 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.



This post first appeared on VedVyas Articles, please read the originial post: here

Share the post

SwiftUI Testing: a Pragmatic Approach

×

Subscribe to Vedvyas Articles

Get updates delivered right to your inbox!

Thank you for your subscription

×