Welcome to the grand finale — the victory lap we’ve been building towards for the last 5 chapters (and, really, my last 12 weeks of writing!)
Combine, released in 2019, was the biggest concurrency-related change Apple had created since the release of Grand Central Dispatch in 2009. More recently, it has been overshadowed by the async/await paradigm introduced to Swift in 2021.
As we learned in Part V, however, Combine is often an ideal way to approach many problem spaces — you can see this when implementing a Repository using publishers, and Apple themselves used it to power SwiftUI under the hood.
Therefore, understanding how to test Combine code — and, more importantly, how to test it in interoperation with the more popular async/await, gives you a superpower. A superpower which allows you to write robust and maintainable code with these modern frameworks and language features.
Part II Mocking like a Pro
Part III Unit Testing with async/await
Part V (interlude) — Implement your Data Access layer with Combine
Part VI: Combine, async/await, and Unit Testing
For a quick refresher, Combine was Apple’s take on functional reactive programming, analogous to the popular Rx family of libraries, to Flows in Kotlin, or to all of Scala. It also serves as the foundation for the reactive nature of SwiftUI implemented under the hood — for instance, @ObservableObject is entirely Combine-powered.
In 2021, the Swift team finally released first-class concurrency support to the language. These features, long-awaited through the swift evolution process, covered structured concurrency (async/await, async let and taskGroup); unstructured concurrency (Tasks); and actors which help you protect access to mutable state.
While the two paradigms are somewhat orthogonal, you might use both Combine and async/await together in your projects to allow their respective strengths to shine — bev, our faithful open-source companion project, is a prime example.
Unit testing code which marries these two concurrency approaches has some gotchas. Additionally, even when you aren’t using Combine directly in a SwiftUI project, you may find it’s not intuitive how to unit test your @ObservableObject view models in a maintainable way.
There are three main types of interoperation I’ll dive into in this article, and I’ll supply you with the techniques to achieve unit test mastery over them all:
Test
@ObservableObjectview modelsTest Combine publishers in
asyncmethodsTest Combine publishers converted to
AsyncSequence
(#1) — Test @ObservableObject view models
SwiftUI lends itself naturally to an MVVM structure where Views contain @ObservableObject view models. These view models contain business logic and @Published properties, which automagically update the UI in the view when they change.
Let’s dig into BeerViewModel.
I’ll present a trimmed-down version to explain the core concept:
final class BeerViewModel: ObservableObject {
@Published private(set) var beers: [Beer] = []
private var cancelBag = Set<AnyCancellable>()
private let repository: BeerRepository
init(repository: BeerRepository = BeerRepositoryImpl()) {
self.repository = repository
setupBeerListener()
}
func loadBeers() async {
await repository.loadBeers()
}
private func setupBeerListener() {
repository.beersPublisher
.receive(on: RunLoop.main)
.sink(receiveValue: { [weak self] in
self?.handleBeer(loadingState: $0)
}).store(in: &cancelBag)
}
private func handleBeer(loadingState: LoadingState<[Beer]>) {
switch loadingState {
case .success(let beers):
self.beers = beers
default:
break
}
}
}The async loadBeers() method is called by our SwiftUI view when our view appears. This asks our repository to load the latest and greatest beverages and broadcast them through the publisher — which our view model is listening to.
Since we are all experts at access control, our methods which handle the value from the repository are private. Therefore, it’s not obvious how exactly we structure our tests.
Requirements for our tests
We need our tests to:
Set up a mock version of
BeerRepositorythrough which we can broadcast values.Send a
LoadingStatethrough the publisher on ourMockBeerRepository. that containsBeerobjects.Once
handleBeer(loadingState: LoadingState<[Beer]>)has been called in oursut, we need to check the value of thebeersproperty to validate everything has been set correctly.
The tricky part, as always the case with these, is when to fulfill our expectation and check the values. This is particularly tricky in the instances where all the methods involved are private.
If we’ve been paying attention in Part IV — Advanced Async Testing — Unstructured Concurrency, the cogs might be turning your head. We could set up an expectation, and toggle some kind of closure on a mock once we know the beers value has been set…
You’ve nearly got it, but this time around, we don’t have to do anything terribly clever with mocks. Check out the view model’s property declaration for beers:
@Published private(set) var beers: [Beer] = []@Published properties
@Published is a property wrapper which synthesises a custom setter on the property — when its value changes, it calls objectWillChange on the parent @ObservableObject. This in turn tells SwiftUI that the view drawn from the property needs to be redrawn.
More importantly for testing purposes, the @Published property wrapper (and any property wrapper for that matter!) synthesises a projectedValue property, which can be accessed via the dollar $ syntax (sugar). Conveniently, the projected value of @Published properties is our good friend, CurrentValueSubject.
This means the beers property in our view model is wrapped in a publisher!
Finishing the first test
This solves the problem of ‘when to test the value’. In our test, we can simply create a subscriber which listens to beers on our view model:
func test_listenerSentBeersSuccessfully_setsBeers() {
let sampleBeers = [Beer.sample()]
// 1
mockBeerRepository.beersPublisher.send(.success(sampleBeers))
let exp = expectation(description: #function)
// 2
cancel = sut.$beers.sink {
// 3
guard !$0.isEmpty else { return }
exp.fulfill()
}
waitForExpectations(timeout: 1)
XCTAssertEqual(sampleBeers, sut.beers)
}This test builds on the concepts we’ve learned before:
Send some mock data to our BeerRepository to publish to our BeerViewModel (i.e. our
sut).We create a subscription to the
$beerspublisher on oursutandsinkit to capture the values it sends. We assign the resulting subscription to thecancelproperty of our test class (to keep it in memory; and allow us to clean it up intearDown()).The publisher actually returns the initial empty
beersarray that we initialise the BeersViewModel with. Therefore, to prevent the expectation immediately being fulfilled, we first check the array isn’t empty.
We run the test and it’s happy and green!

Testing the error case
We can use these exact same concepts to test the error cases— just subscribe to sut.$showAlert to fulfill the expectation:
func test_listenerSentError_setsErrorMessageAndTogglesAlert() {
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)
XCTAssertEqual(sut.errorMessage, testError.localizedDescription)
XCTAssertTrue(sut.showAlert)
}Here, once the $showAlert publisher on the @Published showAlert property sends a value of true, we fulfill the expectation.
This is a great start — in one move; we’ve created a technique to test SwiftUI view models which contain both Combine logic and async methods.
(#2) — Test Combine publishers in async methods
Let’s look back in our BeerRepository, our Data Access Layer which we delved into last chapter. The main player here is the CurrentValueSubject, which publishes a loading state to all its listeners.
We need to test the loadBeers() method; the only public function in the BeerRepository interface:
public func loadBeers() async {
beersPublisher.send(.loading)
do {
let beers = try await api.getBeers()
beersPublisher.send(.success(beers))
} catch {
beersPublisher.send(.failure(error))
}
}The structure of this async method is fairly familiar to us now.


