Combine, async/await, and Unit Testing
Write robust and maintainable software using modern language features
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 (Task
s); and actor
s 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
@ObservableObject
view modelsTest Combine publishers in
async
methodsTest Combine publishers converted to
AsyncSequence
(#1) — Test @ObservableObject
view models
SwiftUI lends itself naturally to an MVVM structure where View
s 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
BeerRepository
through which we can broadcast values.Send a
LoadingState
through the publisher on ourMockBeerRepository
. that containsBeer
objects.Once
handleBeer(loadingState: LoadingState<[Beer]>)
has been called in oursut
, we need to check the value of thebeers
property 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
$beers
publisher on oursut
andsink
it to capture the values it sends. We assign the resulting subscription to thecancel
property of our test class (to keep it in memory; and allow us to clean it up intearDown()
).The publisher actually returns the initial empty
beers
array 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.
Testing the happy path
We know how we might test the success case:
Create a mock API
Set the stub value to an array of
Beer
test objectsCall
loadBeers()
on our BeerRepositoryCreate a subscriber on
beersPublisher
to capture the loading state sentCheck the loading state value is a
.success
containing our beer!
func test_loadBeers_success_sendsBeersToPublisher() async {
let expectedBeers = [Beer.sample()]
mockBeerAPI.stubBeersResponse = .success(expectedBeers)
var testResult: LoadingState<[Beer]>?
let exp = expectation(description: #function)
cancel = sut.beersPublisher
.sink(receiveValue: {
testResult = $0
exp.fulfill()
})
await sut.loadBeers()
await fulfillment(of: [exp], timeout: 1)
if case .success(let beers) = testResult {
XCTAssertEqual(beers, expectedBeers)
} else {
XCTFail(#function)
}
}
This test is structured pretty similarly to the tests on our view model, with an expectation fulfilled as part of the closure in our sink
subscription, and the loading state captured in testResult
.
Since we’re using expectations in an async
test method, we use the brand-new waiting API from Xcode 14.3:
await fulfillment(of: [exp], timeout: 1)
Brimming with confidence, we run our test suite.
Avoiding multiple fulfilment
But wait! When we run the test, we get a crash:
API violation — multiple calls made to [XCTestExpectation fulfill]
This is a crash created from the XCTest framework. The expectation
API asserts that each expectation may be fulfilled only once, and crashes if this assertion is not met.
Here is where the issue lies: Let’s look at the journey our beersPublisher
makes over the course of the test:
When the Repository is set up, the publisher starts in the
.idle
state.When
loadBeers()
is called, the publisher immediately broadcasts the.loading
state.After fetching data from the API, the publisher lands on the
.success
state with thebeer
values we care about.
If you step through the code, you’ll discover that the .idle
state, with which our CurrentValueSubject
was initialised, immediately gets passed into our sink
closure. Next, the .loading
state is passed in when await loadBeers()
is called. This hits the expectation for the second time, and causes a crash before we even get to the success
state!
There are a few ways to handle this problem; and wait for a success before we fulfill the expectation. The obvious format might look like this:
cancel = sut.beersPublisher
.sink(receiveValue: {
if case let .success == $0 {
testResult = $0
exp.fulfill()
}
})
But the Combine framework allows us to create a neater version; that properly utilises Combine operators on the pipeline of values from the publisher. Since we want to ignore the first 2 values, we can simplify our code down to:
cancel = sut.beersPublisher
.dropFirst(2)
.sink(receiveValue: {
testResult = $0
exp.fulfill()
})
We can also apply this neat trick to @Published
properties we want to test — if a property starts as nil, and we later set it, we can simply use dropFirst()
instead of checking for nil in the sink { }
closure. Neat!
func test_loadBeers_success_sendsBeersToPublisher() async {
let expectedBeers = [Beer.sample()]
mockBeerAPI.stubBeersResponse = .success(expectedBeers)
var testResult: LoadingState<[Beer]>?
let exp = expectation(description: #function)
cancel = sut.beersPublisher
.dropFirst(2)
.sink(receiveValue: {
testResult = $0
exp.fulfill()
})
await sut.loadBeers()
await fulfillment(of: [exp], timeout: 1)
if case .success(let beers) = testResult {
XCTAssertEqual(beers, expectedBeers)
} else {
XCTFail(#function)
}
}
This updated test now works happily and passes!
Check out BeerRepositoryTests for the full code, which I have refactored to enable code sharing between the tests for the success and failure states.
(#3) — Test Combine publishers converted to AsyncSequence
Combine, being a perfect framework in its own right, has only had one significant update since 2020: bridging with async
/await
.
The Publisher
protocol now includes a values
property, which wraps a Combine publisher with an AsyncPublisher
. This means we can fetch values in an alternative way — I’ve demonstrated this in my BeerViewModel, with a simple Strategy pattern implemented in the initialiser so we can use both approaches:
init(repository: BeerRepository = BeerRepositoryImpl(), strategy: BeerListeningStrategy = .combine) {
self.repository = repository
switch strategy {
case .combine:
setupBeerListener()
case .asyncSequence:
Task {
await setupBeerSequence()
}
}
}
Instead of handling values via sink
:
private func setupBeerListener() {
repository.beersPublisher
.receive(on: RunLoop.main)
.sink(receiveValue: { [weak self] in
self?.handleBeer(loadingState: $0)
}).store(in: &cancelBag)
}
We could instead use the for
-await
-in
syntax:
private func setupBeerSequence() async {
for await loadingState in repository.beersPublisher.values {
handleBeer(loadingState: loadingState)
}
}
Testing an AsyncPublisher
This is a beautiful bridge between the two concurrency paradigms, where the values property on a Combine publisher returns the same publisher wrapped in AsyncPublisher
, which grants it async
/await
compatibility.
Let’s duplicate our success-case test to work with AsyncSequence. For good measure, let’s put dropFirst()
on our sink
closure instead of checking the actual loading state.
// Combine
func test_listenerSentBeersSuccessfully_setsBeers_withCombine() {
sut = BeerViewModel(repository: mockBeerRepository, strategy: .combine)
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)
XCTAssertEqual(sampleBeers, sut.beers)
}
// AsyncSequence
func test_listenerSentBeersSuccessfully_setsBeers_withAsyncSequence() {
sut = BeerViewModel(repository: mockBeerRepository, strategy: .asyncSequence)
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)
XCTAssertEqual(sampleBeers, sut.beers)
}
Even though we just duplicated our tests, and only changed the initialiser in our view model, they all pass immediately!
This is because the actual behaviour — beerPublisher
sending values and passing those values to the handleBeer
method—are the same. The choice between Combine and AsyncSequence
is just an implementation detail for creating identical behaviours.
Conclusion
We’ve been on quite a journey.
This series was first conceived many years ago, when I was a junior engineer. I was trying to wrap my head around dependency injection and couldn’t for the life of me understand anyone’s explanations — it was as if they were explaining it in x86 while my brain was running ARM.
We started with getting a grasp on dependency injection and how it can allow you to design your codebase in a maintainable and testable way. We then looked at mocking and an approach for setting up super-powered mocks for your services which enabled tests for a wide range of behaviours.
Next, we wrote our first async unit tests. These were straightforward, since async
/await
allows async code to be written in the same linear way as regular synchronous code. We moved onto more advanced use cases where unstructured concurrency could also be tested fully by utilising expectation
s and our powerful mocks.
Later, we looked at the Combine framework, and how it can be put to work in a clean Data Access Layer. Finally, we took a deep dive into the techniques that marry Combine and async
/await
code and learned various approaches for unit testing this inter-op fully.
I hope you enjoyed reading this series, and that I’ve helped you to become a stronger engineer.
Part II Mocking like a Pro
Part III Unit Testing with async/await
Part V (interlude) — Implement your Data Access layer with Combine
Hi Jacob! Great articles series! We've got a big project with async await and Combine and we've been struggling with flaky asynchronous testing. Your articles helped us a lot to make them stable. However, I have one question to this article.
We've found out that some tests were failing because we've been waiting for the @Published property and then we've been checking the value inside the sut, not the returned value from the sink.
As per Apple's documentation for @Published: "When the property changes, publishing occurs in the property’s willSet block, meaning subscribers receive the new value before it’s actually set on the property". So it looks like there is a slight chance of race and that checking the value after sink might check the "wrong" value, before it changes and I think we've seen exactly that problem in our tests because making a change - stabilised them (we have many many tests - around 2k).
So in your first example it would require saving what sink has returned into a variable, e.g. returnedBeers and then instead of checking XCTAssertEqual(sampleBeers, sut.beers) - check XCTAssertEqual(sampleBeers, returnedBeers).
What do you think? Does it make sense? Have you experience that as well?