
Introduction
The asyncExpectation function is a powerful testing utility designed for the Swift Testing framework that provides functionality similar to XCTest’s XCTExpectation. It allows you to wait for asynchronous events to complete before your test continues, making it essential for testing concurrent code, Combine code, network operations, and other async behaviors.
Why AsyncExpectation?
The Swift Testing framework uses a different approach than XCTest, relying on confirmation blocks rather than expectations. However, there are scenarios where you need more control over:
• When an expectation is fulfilled
• How many times it should be fulfilled
• Timeout handling for expectations
• Complex async flows that need to wait for specific events
• Combine publishers that emit values asynchronously
This is where asyncExpectation comes in—it bridges the gap between XCTest’s familiar expectation pattern and Swift Testing’s modern architecture.
The Architecture: Breaking It Down
1. The Actor-Based State Management
The function uses an internal actor to manage state safely across concurrent contexts:
actor State {
var fulfilledCount = 0
var hasResumedContinuation = false
var timeoutTask: Task<(), Never>?
func fulfill(_ count: Int) {
self.fulfilledCount += count
}
func resumeContinuation() -> Bool {
guard hasResumedContinuation == false else { return false }
hasResumedContinuation = true
return true
}
func setTimeoutTask(_ task: Task<(), Never>?) {
self.timeoutTask = task
}
}
Why an actor? Actors provide automatic synchronization for mutable state in concurrent code. This ensures:
• fulfilledCount is safely incremented from multiple concurrent calls
• hasResumedContinuation prevents multiple resumption attempts (which would crash)
• timeoutTask can be safely managed across different tasks
2. The Dual-Layer Waiting Mechanism
The function combines two Swift Testing/Swift Concurrency primitives:
Layer 1: confirmation
This is Swift Testing’s built-in mechanism for verifying events occur the expected number of times:
try await confirmation(comment, expectedCount: expectedCount,
isolation: isolation,
sourceLocation: sourceLocation) { confirmation in
// Verification logic
confirmation(count: count) // Called when events occur
}
Layer 2: withCheckedThrowingContinuation
This suspends the async function until manually resumed, providing precise control over when execution continues:
try await withCheckedThrowingContinuation { continuation in
// Setup expectation and timeout
// Resume when ready: continuation.resume()
}
3. The AsyncExpectation Object
The actual expectation object passed to your test code is a simple, sendable struct:
public struct AsyncExpectation: Sendable {
private let closure: @Sendable (Int) -> Void
public func fulfill(count: Int = 1) {
closure(count)
}
}
When you call fulfill(), it triggers a chain of events:
-
Updates the fulfilled count in the actor
-
Calls the confirmation handler
-
Checks if the expected count is reached
-
Resumes the continuation if complete
-
Cancels the timeout task
-
The Timeout Mechanism
If a timeout is specified, a separate task is spawned:
if let timeout {
await state.setTimeoutTask(Task {
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
if !Task.isCancelled, await state.resumeContinuation() {
continuation.resume(throwing: AsyncExpectationTimeoutError())
}
})
}
This task:
• Sleeps for the specified duration
• Checks if it was cancelled (meaning the expectation was fulfilled)
• If not cancelled, resumes with a timeout error
Step-by-Step Execution Flow
Let’s trace what happens when you call asyncExpectation:
Step 1: Initialization
let state = State() // Create actor for state management
Step 2: Enter Confirmation Block
Swift Testing’s confirmation is called with your expected count.
Step 3: Enter Continuation Block
The function suspends, waiting for manual resumption.
Step 4: Create AsyncExpectation
An AsyncExpectation object is created with a closure that:
• Increments the fulfilled count
• Calls the confirmation handler
• Checks for completion
• Resumes if done
Step 5: Start Timeout (if specified)
A background task begins sleeping for the timeout duration.
Step 6: Execute Your Test Code
Your body closure is called with the expectation object.
Step 7: Wait for Fulfillment
When you call expectation.fulfill():
• The fulfilled count increases
• If count reaches expectedCount, the continuation resumes
• The timeout task is cancelled
Step 8: Completion or Timeout
Either:
• Success: Expected count reached, test continues
• Timeout: Time expires before fulfillment, error thrown
• Error: Your code threw an error, propagated to test
Code Example: Understanding Each Component
Here’s a detailed example showing how each piece works:
import Testing
@Test("Network request completion")
func testNetworkRequest() async throws {
// Create an expectation that should be fulfilled exactly once
// with a 5-second timeout
try await asyncExpectation(
"Network should complete",
expectedCount: 1,
seconds: 5.0
) { expectation in
// Simulate a network service
let networkService = NetworkService()
// Start async operation
Task {
let result = await networkService.fetchData()
// Fulfill the expectation when done
expectation.fulfill()
// Additional assertions
#expect(result.isValid)
}
// The test will wait here until:
// 1. expectation.fulfill() is called once
// 2. Or 5 seconds pass (timeout error)
}
}
Also, here is an example of using asyncExpectation with Combine publishers:
import Testing
import Combine
@Test("Combine publisher emits expected value")
func testPublisherEmitsValue() async throws {
try await asyncExpectation(
"Publisher should emit a value",
expectedCount: 1,
seconds: 2.0
) { expectation in
let publisher = Just("Hello, World!")
.delay(for: .milliseconds(100), scheduler: DispatchQueue.main)
var cancellables = Set<AnyCancellable>()
publisher
.sink { value in
#expect(value == "Hello, World!")
expectation.fulfill()
}
.store(in: &cancellables)
}
}
Final Implementation
The final implementaion of asyncExpectation provides a robust, flexible way to handle asynchronous testing scenarios in Swift Testing, giving you the tools to write reliable tests for complex async code:
import Foundation
import Testing
/// An async function based on `SwiftTesting` that is similar to XCTExpectation and waits for the `AsyncExpectation.fulfill(count:)` to be called for a required count before completing.
/// It uses a combination of `confirmation` and `withCheckedThrowingContinuation` to wait for fulfillment of the expectation.
/// - Parameters:
/// - comment: An optional comment to apply to any issues generated by this
/// function.
/// - expectedCount: The number of times the expected event should occur when
/// `body` is invoked. The default value of this argument is `1`, indicating
/// that the event should occur exactly once. Pass `0` if the event should
/// _never_ occur when `body` is invoked.
/// - seconds: The number of seconds within which all expectations must be fulfilled.
/// - body: The function to invoke.
/// - Throws: The error thrown by `Body` or an error thrown if the expectation is not fulfilled.
public func asyncExpectation(_ comment: Testing.Comment? = nil, expectedCount: Int = 1, seconds timeout: TimeInterval? = nil, isolation: isolated (any Actor)? = #isolation, sourceLocation: Testing.SourceLocation = #_sourceLocation, _ body: @Sendable @escaping (AsyncExpectation) async throws -> Void) async throws {
actor State {
var fulfilledCount = 0
var hasResumedContinuation = false
var timeoutTask: Task<(), Never>?
func fulfill(_ count: Int) {
self.fulfilledCount += count
}
func resumeContinuation() -> Bool {
guard hasResumedContinuation == false else { return false }
hasResumedContinuation = true
return true
}
func setTimeoutTask(_ task: Task<(), Never>?) {
self.timeoutTask = task
}
}
let state = State()
try await confirmation(comment, expectedCount: expectedCount, isolation: isolation, sourceLocation: sourceLocation) { confirmation in
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
let testingExpectation = AsyncExpectation { count in
Task {
await state.fulfill(count)
confirmation(count: count)
if await state.fulfilledCount == expectedCount {
if await state.resumeContinuation() {
continuation.resume()
}
await state.timeoutTask?.cancel()
await state.setTimeoutTask(nil)
}
}
}
Task {
if let timeout {
await state.setTimeoutTask(Task {
try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))
if !Task.isCancelled, await state.resumeContinuation() {
continuation.resume(throwing: AsyncExpectationTimeoutError())
}
})
}
do {
try await body(testingExpectation)
} catch {
if await state.resumeContinuation() {
continuation.resume(throwing: error)
}
}
}
}
}
}
public struct AsyncExpectation: Sendable {
private let closure: @Sendable (Int) -> Void
public init(closure: @escaping @Sendable (Int) -> Void) {
self.closure = closure
}
public func fulfill(count: Int = 1) {
closure(count)
}
}
public struct AsyncExpectationTimeoutError: Error, LocalizedError {
public var errorDescription: String? {
return "Expectation timed out. This may be because the expected count was not reached or the count exceeded the expected value."
}
}
Comparison with XCTest
| Feature | XCTest | AsyncExpectation |
|---|---|---|
| Framework | XCTest | Swift Testing |
| Async/Await | Limited | Native |
| Syntax | wait(for: [exp]) | try await asyncExpectation |
| Timeout | Separate wait call | Built-in parameter |
| Error Handling | Manual | Automatic propagation |
| Actor Safety | Manual | Built-in |
| Multiple Expectations | Array of expectations | Single call with count |
Conclusion
The asyncExpectation function provides a powerful, modern way to test asynchronous code in Swift Testing. By combining actors for state management, continuations for precise control, and Swift Testing’s confirmation mechanism, it offers:
• Type safety through Swift’s strong typing
• Concurrency safety through actors
• Ergonomic API that feels natural in async contexts
• Flexible timeout handling for different scenarios
• Clear error reporting when expectations aren’t met
Whether you’re testing network operations, UI updates, delegate callbacks, combine publishers, or complex async flows, asyncExpectation provides the control and safety you need for reliable asynchronous testing.