Jacob’s Tech Tavern

Jacob’s Tech Tavern

Async Unit Testing: The Comprehensive Guide

Utilise modern Swift Concurrency with full test coverage

Jacob Bartlett's avatar
Jacob Bartlett
Mar 11, 2024

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 Tasks 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.

  1. Part I — Dependency Injection Demystified

  2. Part II — Mocking Like a Pro

  3. Part III — Unit Testing Async Code

  4. Part IV — Advanced async testing: Unstructured concurrency

  5. Part V — (interlude) — Implement your Data Access layer with Combine

  6. Part VI — Combine, async/await, and Unit Testing

Part I: Dependency Injection Demystified

Image generated using Midjourney

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.

If you know, you know. Image from Japanese Knife Company

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.

I refuse to admit how long I spent making this app to procrastinate from writing this blog 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:

  1. UI / Presentation Layer — display UI + handle events (such as user actions)

  2. Data Access Layer — defines “what” data we want to get

  3. Network Layer — deals with “how” we get this data

Super-basic architecture diagram of our sample app. If you look into the sample code, we also have a Domain module (which everything depends on), but that’s not important for the purposes of this article.
Architecture of the Bev app. Underlying everything is our Domain layer, which defines our model objects

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 Views 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

Quick diagram of the Test Pyramid in all its glory

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:

MockBeerAPI first draft with protocol conformance

Simply allow Xcode to auto-fill the method required to conform to the protocol, and hey, presto! You’ve got yourself a mock.

Auto-filling the protocol conformance for MockBeerAPI

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 a Result 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 is deferred, which means it gets called after I return from my getBeers() 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 the getBeers 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 using get(), we either return the value set in the stubBeersResponse 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 a Result 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 is public 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 the Result which we set up.

Mocking out more complex interfaces

Our BeerRepository protocol has two pieces to it:

  1. The loadBeers() method, which you are now pretty familiar with.

  2. 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 the didThisGetCalled test, where instead of incrementing an Int to count the method calls, we simply toggle a Bool value to true when the method is called. Either approach is fine, but callCount 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 Swift Data 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 the URLResponse here. We're just telling the mock to return the beerData we just created when data(from url: URL) is called.

  • In line 11, we’re calling the function on our API. Remember, here, sut is our BeerAPI class. As always, we await 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 by URLSession.data(from url: URL) when the user is offline.

  • In line 3, we’re telling URLSession to return a Result.failure with this error wrapped inside.

  • In lines 4–6, we’re calling the getBeers() function on our sut, except we tell XCTest to fail the test if the do/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 keywords

  • async let to run methods and fetch properties in parallel

  • taskGroup (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. Tasks are used to encapsulate asynchronous computation and can be cancelled, paused, and resumed.

Why would we need unstructured concurrency?

Doesn’t having these unstructured Tasks 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.

  1. Our test is trying to check whether the refreshBeers() method is talking to our repository as we expect.

  2. We’ve set our mocks up so that our tests fail if we don’t set a stub, so we set one here.

  3. We call the refreshBeers() method on our view model.

  4. 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!

Assertion failure at BeerViewModelTests.swift: “0” is not equal to “1”

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()

#1: Breakpoint on Task

Breakpoint #2 — Our test assertion

#2: Breakpoint on XCTAssertEqual

Breakpoint #3 — Our loadBeers() method called inside the Task

#3: Breakpoint on await repository.loadBeers()

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.

Subscribe for free to continue reading

Enter your email and get every post from Jacob’s Tech Tavern.
Already have an account? Sign in
© 2026 Jacob Bartlett · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for writing, podcasting, and media founders.