Async Unit Testing: The Comprehensive Guide
Utilise modern Swift Concurrency with full test coverage
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.
Unit testing will inherently nudge you toward writing your code in a maintainable way. You’ll separate concerns, design sensible interfaces, and break your code into small, easy-to-reason-about chunks.
Modern language features like async
/await
and functional reactive programming bring incredible ergonomics to our code. However, writing tests for code that executes asynchronously is not always straightforward.
I’ve always wanted to write this piece since I rarely see it explained well — frankly, it’s a tough set of concepts, and clearly, I think a lot of myself if I’m writing 12,000 words on the topic.
Introduction
It’s difficult to talk about testing without laying down some core concepts to get started. I would love to jump straight into advanced topics such as unstructured Task
s and Combine interoperation, but I’d rather start slow and ensure we’re all up to the same level of understanding as we go along.
I’m writing the article I needed back when I was a junior but never had.
As well as six parts, there are three arcs on this journey that build on each other in sequence:
Arc 1: Foundational concepts
First, I will explain dependency injection and how this can be used to ensure the separation of concerns in your codebase. Following that, we will look at how this foundation helps us set up mocks for fine-tuned control over our test scenarios.
Arc 2: Async unit testing
After we master these critical concepts, we’re going to start writing some basic unit tests when using async
/await
code and cover common test scenarios. We’ll later learn about unstructured concurrency, the difficulties of testing code that utilises it, and apply our knowledge of mocks to solve these.
Arc 3: Interoperation with Combine
Next, we’ll take a short interlude to learn about data access layers, a common architectural pattern, and utilise the Combine framework to build one ourselves. Finally, we will marry async
/await
with Combine and learn several techniques to test the interoperation between these two concurrency paradigms.
I’ve built bev — a simple, fully-tested companion app in SwiftUI to illustrate all the concepts we explore in this piece. Feel free to download it from GitHub and use it as a reference.
Part I: Dependency Injection Demystified
Dependency injection, a.k.a. DI, is a handy concept that’s explained badly. Gatekeeping senior engineers use cryptic jargon like “inversion of control” and repeat useless platitudes like “DI is a 25-dollar term for a 5-cent concept.”
I viscerally remember these phrases making me feel like an idiot and an imposter when I was a junior — maybe even when I was mid-level.
What is Dependency Injection?
Having married this year, I’ve fully abandoned my obligation to stay in shape — so I’ll indulge in a culinary analogy.
Think of Dependency Injection (DI) like a fancy restaurant. Your class (struct, module, actor, etc.) is head chef, and DI is the sous-chef who hands her the ingredients she needs to cook up a storm. By separating the tasks of “gathering ingredients” and “cooking,” our menu is far more testable (I never said the analogy would make sense).
Without dependency injection here, we’d force the chef to make up the ingredients herself, rush to cook, and leave us with an unmaintainable dumpster fire of a kitchen.
In a nutshell, DI dices up the duties of “get what I need” and “do what I need to do” like my perfectly sharpened Tojiro Gyuto.
What does this have to do with testing?
Hold your horses!
I could dive right into testing and teach you how to write basic tests to prove that 1+1= 2 or that the string you hardcoded matches… well, your hardcoded string.
I’m starting slow and steady with DI because I want to give you a proper foundation to start from. The foundation I never received.
Essentially, what I’m saying is, don’t want to brush your teeth (unit test) before you’ve eaten your veggies (created mocks). And before you can eat veggies, you need to preheat the oven (learn dependency injection).
When you use DI to inject services into your classes, you can create a corresponding buffet of mock services. These conform to the same interface — they look and act like the real deal — but really it’s you pulling the strings.
This is where the magic happens:
With your mock, you can count how often the mock’s functions are called, stub out test data to return, or even make it scream ERROR!
in digital agony. This control allows you to write test cases for every conceivable outcome.
Keep reading, and you’ll find more in-depth explanations of mocking and the unit tests themselves.
As a quick disclaimer — in this article, I am primarily talking about Constructor Injection, where we pass services into our initialisers. There is another big component of DI, which is all about cleanly passing your services throughout the codebase. We’re focusing on constructor injection in this article, since it’s the concept you need to understand for unit testing.
Worked example: Bev app
Here’s some sample code I whipped up for an app called Bev, which uses the fantastic PunkAPI to retrieve data on Brewdog beers. It’ll be our gourmet code reference throughout this series.
Bev App and architecture and data flow
The structure is a basic layered modular architecture (wildly overkill for a project this size, but perfect for future posts).
The key layers in our technological lasagna are:
UI / Presentation Layer — display UI + handle events (such as user actions)
Data Access Layer — defines “what” data we want to get
Network Layer — deals with “how” we get this data
Each layer depends on the layer underneath: With DI, we can serve our class up something to allow smooth communication between the layers.
As well as nicely separating your concerns, this technique is extremely important when testing your code — read on to Part II, Mocking Like a Pro, to find out.
I’ve waffled for long enough. It’s time to show you some code.
UI Layer — BeerViewModel
MVVM is the most popular way to structure SwiftUI apps and build our UI with View
s that react to state changes in associated View Models.
The view model behaves like a movie director, with the view as the stereotypical brain-dead actor. The view model tells the view what to do, what to look like, and how to act; the view’s job is to look pretty and avoid thinking for itself.
This view model, at the top layer, is charged with dealing with the “real world,” such as user input and app lifecycle events.
final class BeerViewModel: ObservableObject {
@Published private(set) var beers: [Beer] = []
private let repository: BeerRepository
// "injecting" the dependency
init(repository: BeerRepository) {
self.repository = repository
}
// using the dependency
func loadBeers() async throws {
self.beers = try await repository.loadBeers()
}
}
Here, we inject the Data Layer dependency. This means we’re initialising the view model with reference to a Repository
— defined in the Data Layer that lives one step below the UI Layer in our architecture diagram.
When we call loadBeers()
, we are instructing the repository to load the data and setting the result as an array of Beer
models, which is rendered to the user as the list of tasty beverages.
This approach separates the concerns of “creating the beer repository object” and “asking the repository to load some beers.”
Data Access Layer — BeerRepository
As mentioned before, the data access layer defines what data we want to get, not how we get it.
The data layer is like the kitchen, with the interface as the menu: “I’ll give you this meal when you ask for it.” The UI Layer — a hungry patron — doesn’t know or care how the sausage is made.
If we wanted to be posh, we’d say the Data Layer is an abstraction on top of how we get the data.
public protocol BeerRepository {
func loadBeers() async throws -> [Beer]
}
public final class BeerRepositoryImpl: BeerRepository {
private let api: BeerAPI
// "injecting" the dependency
public init(api: BeerAPI) {
self.api = api
}
// using the dependency
public func loadBeers() async throws -> [Beer] {
return try await api.getBeers()
}
}
Here, we define the interface with the BeerRepository
protocol, then implement an object that conforms to that protocol, handling the beer-fetching duties.
The protocol defines all the behaviour — and keeps the messy details of how the beer is fetched hidden from the layer above. The implementation of the Repository protocol, BeerRepositoryImpl
, has the API dependency injected.
This is the “how” we’re fetching data, and it’s hidden from the protocol definition. The repository asks the API to get the beers and forwards this to the UI Layer.
“What’s the point of a data access layer if all it’s doing is forwarding the request from the layer above?”
In a more complex app, the Data Access Layer can do a lot more. For example, it might have multiple ways of accessing the Beer data.
There might be a local database. The Data Access Layer could fetch locally persisted data if it exists, quickly return that to the UI Layer, and request the latest network data to update the UI with the most up-to-date info. This hides the complexity of managing these disparate data sources from the layer above.
Intrigued? Part V brings a deep dive into the concept of the Data Access Layer, the Repository pattern, and how you can implement it using the Combine framework.
Network Layer — API
Like a skilled waiter, the network layer defines how we get the data. In this case, we request an API to fetch it from the internet. This is the “how” of getting the data we crave.
Maintaining my posh demeanour, this is the concrete implementation of our data acquisition — the lowest-level layer.
public protocol BeerAPI {
func getBeers() async throws -> [Beer]
}
public final class BeerAPIImpl: BeerAPI {
private let session: URLSessionProtocol
private let url = URL(string: "https://api.punkapi.com/v2/beers")!
// "injecting" the dependency
public init(session: URLSessionProtocol) {
self.session = session
}
// using the dependency
public func getBeers() async throws -> [Beer] {
let data = try await session.data(from: url).0
return try JSONDecoder().decode([Beer].self, from: data)
}
}
We “inject” the dependency of the URL session, which is the thing that lets us ask for things over the network. The “concrete” implementation of URLSession
is handled by foundation, a fundamental iOS library, which handles the nitty-gritty of sending our humble API request through the internet (at least, sending it to the Transport layer).
Part I: Summary
Congratulations. You made it this far.
In the stand-up comedy circuit, the hardest step — the step that gets you ahead of 99% of people — is starting. Reading the following five courses in my menu of async testing will turn you into a true connoisseur.
In summary, dependency injection is a technique that allows you to separate the responsibility of gathering the tools an entity needs to do its job from actually using those tools.
We learned a little about how we might thoughtfully structure an application with layers and use DI to facilitate data flow between these layers like a well-oiled machine.
But you haven’t seen the pièce de résistance of dependency injection until you’ve put it to the test… as in, unit testing.
Part II: Mocking Like a Pro
I appreciate that I’m writing a six-part blog series about how to write tests, and two chapters in, we still haven’t seen a single test — but trust me, I’m giving you the tools to make the actual testing portion an absolute breeze.
What are mocks?
Mocks are versions of your classes and services that conform to the same protocol (interface) as the “real” versions. These are dependency-injected into the classes you’re testing, allowing you to fine-tune behaviour. These help you achieve things like:
Count the number of times a method is called
Return sample data to return for a success-case test
Throw exceptions to test your error handling
We use mocks extensively when testing— in unit tests. We don’t care about the behaviour of the service that we’re injecting, so we generally mock its behaviour to control everything that feeds into the entity we are testing.
Quick note on the Test Pyramid
The ‘Test Pyramid’ is the canonical approach to testing software:
Unit tests live at the base of the pyramid — these usually only test one class, have lots of mocks, and we write a lot of them
Integration tests sit in the middle of the pyramid — these test the interaction between classes, and we should generally write them for critical components like auth or core business logic
End-to-end testing belongs at the top of the pyramid — these are a lot slower, test whole features at once, and we write a smaller number of these. UI tests are a classic example of end-to-end tests
The mock-related thing to note here is that unit tests pretty much exclusively use mocks, whereas in integration tests, you frequently inject and use the “real” service instead of mocks to test actual interaction between components of your software.
How do we create mocks?
Creating mocks is pretty straightforward once you have an interface built.
Let’s return to our sample code for Bev. We’re trying to mock out the API, which in Part I, we dependency-injected into the Repository
class.
Remember, by mocking out the API, we should be able to replicate the behaviour of the real API but control all the results. Here’s the protocol for the BeerAPI
:
public protocol BeerAPI {
func getBeers() async throws -> [Beer]
}
To make a mock, let’s just define a class, MockBeerAPI, and you’ll see an error in Xcode:
Simply allow Xcode to auto-fill the method required to conform to the protocol, and hey, presto! You’ve got yourself a mock.
In true Art Attack style, here’s one I made earlier:
public final class MockBeerAPI: BeerAPI {
public init() { }
public var stubBeersResponse: Result<[Beer], Error>?
public var didGetBeers: (() -> Void)?
public var getBeersCallCount = 0
public func getBeers() async throws -> [Beer] {
defer { didGetBeers?() }
getBeersCallCount += 1
return try stubBeersResponse!.get()
}
}
There’s quite a bit of extra power that I added to the mock — let’s go through it sequentially:
I added a
public init
so that any test class can easily create their own version of this mock.There’s a public
stubBeersResponse
that returns aResult
type - this means I can tell my mock to return either an error or some test data when I set this property.The didGetBeers
closure isdeferred
, which means it gets called after I return from mygetBeers()
method. This is extremely helpful when dealing with async tests, but fairly advanced stuff, which I'll cover in future posts.getBeersCallCount
is a good old-fashioned integer property that simply gets incremented every time thegetBeers
method is called. This is great for writing the common or garden “did this method call this API?” test.Finally, we modify
getBeers()
to ensure we call the deferred closure, we increment the call count, and by usingget()
, we either return the value set in thestubBeersResponse
Result, throw an error, or crash if this isn’t set.
In the sample code, I’ve actually written helper method called
evaluate()
which causes an XCTest failure instead of a crash if you forget to set this, but I’m leaving it out of this example for simplicity’s sake.
This is a lot of info to get at once, but this pretty much covers all the things you want a mock to be doing. Most mocks have many functions for the interface they cover, but each function will more or less follow this exact format.
Quick aside on modules
If you delve into the sample code, you’ll notice that the MockBeerAPI
— and actually, all the mock classes — are defined in their own modules, such as NetworkingMocks
or RepositoryMocks
. This is to allow separate modules in layers above to import this code without mixing it all in with our production code — of course, we want our tests to be using the mocks and don’t want it built as part of the app we send to our users!
If your app is single-module, it’s a lot more straightforward — you can just define the mocks as part of your app’s unit test target (and don’t need to mark everything public
).
Advanced mocking
Now you know how to create a basic mock, you have 90% of the tools you need to mock out any function on any interface you might want to test.
But there are a couple of extra cases you might want to be able to test: Mocking out existing system tools and mocking out slightly more complex interfaces.
Mocking out system tools
As we saw at the end of the previous post on Dependency Injection, the lowest layer contained our API and networking logic, which had the URLSession
dependency injected into it.
We didn’t write this ourselves — Apple provides this out-of-the-box with Foundation’s URLSession
class. So it’s a little trickier to mock this out, but still doable.
There are a few approaches, but my favoured is to create a new protocol, which copies the function signature of the functions in URLSession
we’re using.
Once we’ve done this, we can conform the existing URLSession
to our new protocol:
public protocol URLSessionProtocol {
func data(from url: URL) async throws -> (Data, URLResponse)
}
extension URLSession: URLSessionProtocol { }
This was the tricky part! Now we can simply create another mock in our tests that confirms this protocol:
public final class MockURLSession: URLSessionProtocol {
public init() { }
public var stubDataResponse: Result<(Data, URLResponse), Error>?
public var capturedURL: URL?
public func data(from url: URL) async throws -> (Data, URLResponse) {
capturedURL = url
return try stubDataResponse!.get()
}
}
It’s structured very similarly to our first mock:
public init
so anything outside the module can create its own mock.stubDataResponse
that allows us to set aResult
type with test values or an error.capturedURL
is another classic test property - whenever the API is called with a URL, we set this property so we can test the correct URL is called. You’ll also notice this property ispublic private(set)
because only this file is meant to modify the property, but any of our test modules should be able to read it.
For more details about
public private(set)
, learn how to fine-tune your access control by checking out my post: Access Control Like A Boss.
Finally, we implement
data(from url: URL)
as normal to conform to the protocol, capture the URL, and return theResult
which we set up.
Mocking out more complex interfaces
Our BeerRepository
protocol has two pieces to it:
The
loadBeers()
method, which you are now pretty familiar with.beersPublisher
, a Combine publisher.
To learn more about the approach used here, continue on to Part V— Implement your Data Access Layer with Combine.
public protocol BeerRepository {
var beersPublisher: PassthroughSubject<Result<[Beer], Error>, Never> { get }
func loadBeers() async
}
We follow the same approach to put together our mock — we create a mock class that conforms to the interface, and we fill out the required methods and properties:
public final class MockBeerRepository: BeerRepository {
public var beersPublisher = PassthroughSubject<Result<[Beer], Error>, Never>()
public init() { }
public var stubLoadBeersResponse: Result<[Beer], Error>?
public var didLoadBeers: (() -> Void)?
public var loadBeersCallCount = 0
public func loadBeers() async {
defer { didLoadBeers?() }
loadBeersCallCount += 1
beersPublisher.send(stubLoadBeersResponse!)
}
}
The
public init
is there as normal.We include a
stubLoadBeersResponse
to allow us to return either values or errors from our unit tests.We also count the number of times the
loadBeers()
method is called as expected.We defer the execution of an optional closure
didLoadBeers
in case we need to run any code after the response.Finally, the primary difference here is that instead of returning the
Result
value or error, we send the result to the publisher. This is common when using the Repository pattern.
Part II: Summary
You’ve made it, mock maestro! You now possess the skills you need to craft mocks for any API interface, system framework, or complex scenario.
With these powerful tools, you’re ready to dive into the world of async testing. Buckle up and keep reading on for Part III — Unit Testing Async Code.
Happy mocking!
Part III — Unit Testing Async Code
After comprehending the ancient wisdom of dependency injection; and adding the mysteries of mocking to your toolbelt, our first dip into asynchronous unit testing will feel like a breeze.
Let’s harness our freshly-acquired skills to write our first async tests.
Fortunately, async
/await
is specifically designed to make asynchronous code look and feel the same as synchronous code — so our first tests with them are very straightforward.
We’re going to test three common test scenarios in this post:
Is my code properly talking to the dependencies it depends on?
How is my code dealing with successful function calls?
How is my code handling error states that result from function calls?
Setting up our first tests with callCount
As I mentioned in Part II, we’ve set our mocks up to count the number of times each method is called, like a jobsworth club bouncer and his clicker. Some of the simplest tests are callCount
tests — we’re essentially asking our code to answer this question:
When I call this function, does the service on which my class depends get called?
Here’s the first such test: found in the BeerViewModelTests
in my Bev project.
1 func test_loadBeers_callsLoadOnRepository() async {
2 mockBeerRepository.stubLoadBeersResponse = .success([])
3 await sut.loadBeers()
4 XCTAssertEqual(mockBeerRepository.loadBeersCallCount, 1)
5 }
Let’s step through the code line by line to make sure we all understand:
1. In Swift’s XCTest, you need to prefix any test method with test
. We're also marking this function async
because it contains an asynchronous method call. If you leave this out, Xcode flags up a handy compiler error.
2. We’ve set our mockBeerRepository
up to fail if we call any methods without setting a stub (either a value or an error) - so here, we simply tell it to return an empty array to us when we call loadBeers
.
3. This is where the magic happens! We call loadBeers()
on the system under test — a.k.a the sut
— our instance of BeerViewModel
. Since this is an async method, we prefix with await
.
4. Finally, to make this a proper test, we need to make assertions. Since we called sut.loadBeers()
once, it should have in turn called loadBeers()
on the repository. Therefore, we expect that the mock's loadBeersCallCount
property is equal to 1.
The cousin of the
callCount
-style test is thedidThisGetCalled
test, where instead of incrementing anInt
to count the method calls, we simply toggle aBool
value totrue
when the method is called. Either approach is fine, butcallCount
can be a little more flexible and allows you to keep your mocks consistent.
Now that we’ve understood our first test, writing another similar test in the BeerRepositoryTests
is quite straightforward:
1 func test_loadBeers_callsAPI() async {
2 mockBeerAPI.stubBeersResponse = .success([])
3 await sut.loadBeers()
4 XCTAssertEqual(mockBeerAPI.getBeersCallCount, 1)
5 }
These look very similar to the view model — except that we’re using a dependency-injected mock of the BeerAPI
, and our sut
is the Repository this time around.
If you like, take a proper look at the full files for BeerViewModelTests
and BeerRepositoryTests
to see the full setup logic and take another look at the mocks we’re setting up.
Testing success and failure states
These tests are the real meat and potatoes of unit testing. We will focus on writing tests for the BeerAPI
in this section — check out BeerAPITests
in my sample project for the full code.
We often start writing our code to deal with the happy path — when everything goes according to plan — we get the data we want and process it successfully.
Of course, unfortunately, we live in the real world.
Perhaps we do simply get the data we want and clock off early. But oftentimes, a user is offline. Our function might succeed but find no actual data. Finally, we must expect the unexpected — and handle various error cases.
If we have tests that verify how our code deals with all the possible scenarios, then we have confidence that the code we’re shipping to our users is robust and maintainable.
In BeerAPI, the getBeers()
method asks our URLSessionProtocol
to request the beer data from the network at the api.punkapi.com endpoint. The URLSessionProtocol is URLSession.default
in our production code, but our tests set it up with the MockURLSession
.
The function signature getBeers()
looks like this:
public func getBeers() async throws -> [Beer]
This tells us it’s asynchronous, that we can expect it to return an array of beers, and finally, that it might throw an error for us to handle.
Testing the success state
Our first test makes sure that we can get the array of beers we’re asking for, along the happy path:
1 func test_getBeers_returnsBeer() async {
2
3 let expectedBeers: [Beer] = [
4 Beer(id: 1, name: "Punk IPA"),
5 Beer(id: 2, name: "Dead Pony Club"),
6 Beer(id: 3, name: "Lost Lager")
7 ]
8
9 let beerData = try? JSONEncoder().encode(expectedBeers)
10 mockURLSession.stubDataResponse = .success((beerData ?? Data(), URLResponse()))
11 let resultBeer = try? await sut.getBeers()
12 XCTAssertEqual(resultBeer, expectedBeers)
13 }
In lines 3–7, we’re setting up an array of beers to use as mock data.
In line 9, we take this mock
Beer
data and encode it into the SwiftData
type.In line 10, we set up our mock as before. Since this is a mock for
URLSession.data(from url: URL)
, we need to stub out a full tuple for the return type:(Data, URLResponse)
, but you can ignore theURLResponse
here. We're just telling the mock to return thebeerData
we just created whendata(from url: URL)
is called.In line 11, we’re calling the function on our API. Remember, here,
sut
is ourBeerAPI
class. As always, weawait
here to ensure we properly wait for the asynchronous method to return before continuing to our assertions.Finally, in line 12, we assert that the
Beer
objects returned by our API match what we expect.
Testing the success state with empty data
The next scenario is very similar to the happy path and dead simple if you were paying attention:
1 func test_getBeers_returnsEmptyArray() async {
2 let expectedEmptyArray = [Beer]()
3 let emptyData = try? JSONEncoder().encode(expectedEmptyArray)
4 mockURLSession.stubDataResponse = .success((emptyData ?? Data(), URLResponse()))
5 let resultBeer = try? await sut.getBeers()
6 XCTAssertEqual(resultBeer, expectedEmptyArray)
7 }
The only difference here is instead of instantiating a full array of [Beer]
, we have an empty array. We encode it, tell the API to return this empty array as data, and then check that the API succeeds in returning this empty array.
Failure state with an anticipated error
This is where things get interesting. To make sure our code is fully robust, we want to check that our code is dealing with common error cases in the way we like. Since the users of our Bev app might be deep in a bar, their network signal might not be reliable. To handle this, I’m going to amend my code to ensure we have a special case to handle this common error:
public func getBeers() async throws -> [Beer] {
// ... set up URL and request data ...
} catch let error as NSError
where error.domain == NSURLErrorDomain
&& error.code == NSURLErrorNotConnectedToInternet {
throw BeerAPIError.offline
// ... handle any other errors ...
}
When the consumers of my API class get this error, they can handle it by implementing retry logic with polling or exponential backoff, by falling back to cached data, or simply by presenting an alert to the user.
1 func test_getBeers_offline_throwsError() async {
2 let testError = NSError(domain: NSURLErrorDomain, code: NSURLErrorNotConnectedToInternet)
3 mockURLSession.stubDataResponse = .failure(testError)
4 do {
5 _ = try await sut.getBeers()
6 XCTFail("Expected to fail")
7
8 } catch {
9 XCTAssertEqual(error as? BeerAPIError, .offline)
10 }
11 }
First, in line 2, we are setting up our expected value; except this time around, we’re expecting an error.
NSURLErrorNotConnectedToInternet
is an alias for “-1009”, that is, the iOS system error code returned byURLSession.data(from url: URL)
when the user is offline.In line 3, we’re telling
URLSession
to return aResult.failure
with this error wrapped inside.In lines 4–6, we’re calling the
getBeers()
function on oursut
, except we tell XCTest to fail the test if thedo/catch
block ends in success.Finally, we catch the error in lines 8–10 and assert that it’s the type we expect to have returned by our method.
Failure state with an unexpected error
My networking logic depends on URLSession
, which has lots of ways it can fail (as we’ve seen, at least -1009 ways!). Instead of dealing with each case individually, eventually, you’ll need to end your API method with catch { throw error }
, which acts as a catch-all to forward your error.
To make life simple, let’s set up a simple error enum in our test code:
private enum TestAPIError: Error, Equatable {
case failed
}
Now, we can write one last test:
1 func test_getBeers_requestFailure_throwsError() async {
2
3 mockURLSession.stubDataResponse = .failure(TestAPIError.failed)
4 do {
5 _ = try await sut.getBeers()
6 XCTFail("Expected to fail")
7
8 } catch {
9 XCTAssertEqual(error as? TestAPIError, .failed)
10 }
11 }
This follows the same format as before, except the stub is set up with this new error, and we’re checking for the TestAPIError
in our assertion.
Since it’s not exactly async-testing-related, I won’t get into it today, but you’re welcome to look through BeerAPITests.swift
on GitHub to see how we achieve test coverage of our API:
test_getBeers_returnsEmptyArray()
test_getBeers_invalidURL_throwsCouldNotConstructURLError()
test_getBeers_invalidJSON_throwsDecodingError()
test_getBeers_emptyData_throwsDecodingError()
Part III: Summary
Congratulations! With the first three chapters under your belt, you’ve learned how to handle most situations that call for asynchronous unit tests.
As a bonus, making your code testable through dependency injection is far cleaner and easier to reason about, thanks to encapsulated logic and properly separated responsibilities.
You’re ready to handle more advanced situations in Part IV.
Part IV — Advanced Async Testing: Unstructured Concurrency
In the first two chapters, we laid down our testing foundation: We developed techniques to separate concerns through dependency injection and then created mock versions of our injected services.
Subsequently, in Part III, we wrote our first asynchronous tests that checked whether our services were called and handled cases for successful and failed calls to these services.
We’re now equipped to handle testing all sorts of tests that involve using the vanilla async/await
keywords and, as a bonus, the same principles apply to writing unit tests for async let
, taskGroup
, and throwingTaskGroup
.
In the realm of asynchronous code, however, there are cases where a straightforward linear test won’t work. Welcome to the world of unstructured concurrency.
Unstructured concurrency
Briefly, let’s define what we mean by unstructured concurrency.
The asynchronous programming language features introduced to Swift in 2021 include its crown jewel, the async
/await
keywords, but if you know Apple — or if you’re a regular punter at my tech tavern — you’ll know these keywords barely scratch the surface of asynchronous programming. Advanced features are revealed through progressive disclosure as you learn more about asynchronous programming and require more advanced use cases.
Structured concurrency allows us to treat async code as good old-fashioned linear synchronous code. Execution of your code is suspended at the await
keyword (yielding the thread to other code) and resumes at a later time.
Structured concurrency covers:
The bread-and-butter
async
/await
keywordsasync let
to run methods and fetch properties in paralleltaskGroup
(and its friends) to run multiple jobs simultaneously
Another set of concurrency features is unstructured concurrency, which behaves differently. I refer, of course, to the ever-mysterious Task
. This is called unstructured because the Task
executes code outside its created context; and does not suspend execution at its call site. In short, it distinguishes itself from the nice, linear, ordered, structured code to which we’ve become accustomed.
What is a Task?
A
Task
is a ‘unit of work’ which can run concurrently with other work.Task
s are used to encapsulate asynchronous computation and can be cancelled, paused, and resumed.
Why would we need unstructured concurrency?
Doesn’t having these unstructured
Task
s defeat the whole purpose of this new, cleaner paradigm for concurrency?
In some ways, yes, but think about it in this way:
In Swift, the main()
function called at the start of your app is synchronous*. Everything it subsequently adds to the call stack is synchronous.
Asynchronous code can only be called from an asynchronous context, so how can we start using unstructured concurrency in the first place?
Enter Task
. It allows you to create a brand-new asynchronous execution context.
That’s right — structured concurrency could not exist without unstructured concurrency somewhere up the chain.
*yes, I know that you can make the
main()
function async in command-line apps. You're very clever for also knowing that.
Problems with task and testing
Let’s return to Bev, my trusty boozy side-project.
With Task
, you create a new asynchronous context from a synchronous context using a closure, like this method in BeerViewModel
:
1 func refreshBeers() {
2 Task {
3 await repository.loadBeers()
4 }
5 }
Why might this be tough to test? It looks simple enough.
Let’s try and write a simple linear test as we did in Part III, in BeerViewModelTests.swift
. Since there are no async functions called, we can't just mark the test async
and call it a day.
func test_refreshBeers_tellsRepositoryToLoad() {
mockBeerRepository.stubLoadBeersResponse = .success([])
sut.refreshBeers()
XCTAssertEqual(mockBeerRepository.loadBeersCallCount, 1)
}
Let’s step through to make sure we’re all on the same page.
Our test is trying to check whether the
refreshBeers()
method is talking to our repository as we expect.We’ve set our mocks up so that our tests fail if we don’t set a stub, so we set one here.
We call the
refreshBeers()
method on our view model.Finally, we’re checking whether
loadBeersCallCount
on our mock repository was incremented, i.e., that the function was called.
Let’s run our test in Xcode.
Oh no!
Seems like our test failed. Let’s try and step through the execution of test_refreshBeers_tellsRepositoryToLoad()
using breakpoints to see if we can spot the issue:
Breakpoint #1 — Calling refreshBeers()
Breakpoint #2 — Our test assertion
Breakpoint #3 — Our loadBeers()
method called inside the Task
It looks like our assumptions about the order of execution are wrong.
Which, naturally, is pretty important to get right in the hermetically-sealed environment of unit tests.
The Task
creates an asynchronous execution context within the closure. Despite our burning desire to see a sea of green ticks, all things are equal under the watchful eye of the Swift runtime. Tim Apple isn't in the business of nepotism, so your unit test code doesn't get special treatment. The code we carelessly flung into an asynchronous context has to wait for the runtime to offer a thread on which to execute.
Meanwhile, the refreshBeers()
method finishes — it's a linear function on a synchronous execution context, after all. After the Task
is created, the function's job is done. Now, it returns to test_refreshBeers_tellsRepositoryToLoad()
and continues to the next line of the test code — our assertion.
Our assertion fails because loadBeersCallCount
is still 0. The unstructured Task
produced by refreshBeers()
doesn't get to jump the queue.
We’ve identified the problem, which is 80% of the job. After calling sut.refreshBeers()
in our test, we need to suspend execution of our test until we know that the code inside our MockRepository
has been called.
In short, we have to wait.
If, following our breakpoints, you were to inspect our mock after the await repository.loadBeers()
method is called, you would find that loadBeersCallCount
equals 1
as expected. We need to find a way to delay the execution of the rest of our test and wait for this to be set.
Fortunately, the XCTest
framework has always had an approach for waiting, which predates async/await by seven years — expectations!
Quick aside: All about
XCTestExpectation
Expectations allow us to test asynchronous operations. It behaves like a “promise” that an operation will complete in the future.
There are three components to expectations:
1. Setting up the expectation as a local property
2. Waiting for the expectation, with a timeout
3. Fulfilling the expectationIf the expectation is not fulfilled in time, your test fails.
In the world of unstructured concurrency, we are using closures to move execution into a context separate from the function in which your Task
is created. expectation
s are the natural fit for closure-based asynchronous operations.
Let’s update our test to handle an expectation:
func test_refreshBeers_tellsRepositoryToLoad() {
mockBeerRepository.stubLoadBeersResponse = .success([])
let exp = expectation(description: #function)
// ???
sut.refreshBeers()
waitForExpectations(timeout: 1)
XCTAssertEqual(mockBeerRepository.loadBeersCallCount, 1)
}
This test’s start, middle, and end are the same as before, except we’ve created an expectation
and waited for it before our assertion. We’re still calling sut.refreshBeers()
and asserting that the loadBeersCallCount
on our repository has been incremented. We pick one second as a timeout because, in the lightning-fast world of unit tests, we don't expect the runtime to take long to find a thread to execute.
But how do we fulfill our expectations?
This is where we need to think a little.
We need to fulfill the expectation — that is, call exp.fulfill()
— once we know that loadBeersCallCount
has already been incremented. Since our refreshBeers()
function simply sets the Task
up and nothing else, that won't be any help to us. But maybe something changes as a result of await repository.loadBeers()
, which we can use.
Leveraging our mocks
Fortunately, we are all mocking masters after digesting Part II of this series, Mocking Like a Pro. Stepping through the code, we see that we naturally increment loadBeersCallCount
in the body of the loadBeers()
function. So we need to trigger the expectation once this incrementing is complete.
Fortunately, we have set up our mocks like professionals and have the infrastructure in place to do just that:
public final class MockBeerRepository: BeerRepository {
public var beersPublisher = PassthroughSubject<LoadingState<[Beer]>, Never>()
public init() { }
public var stubLoadBeersResponse: Result<[Beer], Error>?
public var didLoadBeers: (() -> Void)?
public var loadBeersCallCount = 0
public func loadBeers() async {
defer { didLoadBeers?() }
loadBeersCallCount += 1
beersPublisher.send(stubLoadBeersResponse!)
}
}
This defer { }
statement delays the execution of the wrapped closure until the end of the function execution — it ensures it's the last thing that happens. This makes it the perfect place to fulfill our expectation
s. That's exactly what the didLoadBeers
property is here for. We can pass in closures to fulfill expectation
s that run after the mocked-out loadBeers()
method is called.
The
defer
keyword even lets us to execute code after a functionreturn
s a value. This makes it an extremely powerful tool when writing asynchronous tests which depend on mocks.
If you want to understand more about how this beersPublisher
works in our repository, keep reading down to Part V: Implement your Data Access Layer with Combine.
Now, we can implement our full test, as shown below:
func test_refreshBeers_tellsRepositoryToLoad() {
mockBeerRepository.stubLoadBeersResponse = .success([])
let exp = expectation(description: #function)
mockBeerRepository.didLoadBeers = { exp.fulfill() }
sut.refreshBeers()
waitForExpectations(timeout: 1)
XCTAssertEqual(mockBeerRepository.loadBeersCallCount, 1)
}
And it passes!
To summarise how far we’ve come, we utilised the power of our mocks, and leveraged our understanding of the Swift concurrency model to create passing unit tests for code that contain unstructured concurrency.
Expectations in asynchronous
Tests
expectation
s come from a world long before async
/await
, introduced in 2015, and so originally had no concept of interoperation with the Swift concurrency model— essentially, they didn’t really work in a test marked async
, so until very recently, we needed to awkwardly wrap our async methods in a Task
.
But all this changed since Xcode 14.3. Nowadays, you can make expectations in async tests and wait for them using the await
keyword:
await fulfillment(of: [exp], timeout: 1)
I find myself reaching for this tool in three scenarios:
You have an unstructured
Task
being triggered as part of aprivate
function. This is called by an asyncinternal
orpublic
method, which you can call from your test.Your async method might set a synchronous closure, which includes a
Task
.Instead of using a mock’s closure to fulfill our expectation, we use Combine to listen to changes to a
@Published
property in a view model, which itself changes in response to anasync
method.
There are a number of tricks to get Combine playing well with
async
/await
. To learn them all, keep reading through to the finale; Part VI: Combine, async/await, and Unit Testing.
Getting a sneak peek at some code from the grand finale, here’s a quick example of how Scenario #1 looks:
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)
}
}
Advanced use of defer in our mocks
There are instances we can go further: we can add conditionals to the deferred didLoadBeers
closure to fulfill our expectation
s only when a specific condition is fulfilled.
This is helpful when you call the mock method multiple times with different answers — expectations may only be fulfilled a single time and will cause tests to fail if called twice. Plus, you might only want your assertion checked after a specific value arrives.
Combine and async Tests
Sometimes we need to wait for a specific value to exist before we trigger our assertion — since fulfilling an expectation
twice causes the XCTest framework to crash.
In this example, the value of userPublisher
, while it loads, might have a loading state of .idle
, .loading
, .success(user)
, or .failure(error)
. Our test cycles through .idle
and .loading
before we land on .success
, the value from which we want to make our assertion (I knew English GCSE would come in handy one day).
func test_loadUser_setsName() async {
mockUserAPI.stubGetUserResponse = .success(User(name: "Jacob")
let exp = expectation(description: #function)
sut.userPublisher
.sink(receiveValue: {
guard case .success(let user) = $0 else { return }
exp.fulfill()
})
.store(in: &cancelBag)
await sut.loadUser()
await fulfillment(of: [exp], timeout: 1)
XCTAssertEqual(sut.user.name, "Jacob")
}
Here, we listen to the value from our repo and only fulfill the expectation
when the user
arrives wrapped in the .success
response from our userPublisher
.
This touches on a seldom-discussed issue regarding asynchronous unit testing. In 2019, coinciding with SwiftUI 1.0, Apple released the Combine framework to try its hand at a functional reactive programming paradigm. This was Apple’s most significant new concurrency-related release since Grand Central Dispatch in 2009.
Since async
/await
was released in 2021, Combine has somewhat fallen by the wayside. However, a fairly complete and robust approach to concurrency is often the best way to solve a problem. More importantly, since Combine is an important implementation detail of SwiftUI (it underpins @ObservableObject
), it is vital to understand how to work with it to test your view models properly.
The interoperation between Combine and async
/await
is not always intuitive. The final two chapters dive headfirst into this maelstrom to demonstrate how you can get these two concurrency approaches to cooperate — in both your code and your unit tests.
Part IV: Summary
In this article, we’ve explored the problems you might encounter when utilising unstructured concurrency. We explored firsthand the execution order of our code during unit tests and why the straightforward approach we used with basic async
functions didn't work when invoking Task
.
We looked at using the ‘legacy’ technique of XCTest expectation
s to handle this closure-based code. We leveraged the power of our mocks and the under-appreciated defer
keyword to ensure that our test execution happened in the precise order we define. Finally, we looked at more advanced use cases, which we could solve by using the brand-new async
-compatible expectation fulfillment
API.
In the penultimate chapter of this masterclass, I’ll give a refresher on Combine and demonstrate how you can use it to implement a reactive Data Access Layer in your app. Keep reading for Part V — (interlude): Implement your Data Access layer with Combine.
Part V: (interlude) — Implement your Data Access Layer with Combine
Now we can realise the beauty of a data access layer — we can use any and all of these approaches to fetching data, and synthesise a result with the most up-to-date data, as quickly as possible.
We’re in the home stretch now. In the penultimate chapter of our async testing odyssey, we’re doing something a bit different to prepare for the grand finale.
Apple released Combine in 2019. The framework underpins much of the reactive nature of SwiftUI but is also a powerful tool in its own right. We’re going to take a break from testing in this chapter, and I’m going to get us all up to the same level of understanding in preparation for Part VI: Combine, async/await, and Unit Testing.
Today, we’re going to:
Explore the benefits of a data access layer.
Learn all about the Repository pattern.
Gain an introduction to the basics of the Combine framework.
Finally, we’ll pull these concepts together to show you how you can implement your own data access layer with Combine.
The data access layer: architectural overview
Let’s revisit our nifty architecture diagram for Bev from Part I — Dependency Injection Demystified. In our modular architecture, the key layers are:
UI / Presentation Layer — to display UI and handle events
Data Access Layer — to define what data we want to get
Network Layer — to deal with how we get this data
Data access as an abstraction
The data access layer is an abstraction to make life easy for the UI Layer above it.
The interface of the data access layer could be as simple as this:
public protocol DataAccessLayer {
func getDataModels() async throws -> [Models]
}
The data access layer is an interface that promises, among other things, to give you some data. The key is that it doesn’t tell you how it is getting the data you want — it’s on a need-to-know basis, and the consumer of the API doesn’t need to know.
The innards of the data access layer are non-public; the nuts and bolts are an implementation detail, which the UI Layer (and, perhaps, your front-end developer) is freed from worrying about.
Implementation details
Inside the data access layer, however, we do care about the implementation details for fetching the data. This is where it might get interesting — there are lots of ways we might want to retrieve data in an app:
+------------------------------+--------------------------+-----------------------------------+
| Approach | Implementation | Speed |
+------------------------------+--------------------------+-----------------------------------+
| Fetch from network | HTTP request to internet | Very slow (0.01 to 10 seconds) |
| Fetch from local persistence | Read from disk | Medium (0.1 to 1 milliseconds) |
| Fetch from local cache | Read from RAM | Very fast (10 to 100 nanoseconds) |
+------------------------------+--------------------------+-----------------------------------+
Now we can realise the beauty of a data access layer — we can use any and all of these approaches to fetching data and synthesise a result that gives the consumer of our API the most up-to-date data as quickly as possible.
We might first check our local cache for the data we want to see if we can deliver it instantly. If it’s not already here, we check our local persistence. Finally, as a last resort, we can fetch data from the network. While it’s by far the slowest method, an HTTP call ensures the data returned to the user is up-to-date. We can then persist and cache the data for fast retrieval later.
Retrieval strategies
We can perform all these fetches simultaneously and generate a single result to return to the UI Layer. We can update our interface to allow consumers to define a retrieval strategy such as:
Returning any data as quickly as possible.
Returning only the most up-to-date data from the network, but using local storage as a fallback.
Returning data as fast as possible, but potentially returning more than once (e.g., returning data from the cache, then from the network once it returns).
Here’s a basic approach for implementing the interface with this strategy:
public enum DataAccessStrategy {
case fastestAvailable
case upToDateWithFallback
case returnMultipleTimes
}
public protocol DataAccessLayer {
func getDataModels(strategy: DataAccessStrategy) async throws -> [Models]
}
The Repository pattern
The Repository pattern is an approach for implementing a data access layer in your app. The core idea is to manage data access logic in a centralised location. This means a Repository can collect together multiple data access layers with different underlying implementations — for instance, we might fetch from an in-memory cache, a persistence layer, or over the network.
The resulting abstraction allows our Repository interface to return an approximation to in-memory objects, which your UI Layer can handle without trouble.
This is only an approximation because while we would love to instantly return in-memory objects every time, sometimes things take longer than expected or go wrong.
Our interface — the API contract we are promising consumers — is hence marked
async throws
.async
ensures the worst-case scenario for retrieval speed over the network is accounted for, allowing the Swift runtime to suspend execution at the call site, andthrows
ensures that consumers know they should plan to handle potential errors.
In this example, we will implement both an in-memory cache and a network store, but perhaps I’ll upgrade this with a persistence layer and retrieval strategies once I start digging into SwiftData.
Implementing our repository with Combine
Combine is a functional reactive programming framework released by Apple in 2019, which provides a declarative API for processing values asynchronously. Much of SwiftUI uses the Combine framework as an implementation detail, most notably @Published
properties you put in your view models.
To avoid massively increasing the scope of this already quite unwieldy six-parter, read this SwiftLee article for an overview of the basics.
The primary concept we’re going to use is CurrentValueSubject
, which does two things:
1. Broadcasts a notification to all its subscribers when its value is updated (just like its sibling, the PassthroughSubject
)
2. Stores the most recently broadcast value.
You might already see how we could utilise this — #2 gives us an in-memory cache out-of-the-box!
Repository interface
Let’s go back to Bev. We’ve used Combine in the Repository
module to implement our data access layer via the BeerRepository
interface:
public protocol BeerRepository {
var beersPublisher: CurrentValueSubject<LoadingState<[Beer]>, Never> { get }
func loadBeers() async
}
Our BeerRepository
has the async loadBeers()
method with whom, after our journey through Part IV, we should all be very much acquainted. It also has a beersPublisher
property which exposes a public getter for the CurrentValueSubject
, allowing API consumers to read the value and hence subscribe to its broadcasted values via the sink
subscriber.
One thing to appreciate here — this interface is extremely minimal. We can wrap all kinds of complex data access business logic underneath, and the UI Layer is none the wiser!
If we wanted to add writes so we could update Beers
, we would only need to add something like write(beers: [Beer]) async throws
to the interface and trust the interface to republish the updated values via the CurrentValueSubject
after writing.
LoadingState
here is a simple wrapper enum that works like a souped-upResult
type — theidle
andloading
cases let the API users know whether we are waiting for a value or not, as well as returning the success state with the desired values or any errors:
public enum LoadingState<T> {
case idle
case loading
case success(T)
case failure(Error)
}
Repository implementation
Since we aren’t implementing persistence or any clever retrieval strategies here, the full implementation of our BeerRepository
is pretty brief:
public final class BeerRepositoryImpl: BeerRepository {
// 1
public private(set) var beersPublisher = CurrentValueSubject<LoadingState<[Beer]>, Never>(.idle)
// 2
private let api: BeerAPI
// 3
public init(api: BeerAPI = BeerAPIImpl()) {
self.api = api
}
// 4
public func loadBeers() async {
// 5
beersPublisher.send(.loading)
do {
// 6
let beers = try await api.getBeers()
beersPublisher.send(.success(beers))
} catch {
// 7
beersPublisher.send(.failure(error))
}
}
}
Let’s step through each piece of this in turn:
To start with, we add the
beersPublisher
property to ensure protocol conformance. We mark this aspublic private(set)
since we need it to be available to any API consumers outside this module but don't want anything outside this class to modify the instance of theCurrentValueSubject
we might subscribe to elsewhere. Since it's aLoadingState
, we initialize it with.idle
so consumers know there's nothing to see here to start with.We have an API dependency that is kept
private
. It's an implementation detail that consumers of our interface don't need to know about.Our initialiser is
public
so consumers in other modules can instantiate instances and offers both aBeersAPIImpl()
instance to use by default or the ability to override and inject a mock version of the API dependency.We complete our protocol conformance with the async
loadBeers()
method.When loading begins, we send a
.loading
state to thebeersPublisher
. This value is broadcast to all its subscribers, enabling our UI Layer to show a loading indicator while the user waits.We ask our API dependency to fetch some beer data over the network. If this works, we can then send the array of Beers to our publisher, which broadcasts the values wrapped in a
.success
state.Finally, we handle the unhappy path. Here, we’re simply passing the error to the consumer of our interface to handle, again wrapped in a
.failure
state. The UI Layer checks for these errors and handles them in a way that's helpful to the user.
Consuming the values
In our BeerViewModel
, we have a simple subscription set up to read the values broadcast from the publisher.
@MainActor
final class BeerViewModel: ObservableObject {
// ...
// 1
private var cancelBag = Set<AnyCancellable>()
// 2
private let repository: BeerRepository
// 3
init(repository: BeerRepository = BeerRepositoryImpl()) {
self.repository = repository
setupBeerListener(on: repository)
}
// 4
private func setupBeerListener(on repo: BeerRepository) {
repo.beersPublisher
.receive(on: RunLoop.main)
.sink(receiveValue: { [weak self] in
self?.handleBeer(loadingState: $0)
}).store(in: &cancelBag)
}
// ...
}
Let’s briefly go over the main moving parts here:
Our
cancelBag
collects all the subscribersOur repository is a private property here
With the same approach used in our Repository, the initializer here takes a repository as an argument to allow easy DI during our tests. It instantiates our standard
BeerRepositoryImpl
in the default argument. The initializer callssetupBeerListener(on: repository)
.This private method takes the
beersPublisher
property on the repo and sets up a subscription. It's received on the main RunLoop to ensure it's thread-safe, then the value issink
ed, and we handle theLoadingState
in another method. Finally, the subscription is stored in thecancelBag
.
Advantages of this approach
The Combine-based approach is beneficial because when sharing the Repository among multiple potential consumers — modules, and view models in your app — the subscriptions you create allow the most up-to-date value to be broadcast everywhere it is needed automatically, every time it loads. It also allows for a logical separation of loading the data and handling the result.
Almost as a side-effect, you get caching for free via CurrentValueSubject
- but you may want to create a separate cache that doesn't get wiped when setting the state to loading
, and utilising PassthroughSubject
instead.
Part V: Summary
In this article, I hope I’ve successfully evangelised the benefits of using a Data Access Layer in your own applications to separate the concerns of the data you need to get; and how to get this data. You might have a better idea of how you could utilise this to make your own APIs easier to consume and how to implement retrieval strategies to allow your UI Layer to optimise for speed, correctness, or both. Finally, you gained a brief overview of Combine and how you might use it to implement your own Data Access Layer using the Repository pattern.
If you’re reading everything sequentially, I hope this article was a nice change of pace away from testing. It was a very purposeful segue, however, because the next chapter is all about how to unit test your project when using Combine and async/await together in the same codebase.
We’re finally ready to bring all our learning together in Part VI — Combine, async/await, and Unit Testing.
Combine, async/await, and Unit Testing
Welcome to the grand finale — the victory lap we’ve been building towards for the last five chapters, the last 14 weeks of writing, and the last 12,000 words!
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 that allows you to write robust and maintainable code with these modern frameworks and language features.
Interoperation
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 that 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 that 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 that 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 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 that 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 that 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 ourBeerViewModel
(i.e., oursut
).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 theBeersViewModel
with. Therefore, to prevent the expectation from being fulfilled, we first check that 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 ourBeerRepository
Create 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 fulfillment
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 so we should 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 two 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 passed 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.
Part VI: The Final Summary
We’ve been on quite a journey.
This series was conceived many years ago when I was a junior engineer. I was trying to wrap my head around dependency injection and couldn’t understand anyone's explanations for the life of me — it was as if they were explaining it in x86 while my brain was running ARM.
We started with getting a grasp on the foundation s— firstly, dependency injection, and how it can allow you to design your codebase in a maintainable and testable way. Secondly, we learned the basics of mocking and an approach to setting up super-powered mocks for your services to test a wide variety 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.
Are these out-of-cycle re-release posts useful to you, or annoying inbox noise? Please let me know in the comments!
Still thirsty for more unit testing content? Check out my piece on writing tests for the iOS 17 Observation framework, featuring a nifty open-source library you can use!