axiom-testing-async

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Testing Async Code — Swift Testing Patterns

异步代码测试——Swift Testing 模式

Modern patterns for testing async/await code with Swift Testing framework.
使用Swift Testing框架测试async/await代码的现代模式。

When to Use

适用场景

Use when:
  • Writing tests for async functions
  • Testing callback-based APIs with Swift Testing
  • Migrating async XCTests to Swift Testing
  • Testing MainActor-isolated code
  • Need to verify events fire expected number of times
Don't use when:
  • XCTest-only project (use XCTestExpectation)
  • UI automation tests (use XCUITest)
  • Performance testing with metrics (use XCTest)
适用场景:
  • 为异步函数编写测试
  • 使用Swift Testing测试基于回调的API
  • 将异步XCTest迁移至Swift Testing
  • 测试MainActor隔离的代码
  • 需要验证事件触发次数符合预期
不适用场景:
  • 仅使用XCTest的项目(使用XCTestExpectation)
  • UI自动化测试(使用XCUITest)
  • 带指标的性能测试(使用XCTest)

Key Differences from XCTest

与XCTest的主要区别

XCTestSwift Testing
XCTestExpectation
confirmation { }
wait(for:timeout:)
await confirmation
@MainActor
implicit
@MainActor
explicit
Serial by defaultParallel by default
XCTAssertEqual()
#expect()
continueAfterFailure
#require
per-expectation
XCTestSwift Testing
XCTestExpectation
confirmation { }
wait(for:timeout:)
await confirmation
隐式
@MainActor
显式
@MainActor
默认串行执行默认并行执行
XCTAssertEqual()
#expect()
continueAfterFailure
每个预期使用
#require

Patterns

测试模式

Pattern 1: Simple Async Function

模式1:简单异步函数

swift
@Test func fetchUser() async throws {
    let user = try await api.fetchUser(id: 1)
    #expect(user.name == "Alice")
}
swift
@Test func fetchUser() async throws {
    let user = try await api.fetchUser(id: 1)
    #expect(user.name == "Alice")
}

Pattern 2: Completion Handler → Continuation

模式2:完成处理器→Continuation

For APIs without async overloads:
swift
@Test func legacyAPI() async throws {
    let result = try await withCheckedThrowingContinuation { continuation in
        legacyFetch { result, error in
            if let result {
                continuation.resume(returning: result)
            } else {
                continuation.resume(throwing: error!)
            }
        }
    }
    #expect(result.isValid)
}
针对没有异步重载的API:
swift
@Test func legacyAPI() async throws {
    let result = try await withCheckedThrowingContinuation { continuation in
        legacyFetch { result, error in
            if let result {
                continuation.resume(returning: result)
            } else {
                continuation.resume(throwing: error!)
            }
        }
    }
    #expect(result.isValid)
}

Pattern 3: Single Callback with confirmation

模式3:带confirmation的单次回调

When a callback should fire exactly once:
swift
@Test func notificationFires() async {
    await confirmation { confirm in
        NotificationCenter.default.addObserver(
            forName: .didUpdate,
            object: nil,
            queue: .main
        ) { _ in
            confirm()  // Must be called exactly once
        }
        triggerUpdate()
    }
}
当回调应恰好触发一次时:
swift
@Test func notificationFires() async {
    await confirmation { confirm in
        NotificationCenter.default.addObserver(
            forName: .didUpdate,
            object: nil,
            queue: .main
        ) { _ in
            confirm()  // 必须恰好调用一次
        }
        triggerUpdate()
    }
}

Pattern 4: Multiple Callbacks with expectedCount

模式4:带expectedCount的多次回调

swift
@Test func delegateCalledMultipleTimes() async {
    await confirmation(expectedCount: 3) { confirm in
        delegate.onProgress = { progress in
            confirm()  // Called 3 times
        }
        startDownload()  // Triggers 3 progress updates
    }
}
swift
@Test func delegateCalledMultipleTimes() async {
    await confirmation(expectedCount: 3) { confirm in
        delegate.onProgress = { progress in
            confirm()  // 调用3次
        }
        startDownload()  // 触发3次进度更新
    }
}

Pattern 5: Verify Callback Never Fires

模式5:验证回调从不触发

swift
@Test func noErrorCallback() async {
    await confirmation(expectedCount: 0) { confirm in
        delegate.onError = { _ in
            confirm()  // Should never be called
        }
        performSuccessfulOperation()
    }
}
swift
@Test func noErrorCallback() async {
    await confirmation(expectedCount: 0) { confirm in
        delegate.onError = { _ in
            confirm()  // 应从不被调用
        }
        performSuccessfulOperation()
    }
}

Pattern 6: MainActor Tests

模式6:MainActor测试

swift
@Test @MainActor func viewModelUpdates() async {
    let vm = ViewModel()
    await vm.load()
    #expect(vm.items.count > 0)
    #expect(vm.isLoading == false)
}
swift
@Test @MainActor func viewModelUpdates() async {
    let vm = ViewModel()
    await vm.load()
    #expect(vm.items.count > 0)
    #expect(vm.isLoading == false)
}

Pattern 7: Timeout Control

模式7:超时控制

swift
@Test(.timeLimit(.seconds(5)))
func slowOperation() async throws {
    try await longRunningTask()
}
swift
@Test(.timeLimit(.seconds(5)))
func slowOperation() async throws {
    try await longRunningTask()
}

Pattern 8: Testing Throws

模式8:测试抛出异常

swift
@Test func invalidInputThrows() async throws {
    await #expect(throws: ValidationError.self) {
        try await validate(input: "")
    }
}

// Specific error
@Test func specificError() async throws {
    await #expect(throws: NetworkError.notFound) {
        try await api.fetch(id: -1)
    }
}
swift
@Test func invalidInputThrows() async throws {
    await #expect(throws: ValidationError.self) {
        try await validate(input: "")
    }
}

// 特定异常
@Test func specificError() async throws {
    await #expect(throws: NetworkError.notFound) {
        try await api.fetch(id: -1)
    }
}

Pattern 9: Optional Unwrapping with #require

模式9:使用#require解包可选值

swift
@Test func firstVideo() async throws {
    let videos = try await videoLibrary.videos()
    let first = try #require(videos.first)  // Fails if nil
    #expect(first.duration > 0)
}
swift
@Test func firstVideo() async throws {
    let videos = try await videoLibrary.videos()
    let first = try #require(videos.first)  // 如果为nil则测试失败
    #expect(first.duration > 0)
}

Pattern 10: Parameterized Async Tests

模式10:参数化异步测试

swift
@Test("Video loading", arguments: [
    "Beach.mov",
    "Mountain.mov",
    "City.mov"
])
func loadVideo(fileName: String) async throws {
    let video = try await Video.load(fileName)
    #expect(video.isPlayable)
}
Arguments run in parallel automatically.
swift
@Test("Video loading", arguments: [
    "Beach.mov",
    "Mountain.mov",
    "City.mov"
])
func loadVideo(fileName: String) async throws {
    let video = try await Video.load(fileName)
    #expect(video.isPlayable)
}
参数会自动并行运行。

Parallel Test Execution

并行测试执行

Swift Testing runs tests in parallel by default (unlike XCTest).
Swift Testing默认并行运行测试(与XCTest不同)。

Handling Shared State

处理共享状态

swift
// ❌ Shared mutable state — race condition
var sharedCounter = 0

@Test func test1() async {
    sharedCounter += 1  // Data race!
}

@Test func test2() async {
    sharedCounter += 1  // Data race!
}

// ✅ Each test gets fresh instance
struct CounterTests {
    var counter = Counter()  // Fresh per test

    @Test func increment() {
        counter.increment()
        #expect(counter.value == 1)
    }
}
swift
// ❌ 共享可变状态——竞态条件
var sharedCounter = 0

@Test func test1() async {
    sharedCounter += 1  // 数据竞态!
}

@Test func test2() async {
    sharedCounter += 1  // 数据竞态!
}

// ✅ 每个测试获取全新实例
struct CounterTests {
    var counter = Counter()  // 每个测试实例全新

    @Test func increment() {
        counter.increment()
        #expect(counter.value == 1)
    }
}

Forcing Serial Execution

强制串行执行

When tests must run sequentially:
swift
@Suite("Database tests", .serialized)
struct DatabaseTests {
    @Test func createRecord() async { /* ... */ }
    @Test func readRecord() async { /* ... */ }  // After create
    @Test func deleteRecord() async { /* ... */ }  // After read
}
Note: Other unrelated tests still run in parallel.
当测试必须按顺序运行时:
swift
@Suite("Database tests", .serialized)
struct DatabaseTests {
    @Test func createRecord() async { /* ... */ }
    @Test func readRecord() async { /* ... */ }  // 在create之后运行
    @Test func deleteRecord() async { /* ... */ }  // 在read之后运行
}
注意:其他不相关的测试仍会并行运行。

Common Mistakes

常见错误

Mistake 1: Using sleep Instead of confirmation

错误1:使用sleep而非confirmation

swift
// ❌ Flaky — arbitrary wait time
@Test func eventFires() async {
    setupEventHandler()
    try await Task.sleep(for: .seconds(1))  // Hope it happened?
    #expect(eventReceived)
}

// ✅ Deterministic — waits for actual event
@Test func eventFires() async {
    await confirmation { confirm in
        onEvent = { confirm() }
        triggerEvent()
    }
}
swift
// ❌ 不稳定——等待时间随意
@Test func eventFires() async {
    setupEventHandler()
    try await Task.sleep(for: .seconds(1))  // 寄希望于事件已发生?
    #expect(eventReceived)
}

// ✅ 确定性——等待实际事件触发
@Test func eventFires() async {
    await confirmation { confirm in
        onEvent = { confirm() }
        triggerEvent()
    }
}

Mistake 2: Forgetting @MainActor on UI Tests

错误2:UI测试中遗漏@MainActor

swift
// ❌ Data race — ViewModel may be MainActor
@Test func viewModel() async {
    let vm = ViewModel()
    await vm.load()  // May cause data race warnings
}

// ✅ Explicit isolation
@Test @MainActor func viewModel() async {
    let vm = ViewModel()
    await vm.load()
}
swift
// ❌ 数据竞态——ViewModel可能属于MainActor
@Test func viewModel() async {
    let vm = ViewModel()
    await vm.load()  // 可能引发数据竞态警告
}

// ✅ 显式隔离
@Test @MainActor func viewModel() async {
    let vm = ViewModel()
    await vm.load()
}

Mistake 3: Missing confirmation for Callbacks

错误3:回调测试中遗漏confirmation

swift
// ❌ Test passes immediately — doesn't wait for callback
@Test func callback() async {
    api.fetch { result in
        #expect(result.isSuccess)  // Never executed before test ends
    }
}

// ✅ Waits for callback
@Test func callback() async {
    await confirmation { confirm in
        api.fetch { result in
            #expect(result.isSuccess)
            confirm()
        }
    }
}
swift
// ❌ 测试立即通过——未等待回调
@Test func callback() async {
    api.fetch { result in
        #expect(result.isSuccess)  // 测试结束前从未执行
    }
}

// ✅ 等待回调
@Test func callback() async {
    await confirmation { confirm in
        api.fetch { result in
            #expect(result.isSuccess)
            confirm()
        }
    }
}

Mistake 4: Not Handling Parallel Execution

错误4:未处理并行执行

swift
// ❌ Tests interfere with each other
@Test func writeFile() async {
    try! "data".write(to: sharedFileURL, atomically: true, encoding: .utf8)
}

@Test func readFile() async {
    let data = try! String(contentsOf: sharedFileURL)  // May fail!
}

// ✅ Use unique files or .serialized
@Test func writeAndRead() async {
    let url = FileManager.default.temporaryDirectory
        .appendingPathComponent(UUID().uuidString)
    try! "data".write(to: url, atomically: true, encoding: .utf8)
    let data = try! String(contentsOf: url)
    #expect(data == "data")
}
swift
// ❌ 测试互相干扰
@Test func writeFile() async {
    try! "data".write(to: sharedFileURL, atomically: true, encoding: .utf8)
}

@Test func readFile() async {
    let data = try! String(contentsOf: sharedFileURL)  // 可能失败!
}

// ✅ 使用唯一文件或.serialized
@Test func writeAndRead() async {
    let url = FileManager.default.temporaryDirectory
        .appendingPathComponent(UUID().uuidString)
    try! "data".write(to: url, atomically: true, encoding: .utf8)
    let data = try! String(contentsOf: url)
    #expect(data == "data")
}

Migration from XCTest

从XCTest迁移

XCTestExpectation → confirmation

XCTestExpectation → confirmation

swift
// XCTest
func testFetch() {
    let expectation = expectation(description: "fetch")
    api.fetch { result in
        XCTAssertNotNil(result)
        expectation.fulfill()
    }
    wait(for: [expectation], timeout: 5)
}

// Swift Testing
@Test func fetch() async {
    await confirmation { confirm in
        api.fetch { result in
            #expect(result != nil)
            confirm()
        }
    }
}
swift
// XCTest
func testFetch() {
    let expectation = expectation(description: "fetch")
    api.fetch { result in
        XCTAssertNotNil(result)
        expectation.fulfill()
    }
    wait(for: [expectation], timeout: 5)
}

// Swift Testing
@Test func fetch() async {
    await confirmation { confirm in
        api.fetch { result in
            #expect(result != nil)
            confirm()
        }
    }
}

Async setUp → Suite init

Async setUp → Suite初始化

swift
// XCTest
class MyTests: XCTestCase {
    var service: Service!

    override func setUp() async throws {
        service = try await Service.create()
    }
}

// Swift Testing
struct MyTests {
    let service: Service

    init() async throws {
        service = try await Service.create()
    }

    @Test func example() async {
        // Use self.service
    }
}
swift
// XCTest
class MyTests: XCTestCase {
    var service: Service!

    override func setUp() async throws {
        service = try await Service.create()
    }
}

// Swift Testing
struct MyTests {
    let service: Service

    init() async throws {
        service = try await Service.create()
    }

    @Test func example() async {
        // 使用self.service
    }
}

Resources

资源

WWDC: 2024-10179, 2024-10195
Docs: /testing, /testing/confirmation
Skills: axiom-swift-testing, axiom-ios-testing
WWDC: 2024-10179, 2024-10195
文档: /testing, /testing/confirmation
技能: axiom-swift-testing, axiom-ios-testing