Toni Granados

Toni Granados

Testing SwiftUI's ViewModels


When it comes to testing ObservableObjects, we can leverage their capabilities to observe when a @Published property changes. This comes in handy during testing as we can suspend the test and wait until the property assumes the value we are expecting.

To do this, we use the Combine Publisher that @Published adds to the property. We subscribe to it and test whether the new values of the property satisfy a given condition. Once the condition is met, we can fulfill an Expectation that we had set previously.

I’ve written an extension to XCTestCase that introduces a couple of functions to simplify this process:

@MainActor
extension XCTestCase {
    func until<T>(
        _ publisher: T,
        satisfies conditions: [@Sendable (T.Output) -> Bool],
        timeout: TimeInterval = 1
    ) async where T: Publisher {
        let expectation = XCTestExpectation()
        var mutableConditions = conditions

        let cancellable = publisher.sink { _ in } receiveValue: { value in
            if mutableConditions.first?(value) ?? false {
                _ = mutableConditions.remove(at: 0)
                if mutableConditions.isEmpty {
                    expectation.fulfill()
                }
            }
        }

        await fulfillment(of: [expectation], timeout: timeout)
        cancellable.cancel()
    }

    func until<T>(
        _ publisher: T,
        satisfies condition: @escaping @Sendable (T.Output) -> Bool,
        timeout: TimeInterval = 1
    ) async where T: Publisher {
        await until(publisher, satisfies: [condition], timeout: timeout)
    }
}

To use them, you just need to make your test async and await the functions to finish:

    func testViewModel() async {
        let viewModel = ViewModel()

        let task = Task { await viewModel.addItem() }

        // Check that `isLoading` is updating
        await until(viewModel.$isLoading) { $0 == true}
        XCTAssertTrue(viewModel.isLoading)

        // Wait until task finishes and `isLoading` is back to false
        await task.value
        await until(viewModel.$isLoading) { $0 == false}

        XCTAssertEqual(viewModel.items.count, 1)
        XCTAssertFalse(viewModel.isLoading)
    }

For reference, this is the ViewModel:

class ViewModel: ObservableObject {
    @Published var items: [Int] = []
    @Published var isLoading: Bool = false

    func addItem() async {
        self.isLoading = true

        try! await Task.sleep(for: .seconds(1))

        self.items.append(self.items.count + 1)
        self.isLoading = false
    }
}

Using this techniques, you can easily and consistently test very common SwiftUI patterns. I hope you find them useful. Happy testing!