Skip to content

Testing Async Code in Swift: Stop Using Task.yield() in Your Tests

Published: at 09:00 AM

Introduction

Testing async code in Swift often turns into a lottery: tests pass locally, fail on CI, and people try to fix them with Task.yield() or Task.sleep(). Eventually the team gets used to re-running flaky builds and stops trusting the pipeline.

The problem is usually a race between the test and a task that the object under test creates on its own. Task.yield() can make the test pass, but it doesn’t establish any ordering between the two. To make the test deterministic, it needs control over the tasks it creates indirectly.

If you can make a method async, do it. This article is about a different situation: integrating async code into synchronous entry points you can’t control, such as UIKit and SwiftUI lifecycle methods.

Table of contents

Open Table of contents

Why the Test Fails

Take a ProfileViewModel whose load() starts a Task, fetches the user name through a service, and stores it in a property:

@MainActor
final class ProfileViewModel {
    private(set) var userName = ""
    private let userService: IUserService

    init(userService: IUserService) {
        self.userService = userService
    }

    func load() {
        Task {
            userName = await userService.fetchUserName()
        }
    }
}
@MainActor
func testLoad() {
    // when
    viewModel.load()

    // then
    XCTAssertEqual(viewModel.userName, "Alice") // failed
}

The test fails because load() returns right after creating the Task, so the assertion runs before the task body does. In this case it’s not even a race: ProfileViewModel is isolated to the main actor, and a synchronous test holds the main thread until it returns. The task can only run after the assertion has already executed.

Making the test async turns this into a real race between the task and the assertion, and the outcome depends on scheduling.

Why Task.yield() Doesn’t Fix It

A common suggestion is to yield to the created task before asserting:

// Dangerous, flaky test
@MainActor
func testLoad() async {
    // when
    viewModel.load()
    await Task.yield() // give way to the created task

    // then
    XCTAssertEqual(viewModel.userName, "Alice")
}

or, with Swift Testing:

@Test @MainActor
func load() async {
    // when
    viewModel.load()
    await Task.yield() // give way to the created task

    // then
    #expect(viewModel.userName == "Alice")
}

Locally such a test often passes, which is what makes it dangerous.

Controlling Task Creation with ITaskFactory

Instead of calling Task { ... } directly, we create tasks through an injected factory. The test implementation keeps track of those tasks and lets the test wait for them explicitly.

Step 1. An abstraction over task creation

Production code gets a task factory protocol:

protocol ITaskFactory {
    @discardableResult
    func task<Success: Sendable>(
        priority: TaskPriority?,
        @_inheritActorContext operation: sending @escaping @isolated(any) () async -> Success
    ) -> Task<Success, Never>
}

Protocol requirements can’t have default values, so we add a default priority through an extension:

extension ITaskFactory {
    @discardableResult
    func task<Success: Sendable>(
        @_inheritActorContext operation: sending @escaping @isolated(any) () async -> Success
    ) -> Task<Success, Never> {
        task(priority: nil, operation: operation)
    }
}

The production implementation is trivial: it just creates a task.

struct TaskFactory: ITaskFactory {
    @discardableResult
    func task<Success: Sendable>(
        priority: TaskPriority?,
        @_inheritActorContext operation: sending @escaping @isolated(any) () async -> Success
    ) -> Task<Success, Never> {
        Task(priority: priority, operation: operation)
    }
}

Production behavior stays the same; the factory just gives tests a way to observe the tasks being created.

Step 2. A test factory that remembers every task

The test implementation keeps track of the tasks it creates and provides waitUntilIdle() to wait for them.

Task has generic success and failure types, so we can’t put arbitrary tasks directly into one array. ITask gives us a common interface for waiting on them.

/// A protocol representing a task that can be awaited until its execution completes.
protocol ITask {
  /// Waits for the `Task` to complete by retrieving its result.
  func wait() async
}

// MARK: - Task + ITask

/// Extends the `Task` type to conform to the `ITask` protocol.
extension Task: ITask {
  func wait() async {
    _ = await result
  }
}
public final class TestTaskFactory: @unchecked Sendable {
  // MARK: Properties

  private let lock = NSLock()
  private var tasks: [ITask] = []

  // MARK: Initialization

  public init() {}

  // MARK: Public

  /// Waits until all tasks in the queue have completed execution.
  public func waitUntilIdle() async {
    while let task = popTask() {
      await task.wait()
    }
  }

  // MARK: Private

  private func addTask(_ task: ITask) {
    lock.lock()
    defer { lock.unlock() }
    tasks.append(task)
  }

  private func popTask() -> ITask? {
    lock.lock()
    defer { lock.unlock() }
    return tasks.popLast()
  }
}

// MARK: ITaskFactory

extension TestTaskFactory: ITaskFactory {
  public func task<Success: Sendable>(
    priority: TaskPriority?,
    @_inheritActorContext operation: sending @escaping @isolated(any) () async -> Success
  ) -> Task<Success, Never> {
    let task = Task(priority: priority, operation: operation)
    addTask(task)
    return task
  }
}

The while loop is important here. A task can create another task through the same factory, so waiting for the initial batch isn’t enough. We keep draining the queue until there are no tasks left.

waitUntilIdle() waits for tasks to finish, so it will hang if one of them never completes (an endless for await loop, a continuation that is never resumed, a mock that never calls back). In such cases, cancel the pending tasks (for example, with a cancelAll() helper in your own test factory) or add a timeout. Also remember that it only sees tasks created through the factory.

Step 3. Injecting the factory into ProfileViewModel

@MainActor
final class ProfileViewModel {
    private(set) var userName = ""
    private let userService: IUserService
    private let taskFactory: ITaskFactory

    init(userService: IUserService, taskFactory: ITaskFactory) {
        self.userService = userService
        self.taskFactory = taskFactory
    }

    func load() {
        taskFactory.task {
            self.userName = await self.userService.fetchUserName()
        }
    }
}

Step 4. A stable test

@MainActor
func testLoad() async {
    // given
    let taskFactory = TestTaskFactory()
    let viewModel = ProfileViewModel(
        userService: userServiceMock,
        taskFactory: taskFactory
    )

    // when
    viewModel.load()
    await taskFactory.waitUntilIdle()

    // then
    XCTAssertEqual(viewModel.userName, "Alice")
}

waitUntilIdle() returns only after every task created through the factory has finished, so the assertion no longer depends on scheduling.

Ready-Made Implementation

You don’t have to write all of this from scratch in every project. An ITaskFactory implementation, along with test doubles for tasks and DispatchQueues, is available in the open source library space-code/concurrency. It’s designed as a set of concurrency primitives that make your code more testable. The library is installed via Swift Package Manager.

What About XCTestExpectation and confirmation?

XCTest has XCTestExpectation and Swift Testing has confirmation, so why not use them? They verify that an event happened, while here we need to know that the work has finished. A test using confirmation:

@Test @MainActor
func load() async {
    await confirmation { fetched in
        userServiceMock.onFetchUserName = { fetched() }
        viewModel.load()
    }
    #expect(viewModel.userName == "Alice")
}

confirmation requires the confirmation to happen before the closure body returns. But load() returns immediately, and fetchUserName() is called later inside the task. The confirmation arrives too late, and the test fails.

With XCTestExpectation, the test can still race:

@MainActor
func testLoad() async {
    // given
    let called = expectation(description: "fetchUserName called")
    userServiceMock.onFetchUserName = { called.fulfill() }

    // when
    viewModel.load()

    // then
    await fulfillment(of: [called], timeout: 1)
    XCTAssertEqual(viewModel.userName, "Alice") // still a race
}

The expectation is fulfilled when fetchUserName() is called, which is before the task writes the result to userName. The wait is over but the work isn’t, so the assertion races with the task again, and you also have to pick a timeout.

Both tools are still the right choice for callbacks, delegates, NotificationCenter, Combine publishers, and tasks created by code you don’t control. For a Task you create yourself, waitUntilIdle() is the more direct tool.

Edge Cases

Task Cancellation

The factory returns a Task, so you can store it and call cancel(). The test factory lets you verify this scenario too: create a task, cancel it, call waitUntilIdle(), and make sure there were no side effects. This works if the operation itself responds to cancellation correctly, for example by checking Task.isCancelled or using cancellable awaits.

Don’t Overuse the Factory

The factory is for places where you start a fire-and-forget task from a synchronous context, such as load() in a view model. If a method can be async itself, it’s better to make it so and simply call it with await in the test. Then you don’t need the factory at all.

The approach also has a cost: every Task in production code has to go through the factory, which adds a dependency to initializers and requires discipline from the whole team. It pays off for code with real async logic that you want to cover with tests, and is overkill for trivial one-liners.

Alternatives

Conclusion

Task.yield() and sleep only hide the race between the test and the task. To remove it, create tasks through an ITaskFactory and wait for them in the test with waitUntilIdle().

The trade-off is an extra dependency in the initializer. For fire-and-forget tasks started from synchronous APIs, it gives the test control over task completion without relying on scheduler timing.

Thanks for reading

If you enjoyed this post, be sure to follow me on X or Mastodon to keep up with the new content.


avatar

Nikita Vasilev

A software engineer with over 8 years of experience in the industry. Writes this blog and builds open source frameworks.


Next Post
Demystifying Thread Hopping with Swift 6.2 Approachable Concurrency