Testing SwiftUI's ViewModels
When it comes to testing ObservableObject
s, 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!