Subscribe to Jacob’s Tech Tavern for free to get ludicrously in-depth articles on iOS, Swift, tech, & indie projects in your inbox every two weeks.
Paid subscribers unlock Quick Hacks, my advanced tips series, and enjoy exclusive early access to my long-form articles.
When I heard about the new Observation framework in WWDC 2023, like all good fans of Combine (we still exist!), I was naturally horrified.
Combine is no longer an implementation detail of SwiftUI. Our hard-earned knowledge of testing @Published
properties is deprecated.
Today, we’re going to discover how to unit test @Observable
view models.
This is a sequel to my six-part epic, Async Unit Testing in Swift. I’ll be updating Bev, my trusty, boozy side project, to iOS 17 while maintaining full test coverage.
The Observation Framework
The Combine killer.
SwiftUI’s performance panacea.
Boilerplatebane.
Look, you didn’t come here for this, so I’m not going to waste your precious brain clock cycles on explaining that you can convert this:
final class BeerViewModel: ObservableObject {
@Published private(set) var beers: [Beer] = []
}
Into this:
@Observable
final class BeerViewModel {
private(set) var beers: [Beer] = []
}
Old hat.
It’s been said before.
Every iOS blogger worth their salt has collectively regurgitated the dubdub videos to get those sweet, sweet clicks (and subsequent RevenueCat $$$).
RevenueCat, please sponsor my Substack.
What isn’t clear, however, is how we’re meant to unit test these shiny new @Observable
view models.
Bev17
I’ve given Bev a fresh lick of iOS-17-coloured paint, which sets out an itinerary for my next few articles:
Where I lazily didn’t bother internationalising my strings, there are now String Catalogs.
Where the repository fetched data from the network, now there is a Swift Data persistence layer with multiple retrieval strategies.
And finally, where there was an
@ObservableObject
, there is now an@Observable
view model.
I’ve also made it look less crappy in dark mode.
Unit Testing Observation
Let’s focus on BeerViewModel
. It requests [Beer]
from our data access layer, makes the model objects available to our SwiftUI views, and handles errors.
The view model has a single dependency — BeerRepository
— which exposes a combine publisher as our data source for [Beer]
.
I implemented a couple of strategies to listen to the
[Beer]
data source — A simple Combinesink
, andfor-await-in
to create an AsyncSequence. Check out Combine, async/await, and unit testing to understand how to unit test code that applies either approach.
In the world of synchronous code and basic async code, life remains simple. Take this test, for instance, which checks that loadBeers
calls into the injected Repository dependency.
func test_loadBeers_callsLoadOnRepository() async {
sut = BeerViewModel(repository: mockBeerRepository)
mockBeerRepository.stubLoadBeersResponse = .success([])
await sut.loadBeers()
XCTAssertEqual(mockBeerRepository.loadBeersCallCount, 1)
}
This test passes without changing anything since our mock’s loadBeersCallCount
is incremented immediately.
However, tests for the success and error cases — the tests that touch multiple parts of our view model code asynchronously — fail when we migrate to @Observable
.
We need to rework our approach.
The Old World™ (Combine)
In the old Combine-powered world, BeerViewModel
would update a @Published
property that held [Beer]
. We listen for changes emitted by the publisher in our unit tests, assigning a Cancellable
property to $sut.beers.sink {}
.
func test_listenerSentBeersSuccessfully_setsBeers() {
sut = BeerViewModel(repository: mockBeerRepository)
let sampleBeers = [Beer.sample()]
mockBeerRepository.beersPublisher.send(.success(sampleBeers))
let exp = expectation(description: #function)
cancel = sut.$beers
.dropFirst()
.sink { _ in
exp.fulfill()
}
waitForExpectations(timeout: 1.0)
XCTAssertEqual(sampleBeers, sut.beers)
}
When migrating to the Observation framework, we can’t just create a listener to a property on the view model anymore. This test fails to build, as there is no longer a Publisher
to listen to.
The New World™ (Observation)
There is a trick to testing properties on @Observable
view models.
Built into the Observation framework is a global function, withObservationTracking
, which by Apple’s own documentation, “Tracks access to properties.”
This function takes two closures:
apply
accesses properties on an@Observable
entity.onChange
executes when values captured byapply
change.
With this understanding in place, we can finally write a unit test to respond to the beers
property on our view model being changed:
func test_listenerSentBeersSuccessfully_setsBeers() {
sut = BeerViewModel(repository: mockBeerRepository)
let sampleBeers = [Beer.sample()]
mockBeerRepository.beersPublisher.send(.success(sampleBeers))
let exp = expectation(description: #function)
withObservationTracking {
_ = sut.beers
} onChange: {
exp.fulfill()
}
waitForExpectations(timeout: 1.0)
XCTAssertEqual(sampleBeers, sut.beers)
}
And it passes! ✅
The Error Case
Our old Combine code used the same approach as the success case when monitoring the error states. Here’s what it looks like:
func test_listenerSentError_setsErrorMessageAndTogglesAlert() {
sut = BeerViewModel(repository: mockBeerRepository)
let testError = TestViewModelError.testError
mockBeerRepository.beersPublisher.send(.failure(testError))
let exp = expectation(description: #function)
cancel = sut.$showAlert.sink {
guard $0 else { return }
exp.fulfill()
}
waitForExpectations(timeout: 1.0)
XCTAssertEqual(sut.errorMessage, testError.localizedDescription)
XCTAssertTrue(sut.showAlert)
}
This test, again, fails to build.
But with our newfound mastery of the Observation framework, we can handily convert the test using withObservationTracking
. Here’s how to do that:
func test_listenerSentError_setsErrorMessageAndTogglesAlert() {
sut = BeerViewModel(repository: mockBeerRepository)
let testError = TestViewModelError.testError
mockBeerRepository.beersPublisher.send(.failure(testError))
let exp = expectation(description: #function)
withObservationTracking {
_ = sut.showAlert
} onChange: {
exp.fulfill()
}
waitForExpectations(timeout: 1.0)
XCTAssertEqual(sut.errorMessage, testError.localizedDescription)
XCTAssertTrue(sut.showAlert)
}
Through the magic of withObservationTracking
, this test also passes the first time.
Updating the codebase several days prior to writing this article also helped.
The withObservationTracking
Helper Function
I’m glad we can now test @Observable
code, but we haven’t exactly evaporated much boilerplate — we’re actually using even more lines than the Combine-based tests.
Through the power of key paths, we can handle this issue.
A helper function, resplendent with doc comments, is in order. Here’s what that looks like:
/// Waits for changes to a property at a given key path of an `@Observable` entity.
///
/// Uses the Observation framework's global `withObservationTracking` function to track changes to a specific property.
/// By using wildcard assignment (`_ = ...`), we 'touch' the property without wasting CPU cycles.
///
/// - Parameters:
/// - keyPath: The key path of the property to observe.
/// - parent: The observable view model that contains the property.
/// - timeout: The time (in seconds) to wait for changes before timing out. Defaults to `1.0`.
///
func waitForChanges<T, U>(to keyPath: KeyPath<T, U>, on parent: T, timeout: Double = 1.0) {
let exp = expectation(description: #function)
withObservationTracking {
_ = parent[keyPath: keyPath]
} onChange: {
exp.fulfill()
}
waitForExpectations(timeout: timeout)
}
Here, our BeerViewModel
serves as T
and key paths for both beers
and showAlert
slot into the generic key path for property U
.
Now, our success case is a neat five lines of code.
func test_listenerSentBeersSuccessfully_setsBeers() {
sut = BeerViewModel(repository: mockBeerRepository)
let sampleBeers = [Beer.sample()]
mockBeerRepository.beersPublisher.send(.success(sampleBeers))
waitForChanges(to: \.beers, on: sut)
XCTAssertEqual(sampleBeers, sut.beers)
}
And our error case is also cut down substantially.
func test_listenerSentError_setsErrorMessageAndTogglesAlert() {
sut = BeerViewModel(repository: mockBeerRepository)
let testError = TestViewModelError.testError
mockBeerRepository.beersPublisher.send(.failure(testError))
waitForChanges(to: \.showAlert, on: sut)
XCTAssertEqual(sut.errorMessage, testError.localizedDescription)
XCTAssertTrue(sut.showAlert)
}
ObservationTestUtils
iOS developers are allergic to third-party dependencies and imports at the best of times. But I never pass up an opportunity to acquire precious GitHub Stars.
I have deployed the ObservationTestUtils package for you all. It contains both vanilla and async versions of the waitForChanges
method as an XCTestCase
extension, some documentation, and finally some sample @Observable
view model code with tests.
When you’re working with linear code and not async Combine pipelines, you’ll need to lean on unstructured concurrency: Offer your code to another thread via Task
so waitForChanges
has something to wait for.
func test_viewModel_loadProperty() {
Task {
await sut.loadProperty()
}
waitForChanges(to: \.property, on: sut)
XCTAssertEqual(sut.property, 1)
}
I’ve imported the ObservationTestUtils
package into Bev17’s test target as a demonstration.
Conclusion
I hope you’ve learned something today. We can rest easy knowing that despite Combine no longer being a (mandatory) part of our view models, we can still ensure full unit test coverage via withObservationTracking
.
I’m still a little shocked that, being five months late to the party, seemingly nobody else had commented on unit testing the new Observation framework.
I suppose most of us won’t be using the Observation framework in production until 2025 (maybe 2024, if you’re really lucky). But that’s a rant for another day.
Good one! I used Observation framework when delivering one of my take home assignments as I was curious to learn something new. I got no problem searching for this approach actually when writing tests (Apple docs). However, the more content we have, the better!