In the first two chapters, we built 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.
Part II Mocking like a Pro
Part III Unit Testing with async/await
Part V (interlude) - Implement your Data Access layer with Combine
Part VI Combine, async/await, and Unit Testing
Part IV: Unstructured concurrency
Let's first define, briefly, what we mean by unstructured concurrency.
The asynchronous programming language features introduced to Swift in 2021 include their 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 start to require more advanced use cases.
Structured concurrency allows us to treat async code as if it's 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/awaitkeywordsasync letto run methods and fetch properties in paralleltaskGroup(and its variants) to run multiple jobs simultaneously
There's another set of concurrency features known as unstructured concurrency which behave differently. I refer, of course, to the ever-mysterious Task. This is called unstructured because the Task executes code outside of the context in which it is created; 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
Taskis 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 like 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.
1 func test_refreshBeers_tellsRepositoryToLoad() {
2 mockBeerRepository.stubLoadBeersResponse = .success([])
3 sut.refreshBeers()
4 XCTAssertEqual(mockBeerRepository.loadBeersCallCount, 1)
5 }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
loadBeersCallCounton 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 order of execution is 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. So 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 7 years - expectations!
XCTestExpectation
Expectations allow us to test asynchronous operations. It behaves like a 'promise' that an operation will complete in the future.
There are 3 components to expectations:
Setting up the expectation as a local property
Waiting for the expectation, with a timeout
Fulfilling the expectation
If 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. Expectations are the natural fit for closure-based asynchronous operations.
Let's update our test to handle an expectation:
1 func test_refreshBeers_tellsRepositoryToLoad() {
2 mockBeerRepository.stubLoadBeersResponse = .success([])
3 let exp = expectation(description: #function)
4 // ???
6 sut.refreshBeers()
7 waitForExpectations(timeout: 1)
8 XCTAssertEqual(mockBeerRepository.loadBeersCallCount, 1)
9 }The start, middle, and end of this test 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 1 second as a timeout, because we don't expect the runtime to take long finding a thread on which to execute.


