Modern app development often requires asynchronous tasks to be triggered, executed in the background, then responded to once the asynchronous task has completed. While async/await has made this a far simpler process in structured environments, sometimes asynchronous jobs have to be handled in an unstructured way via Apple's Task {} API. This interface represents a convenient way to spawn tasks when we do not wish to block the current execution context waiting for the results. However, usage of this API can represent a challenge when trying to unit test our app, resulting in code that is difficult or impossible to test reliably. In this article we will explore ways to banish this unreliability forever by bringing structure to our use of Swift's unstructured concurrency.
Let's pretend we're building an app that allows the user to tap a button to generate a random number. We generate this random number via an API call, purely to bring some asynchronicity to our app:
Let's take a look at how this is implemented. First, we need a random number generator, which is built like this:
/// The default implementation of ``RandomNumberGenerating``.
final class RandomNumberGenerator: RandomNumberGenerating {
func generateRandomNumber() async throws -> Int {
let data = try await URLSession.shared.data(from: URL(string: "https://www.randomnumberapi.com/api/v1.0/random?min=100&max=1000")!).0
let numbers = try JSONDecoder().decode([Int].self, from: data)
return numbers[0]
}
}
/// The protocol to conform to when providing random number generation
/// capabilities.
protocol RandomNumberGenerating: AnyObject, Sendable {
/// Requests a new random number be generated.
/// - Returns: The generated random number.
func generateRandomNumber() async throws -> Int
}
Here we have a simple call to www.randomnumberapi.com to asynchronously generate a random number. Our generator also sits behind a protocol named RandomNumberGenerating, to aid us with mocking and stubbing the random number generator interaction later in our unit tests.
Now - the view. This is fairly straightforward:
@MainActor
struct ContentView: View {
private let viewModel: ContentViewModel
init(randomNumberGenerator: RandomNumberGenerating = RandomNumberGenerator()) {
viewModel = ContentViewModel(randomNumberGenerator: randomNumberGenerator)
}
var body: some View {
VStack(spacing: 12) {
Text("My number is \(viewModel.randomNumber)")
.accessibilityIdentifier("number_label")
Button("New Number") {
Task {
await viewModel.updateRandomNumber()
}
}
.buttonStyle(.borderedProminent)
.accessibilityIdentifier("button")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(uiColor: .systemGroupedBackground))
}
}
@MainActor
@Observable final class ContentViewModel {
private(set) var randomNumber = 0
private let randomNumberGenerator: RandomNumberGenerating
init(randomNumberGenerator: RandomNumberGenerating) {
self.randomNumberGenerator = randomNumberGenerator
}
func updateRandomNumber() async {
let randomNumber = try! await randomNumberGenerator.generateRandomNumber()
self.randomNumber = randomNumber
}
}
Our view is passed a RandomNumberGenerating instance, which it in turn passes to a private, observable view model. Our view displays the view model's randomNumber property, and triggers calls to the view model's updateRandomNumber function when the user taps the "New Number" button.
This is where the asynchronous nature of the random number generation begins to affect the view. updateRandomNumber calls the random number generator, which returns a new number asynchronously. As such, the updateRandomNumber function is also marked as async. The button whose action triggers this function call is created in our view's body property, which is not asynchronous and so cannot directly call an async function. Because of this, we wrap the call to viewModel.updateRandomNumber() in a Task {} invocation to escape the button's synchronous action function.
So far, so normal. But things start to get tricky once we get to the unit tests for our view.
Unit testing our views is important and doesn't have to be difficult. When unit testing SwiftUI views, the ViewInspector library is invaluable, and will be used in the following examples. To test our view, first we will need a mock random number generator to inject in to our view so that we can control the random numbers that are generated:
import Foundation
@testable import UnstructuredConcurrencyTesting
final class MockRandomNumberGenerator: RandomNumberGenerating, @unchecked Sendable {
var randomNumberToReturn = 1
private(set) var generateRandomNumberCalled = false
func generateRandomNumber() async throws -> Int {
generateRandomNumberCalled = true
return randomNumberToReturn
}
}
Now we can begin setting up our test suite:
import Testing
import ViewInspector
@testable import UnstructuredConcurrencyTesting
@Suite
@MainActor
final class ContentViewTests {
private var contentView: ContentView!
private let mockRandomNumberGenerator = MockRandomNumberGenerator()
@Test("It shows the correct default message")
func numberLabel() throws {
givenAContentView()
#expect(try contentView.numberLabel.string() == "My number is 0")
}
private func givenAContentView() {
contentView = ContentView(randomNumberGenerator: mockRandomNumberGenerator)
}
private func whenTheRandomNumberGenerator(willReturn number: Int) {
mockRandomNumberGenerator.randomNumberToReturn = number
}
private func whenTheButtonIsTapped() {
let button = try! contentView.button
try! button.tap()
}
}
private extension ContentView {
var numberLabel: InspectableView<ViewType.Text> {
get throws {
try inspect().find(viewWithAccessibilityIdentifier: "number_label").text()
}
}
var button: InspectableView<ViewType.Button> {
get throws {
try inspect().find(viewWithAccessibilityIdentifier: "button").button()
}
}
}
We have a simple test here for checking that our view's number label is set up correctly when the view is first displayed. Next we add a test to check the behaviour when the user taps the "New Number" button:
@Test("It requests and displays the new number when the button is tapped")
func numberLabelUpdate() throws {
givenAContentView()
whenTheRandomNumberGenerator(willReturn: 12)
whenTheButtonIsTapped()
#expect(mockRandomNumberGenerator.generateRandomNumberCalled)
let labelText = try contentView.numberLabel.string()
#expect(labelText == "My number is 12")
}
However, when we run this test we get two failures:
Expectation failed: (mockRandomNumberGenerator → UnstructuredConcurrencyTestingTests.MockRandomNumberGenerator).generateRandomNumberCalled → false
Expectation failed: (labelText → "My number is 0") == "My number is 12"
These expectations show that the generateRandomNumber function was not called on our mock random number generator, and the number label's text did not update to show the expected number. This is because when the button is tapped we spawn off a new Task, which is not run before our test's expectations are evaluated. How should we remedy this?
There are tools available to workaround this issue, but they come with drawbacks that make them unreliable. The first of these tools is the Task.megaYield() function, which suspends the current task in order for newly spawned tasks to hopefully have time to execute. However, yielding in this way does not guarantee that new tasks will have executed by the time the megaYield() function returns.
A more reliable alternative is the withMainSerialExecutor function, which performs tasks on the main serial executor. As its documentation states:
This function attempts to run all tasks spawned in the given operation serially and deterministically. It makes asynchronous tests faster and less flakey.
That sounds just the ticket. However, its documentation also states:
We say that it "attempts to run all tasks spawned in an operation serially and deterministically" because under the hood it relies on a global, mutable variable in the Swift runtime to do its job, and there are no scoping guarantees should this mutable variable change during the operation.
So this doesn't guarantee that any tasks created within the scope of withMainSerialExecutor will be executed. As such, usage of this API will allow us to write tests that pass most of the time, but will periodically fail based on the internal workings of the Swift runtime. If we want a 100% reliable test suite then this solution is insufficient. We will have to do some work ourselves.
In order to solve this problem in a reliable way, we need to take matters in to our own hands and abstract away the Task API altogether. To do this, first we have to create an abstraction of the Task type:
/// The protocol to conform to for types that provide scheduled task behaviour.
/// This is primarily intended to abstract away use of the `Task {}` API such
/// that it may be stubbed out to allow for deterministic task execution during
/// unit testing.
protocol ScheduledTask<Success, Failure> {
associatedtype Success = any Sendable
associatedtype Failure = any Error
@discardableResult init(name: String?, priority: TaskPriority?, operation: sending @escaping @isolated(any) () async -> Success)
}
extension Task: ScheduledTask where Failure == Never {}
This ScheduledTask protocol includes everything we need to stub out our usage of the Task type. We also include an extension here on Task so that it may be used as a ScheduledTask where required, which leads us on nicely to the next piece of the puzzle - creating a task scheduling abstraction:
/// The protocol to conform to for types that provide task scheduling behaviour.
/// This is primarily intended to abstract away use of the `Task {}` API in to a
/// "task scheduler" API such that it may be stubbed out to allow for
/// deterministic task execution during unit testing.
protocol TaskScheduling<Success, Failure>: Sendable {
associatedtype Success = any Sendable
associatedtype Failure = any Error
associatedtype ScheduledTaskType: ScheduledTask<Success, Failure>
/// Runs the given nonthrowing operation asynchronously as part of a new
/// _unstructured_ top-level task.
/// - Parameters:
/// - name: Human readable name of the task.
/// - priority: The priority of the operation task.
/// - operation: The operation to perform.
/// - Returns: A reference to the task.
@discardableResult func scheduleTask(withName name: String?, priority: TaskPriority?, operation: sending @escaping @isolated(any) () async -> Success) -> ScheduledTaskType
}
extension TaskScheduling {
@discardableResult func scheduleTask(operation: sending @escaping @isolated(any) () async -> Success) -> ScheduledTaskType {
self.scheduleTask(withName: nil, priority: .userInitiated, operation: operation)
}
}
This TaskScheduling protocol describes a type that allows us to schedule ScheduledTaskTypes for execution. It deliberately mirrors the Task API that we are replacing in order to make the replacement process easier. We also include an extension that provides useful defaults for the majority of cases where the call site does not wish to specify a task name or priority.
In order to actually schedule tasks we'll need to build a concrete task scheduler, which looks like this:
/// The default implementation of ``TaskScheduling``. Uses Foundation's `Task`
/// API to vend scheduled tasks.
final class DefaultTaskScheduler: TaskScheduling {
typealias Success = Void
typealias Failure = Never
typealias ScheduledTaskType = Task<Success, Failure>
func scheduleTask(withName name: String?, priority: TaskPriority?, operation: sending @escaping @isolated(any) () async -> Void) -> Task<Success, Failure> {
Task(name: name, priority: priority, operation: operation)
}
}
That's it - that's all the code we need to provide a task scheduler that is backed by the existing Task API. Next, we need to update our view to use a task scheduler rather than the Task API directly. This can be done easily in two steps, the first of which is to inject a task scheduler in the view's initialiser:
struct ContentView: View {
private let viewModel: ContentViewModel
private let taskScheduler: any TaskScheduling<Void, Never>
init(randomNumberGenerator: RandomNumberGenerating = RandomNumberGenerator(), taskScheduler: any TaskScheduling<Void, Never> = DefaultTaskScheduler()) {
viewModel = ContentViewModel(randomNumberGenerator: randomNumberGenerator)
self.taskScheduler = taskScheduler
}
…
}
Notice that we provide a default value of DefaultTaskScheduler() so that consumers of our view do not have to specify one themselves.
All that's left to do now is replace our Task {} call to one that uses the task scheduler. We replace this:
Button("New Number") {
Task {
await viewModel.updateRandomNumber()
}
}
with this:
Button("New Number") {
taskScheduler.scheduleTask {
await viewModel.updateRandomNumber()
}
}
Our view is functionally identical after this change, but we now have the ability to mock out the task scheduling infrastructure to allow us to deterministically test our view's behaviour. We will get on to that now.
Our first task is to create an immediate task scheduler that will capture the tasks that it is asked to perform and allow us to trigger them immediately at will. For this we need an immediate scheduled task type:
final class ImmediateScheduledTask: ScheduledTask {
typealias Success = Void
typealias Failure = Never
let name: String?
let priority: TaskPriority?
let operation: () async -> any Sendable
init(name: String?, priority: TaskPriority?, operation: sending @escaping @isolated(any) () async -> Void) {
self.name = name
self.priority = priority
self.operation = operation
}
}
This type simply captures the values that it is initialised with so that they may be accessed later. This is particularly important for the operation parameter, as it is this closure that will allow us to execute the task's operation deterministically.
Next, we create the immediate task scheduler:
/// The implementation of `TaskScheduling` to be used when running unit tests in
/// order to run unstructured concurrency operations in a deterministic manner.
final class ImmediateTaskScheduler: TaskScheduling, @unchecked Sendable {
typealias ScheduledTaskType = ImmediateScheduledTask
private var scheduledTasks: [ImmediateScheduledTask] = []
func scheduleTask(withName name: String?, priority: TaskPriority?, operation: sending @escaping @isolated(any) () async -> Void) -> ImmediateScheduledTask {
let task = ImmediateScheduledTask(name: name, priority: priority, operation: operation)
scheduledTasks.append(task)
return task
}
/// Executes all of the tasks created in response to calls to
/// ``scheduleTask(withPriority:operation:)`` on the receiver.
func runAllScheduledTasks() async {
let scheduledTasksCopy = scheduledTasks
scheduledTasks = []
for task in scheduledTasksCopy {
await _ = task.operation()
}
}
}
Note that this scheduler captures all of the tasks that it is asked to create in its scheduledTasks array. When runAllScheduledTasks is called, the operations of these tasks are executed in order. No voodoo, no magic, just simple closure execution in an async function. Note that a copy of the scheduled tasks is taken before clearing scheduledTasks, then execution of the captured tasks begins. This is so that any new tasks scheduled during the execution of the existing captured tasks are captured and can be run later if needed.
Now that we have all of this infrastructure sorted, we can update our view's tests to make use of it. First, we need to create an ImmediateTaskScheduler and inject it in to the view being tested:
@Suite
@MainActor
final class ContentViewTests {
private var contentView: ContentView!
private var immediateTaskScheduler = ImmediateTaskScheduler()
private let mockRandomNumberGenerator = MockRandomNumberGenerator()
private func givenAContentView() {
contentView = ContentView(randomNumberGenerator: mockRandomNumberGenerator,
taskScheduler: immediateTaskScheduler)
}
…
}
Next, we can update our whenTheButtonIsTapped function to run the task that is added to the task scheduler when the button is tapped:
private func whenTheButtonIsTapped() async {
let button = try! contentView.button
try! button.tap()
await immediateTaskScheduler.runAllScheduledTasks()
}
Finally, our test function needs to add await before its call to whenTheButtonIsTapped, and we're done:
@Test("It requests and displays the new number when the button is tapped")
func numberLabelUpdate() async throws {
givenAContentView()
whenTheRandomNumberGenerator(willReturn: 12)
await whenTheButtonIsTapped()
#expect(mockRandomNumberGenerator.generateRandomNumberCalled)
let labelText = try contentView.numberLabel.string()
#expect(labelText == "My number is 12")
}
Our test now passes, and will always pass. We have replaced the non-deterministic nature of Swift's unstructured concurrency with a reliable mechanism for capturing and exectuing asynchronous tasks without fundamentally altering the behaviour of our program when it runs outside of the unit testing environment.
As we have seen, it is possible to bring structure to Swift's unstructured concurrency, and in doing so we can once again create completely reliable test suits that will always run as expected both locally and on CI/CD setups. No longer will we have to re-run CI jobs in order to get a passing build for PR purposes, saving build minute costs and developer sanity in the process. Check out the example repository for a complete sample project demonstrating the solution outlined in this article.