Home » Advanced Async Testing: Unstructured Concurrency | by Jacob Bartlett | Aug, 2023

Advanced Async Testing: Unstructured Concurrency | by Jacob Bartlett | Aug, 2023

by Icecream
0 comment

  1. Part I Dependency Injection Demystified
  2. Part II Mocking like a Pro
  3. Part III Unit Testing with async/await
  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

Briefly, let’s outline what we imply by unstructured concurrency.

The asynchronous programming language options launched to Swift in 2021 embrace their crown jewel, the async/await key phrases, but when you understand Apple — or for those who’re a daily punter at my tech tavern — you’ll know these key phrases barely scratch the floor of asynchronous programming. Advanced options are revealed by progressive disclosure as you be taught extra about asynchronous programming and require extra superior use circumstances.

Structured concurrency permits us to deal with async code as if it’s good old style linear synchronous code. Execution of your code is suspended on the await key phrase (yielding the thread to different code) and resumes at a later time.

Structured concurrency covers:

  • The bread-and-butter async/await key phrases
  • async let to run strategies and fetch properties in parallel
  • taskGroup (and its variants) to run a number of jobs concurrently

Another set of concurrency options is unstructured concurrency, which behave in a different way. I refer, in fact, to the ever-mysterious Task. This is known as unstructured as a result of the Task executes code exterior its created context; and doesn’t droop execution at its name website. In brief, it distinguishes itself from the good, linear, ordered, structured code to which we’ve turn into accustomed.

What is a Task?

A Task is a ‘unit of labor’ which might run concurrently with different work. Tasks are used to encapsulate asynchronous computation and might be cancelled, paused, and resumed.

Doesn’t having these unstructured Tasks defeat the entire objective of this new, cleaner paradigm for concurrency?

In some methods, sure, however give it some thought on this approach:

In Swift, the fundamental() perform referred to as in the beginning of your app is synchronous*. Everything it subsequently provides to the decision stack is synchronous.

Asynchronous code can solely be referred to as from an asynchronous context, so how can we begin utilizing unstructured concurrency within the first place?

Enter Task. It permits you to create a brand-new asynchronous execution context.

That’s proper — structured concurrency couldn’t exist with out unstructured concurrency someplace up the chain.

*sure, I do know which you can make the fundamental() perform async in command-line apps. You’re very intelligent for additionally realizing that.

Let’s return to Bev, my trusty boozy side-project.

With Task, you create a brand new asynchronous context from a synchronous context utilizing a closure, like this technique in BeerViewModel:

1  func refreshBeers() {
2 Task {
3 await repository.loadBeers()
4 }
5 }

Why may this be powerful to check? It appears easy sufficient.

Let’s try to write a easy linear take a look at as we did in Part III, in BeerViewModelTests.swift. Since there are not any async features referred to as, we will not simply mark the take a look at async and name it a day.

1  func test_refreshBeers_tellsRepositoryToLoad() { 
2 mockBeerRepository.stubLoadBeersResponse = .success([])
3 sut.refreshBeers()
4 XCTAssertEqual(mockBeerRepository.loadBeersCallCount, 1)
5 }

Let’s step by to ensure we’re all on the identical web page.

  1. Our take a look at is making an attempt to examine whether or not the refreshBeers() technique is speaking to our repository as we count on.
  2. We’ve set our mocks up in order that our exams fail if we don’t set a stub, so we set one right here.
  3. We name the refreshBeers() technique on our view mannequin.
  4. Finally, we’re checking whether or not loadBeersCallCount on our mock repository was incremented, i.e., that the perform was referred to as.

Let’s run our take a look at in Xcode.

Oh no!

Seems like our take a look at failed. Let’s try to step by the execution of test_refreshBeers_tellsRepositoryToLoad() utilizing breakpoints to see if we are able to spot the problem:

Breakpoint #1 — Calling refreshBeers()

Breakpoint #2 — Our take a look at assertion

Breakpoint #3 — Our loadBeers() technique referred to as contained in the Task

It appears like our assumptions concerning the order of execution are fallacious.

Which, naturally, is fairly vital to get proper within the hermetically-sealed atmosphere of unit exams.

The Task creates an asynchronous execution context inside the closure. Despite our burning want to see a sea of inexperienced ticks, all issues are equal below the watchful eye of the Swift runtime. Tim Apple is not within the enterprise of nepotism, so your unit take a look at code does not get particular remedy. The code we carelessly flung into an asynchronous context has to attend for the runtime to supply a thread on which to execute.

Meanwhile, the refreshBeers() technique finishes – it is a linear perform on a synchronous execution context, in any case. After the Task is created, the perform’s job is finished. Now, it returns to test_refreshBeers_tellsRepositoryToLoad() and continues to the following line of the take a look at code – our assertion.

Our assertion fails as a result of loadBeersCallCount remains to be 0. The unstructured Task produced by refreshBeers() does not get to leap the queue.

We’ve recognized the issue, which is 80% of the job. After calling sut.refreshBeers() in our take a look at, we have to droop execution of our take a look at till we all know that the code inside our MockRepository has been referred to as.

If, following our breakpoints, you have been to examine our mock after the await repository.loadBeers() technique is known as, you’ll discover that loadBeersCallCount equals 1 as anticipated. We must discover a solution to delay the execution of the remainder of our take a look at and anticipate this to be set.

Fortunately, the XCTest framework has at all times had an method for ready, which predates async/await by seven years — expectations!

XCTestExpectation

Expectations permit us to check asynchronous operations. It behaves like a ‘promise’ that an operation will full sooner or later.

There are three elements to expectations:

1. Setting up the expectation as a neighborhood property
2.
Waiting for the expectation, with a timeout
3.
Fulfilling the expectation

If the expectation shouldn’t be fulfilled in time, your take a look at fails.

In the world of unstructured concurrency, we’re utilizing closures to maneuver execution right into a context separate from the perform wherein your Task is created. Expectations are the pure match for closure-based asynchronous operations.

Let’s replace our take a look at to deal with an expectation:

1  func test_refreshBeers_tellsRepositoryToLoad() {
2 mockBeerRepository.stubLoadBeersResponse = .success([])
3 let exp = expectation(description: #perform)
4 // ???
6 sut.refreshBeers()
7 waitForExpectations(timeout: 1)
8 XCTAssertEqual(mockBeerRepository.loadBeersCallCount, 1)
9 }

This take a look at’s begin, center, and finish are the identical as earlier than, besides we’ve created an expectation and waited for it earlier than our assertion. We’re nonetheless calling sut.refreshBeers() and asserting that the loadBeersCallCount on our repository has been incremented. We choose one second as a timeout as a result of we do not count on the runtime to take lengthy to discover a thread on which to execute.

But how can we fulfill our expectations?

This is the place we have to suppose somewhat.

We want to meet the expectation — that’s, name exp.fulfill() – as soon as we all know that loadBeersCallCount has already been incremented. Since our refreshBeers() perform merely units the Task up and nothing else, that will not be any assist to us. But perhaps one thing adjustments on account of await repository.loadBeers(), which we are able to use.

Fortunately, we’re all mocking masters as a result of we now have all learn Part II of this sequence, Mocking like a Pro. Stepping by the code, we see that we, naturally, increment loadBeersCallCount within the physique of the loadBeers() perform. So we have to set off the expectation as soon as this incrementing is full.

Fortunately, we now have arrange our mocks like professionals and have the infrastructure in place to just do that:

public remaining 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.ship(stubLoadBeersResponse!)
}
}

This defer { } assertion delays the execution of the wrapped closure till the top of the perform execution; it ensures it is the very last thing that occurs. This makes it the proper place to meet our expectations. That’s precisely what the didLoadBeers property is right here for. We can cross in closures to meet expectations that run after the mock technique is known as.

The defer key phrase even lets us to execute code after a perform returns a worth. This makes it an especially highly effective instrument when writing exams that depend upon mocks.

If you need to perceive extra about how this beersPublisher works in our repository, sit tight for Part V: (interlude) – Implement your Data Access layer with Combine.

Now, we are able to implement our full take a look at, as proven beneath:

1  func test_refreshBeers_tellsRepositoryToLoad() {
2 mockBeerRepository.stubLoadBeersResponse = .success([])
3 let exp = expectation(description: #perform)
4 mockBeerRepository.didLoadBeers = { exp.fulfill() }
6 sut.refreshBeers()
7 waitForExpectations(timeout: 1)
8 XCTAssertEqual(mockBeerRepository.loadBeersCallCount, 1)
9 }

And it passes!

To summarise how far we’ve come, we utilised the ability of our mocks’ energy and our understanding of the Swift concurrency mannequin to create inexperienced unit exams for code that leverage unstructured concurrency.

Expectations come from a world lengthy earlier than async/await, and so initially had no idea of interoperation — basically, they didn’t actually work in a take a look at marked async, so till very lately, we wanted to awkwardly wrap our async strategies in a Task.

But all this modified since Xcode 14.3. Nowadays, you can also make expectations in async exams and anticipate them utilizing the await key phrase:

await success(of: [exp], timeout: 1)

I discover myself reaching for this instrument in three situations:

  1. You have an unstructured Task being triggered as a part of a non-public perform. This is known as by an async inner or public technique, which you’ll be able to name out of your take a look at.
  2. Your async technique may set a synchronous closure, which features a Task.
  3. Instead of utilizing a mock’s closure to meet our expectation, we use Combine to take heed to adjustments to a @Published property in a view mannequin, which itself adjustments in response to an async technique.

There are a variety of tips to get Combine taking part in properly with async/await and I’m going to clarify all of them in Part VI — Combine, async/await, and Unit Testing.

Here’s a fast instance of how Scenario #1 appears:

func test_asyncMethod_callsSyncMethod_createsUnstructuredTask() async {
let exp = expectation(description: #perform)
mockAPI.didCallFunctionInaspectUnstructuredTask = { exp.fulfill() }
await sut.someAsyncMethod()
await success(of: [exp], timeout: 1)
XCTAssertEqual(mockAPI.performInaspectUnstructuredTaskCallCount, 1)
}

There are situations we are able to go additional: we are able to add conditionals to the deferred didLoadBeers closure to meet our expectations solely when a particular situation is fulfilled.

This is useful while you name the mock technique a number of occasions with totally different solutions — expectations might solely be fulfilled a single time and can trigger exams to fail if referred to as twice. Plus, you may solely need your assertion checked after a particular worth arrives.

Here’s one instance of ready for a worth from a repository to exist earlier than we set off our assertion. The worth of userPublisher could be .idle, .loading, .success(consumer), or .failure(error). Our take a look at cycles by .idle and .loading earlier than we land on .success, the worth from which we need to make our assertion (I knew English GCSE would come in useful at some point).

func test_loadUser_setsName() async {
mockUserAPI.stubGetUserResponse = .success(User(identify: "Jacob")
let exp = expectation(description: #perform)
sut.userPublisher
.sink(receiveValue: {
guard case .success(let consumer) = $0 else { return }
exp.fulfill()
})
.retailer(in: &cancelBag)
await sut.loadUser()
await success(of: [exp], timeout: 1)
XCTAssertEqual(sut.consumer.identify, "Jacob")
}

Here, we take heed to the worth from our repo and solely fulfill the expectation when the consumer arrives wrapped within the .success response from our userPublisher.

The final instance touches on a seldom-discussed challenge concerning asynchronous unit testing.

In 2019, coinciding with SwiftUI 1.0, Apple launched the Combine framework to strive its hand at a purposeful reactive programming paradigm. This was Apple’s most important new concurrency-related launch since Grand Central Dispatch in 2009.

Since async/await was launched in 2021, Combine has considerably fallen by the wayside. However, it’s a pretty full and strong method to concurrency that’s usually the easiest way to resolve an issue. More importantly, since Combine is a crucial implementation element of SwiftUI (it underpins @ObservableObject), it is important to know the right way to work with it to check your view fashions correctly.

The inter-operation between Combine and async/await shouldn’t be at all times intuitive. My remaining two chapters dive headfirst into this maelstrom to exhibit how one can get these two concurrency approaches to cooperate — in your code and unit exams.

In this text, we’ve explored the issues you may encounter when using unstructured concurrency. We explored first-hand the execution order of our code throughout unit exams and why the simple method we used with fundamental async features did not work when invoking Task.

We checked out utilizing the ‘legacy’ strategy of XCTest expectations to deal with this closure-based code. We leveraged the ability of our mocks and the under-appreciated defer key phrase to make sure that our take a look at execution occurred within the exact order we outline. Finally, we checked out extra superior use circumstances, which we may remedy through the use of the brand-new async-compatible expectation fulfilment API.

In the penultimate chapter of this masterclass, I’ll give a refresher on Combine and exhibit how you need to use it to implement a reactive Data Access Layer in your app. Stay tuned for Part V — (interlude): Implement your Data Access layer with Combine.

You may also like

Leave a Comment