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@Observableview 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-into 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:
applyaccesses properties on an@Observableentity.onChangeexecutes when values captured byapplychange.
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! ✅




