异步测试 (AsyncTesting)

CI Nightly

Xcode 包含 XCTest,它只提供 waitForExpectations(timeout:handler:) 来支持异步测试。它不支持等待给定的期望列表,这对于更复杂的异步活动是必需的。 最初的 wait(for:timeout:enforceOrder:) 函数确实接受期望列表,但与现代 Swift 并发不兼容。

此包扩展了 XCTestCase,使其包含如下所示的 asyncExpectationwaitForExpectations 函数,这些函数模仿现有函数来创建和等待期望。 有关更多参考代码,请参见单元测试。

let done = asyncExpectation(description: "done")
Task {
    try await Task.sleep(seconds: 0.1)
    await done.fulfill()
}
try await waitForExpectations([done])

或者,可以使用 AsyncTesting 类型通过静态函数访问相同的行为,这可以清楚地区分这些函数与原生 XCTest 函数。

测试异步行为

使用单元测试覆盖异步行为必须允许测试断言是确定性的,并且不依赖于可能变化并导致测试不稳定的计时。 测试任务取消可能会导致任务不返回,从而挂起所有单元测试。 通过有效地使用具有等待功能的期望,可以使测试具有确定性,并在出现问题时快速失败。 运行单元测试应尽可能快,因此不必使用更长的睡眠时间来填充不稳定的测试。 将期望和等待调用放置在关键点,以便在适当时进行测试断言并立即继续。

防止异步工作永不返回的方法是使用具有短超时时间的期望和等待。 如果异步函数未能返回,它将允许等待在超时时过期并使测试失败。 将异步工作放置在 Task 中,以便测试在等待函数上暂停。 当等待过期时,如果异步工作未按预期完成,则测试将以失败告终。

通常,在不同的上下文中需要多个期望,这对于当前 XCTest 提供的功能不起作用。 多个期望可能会导致使用 iOS 模拟器运行的测试死锁。 async 版本的 wait 函数也不支持期望的最基本用法之外的功能。 创建此包是为了克服 XCTest 的局限性。 以下是一些如何使用此包的示例。

对于典型情况,以下测试将覆盖异步函数。

func testDoneExpectation() async throws {
    let delay = 0.01
    let done = asyncExpectation(description: "done")
    Task {
        try await Task.sleep(seconds: delay)
        await done.fulfill()
    }
    await waitForExpectations([done])
}

睡眠函数应在默认的 1.0 秒超时之前快速完成。 在测试取消时,应使用反向期望。

func testNotDoneInvertedExpectation() async throws {
    let delay = 0.01
    let notDone = asyncExpectation(description: "not done", isInverted: true)
    let task = Task {
        try await Task.sleep(seconds: delay)
        await notDone.fulfill()
    }
    // cancel immediately to prevent fulfill from being run
    task.cancel()
    await waitForExpectations([notDone], timeout: delay * 2)
}

取消父任务将导致睡眠任务收到 CancellationError 并离开该块,而不会满足期望。 等待在超时时过期,并且由于期望是反向的,因此不会使测试失败。 如果期望在等待过期之前得到满足,则测试将会失败。 注意:反向期望可以被满足,但必须在等待调用的超时之后发生。

func testNotYetDoneAndThenDoneExpectation() async throws {
    let delay = 0.01
    let notYetDone = asyncExpectation(description: "not yet done", isInverted: true)
    let done = asyncExpectation(description: "done")
    
    let task = Task {
        await AsyncRunner().run() // sleeps for 2 seconds
        XCTAssertTrue(Task.isCancelled)
        await notYetDone.fulfill() // will timeout before being called
        await done.fulfill() // will be called after cancellation
    }
    
    await waitForExpectations([notYetDone], timeout: delay)
    task.cancel()
    await waitForExpectations([done])
}

上面的测试还取消了父任务,这导致 run 函数被取消,但它没有抛出异常。 测试断言确认 Task 已取消,然后可以满足反向期望。 对 wait 的第一次调用是针对 notYetDone 的,并且具有足够的短超时时间以进行立即取消。 然后可以满足 done 期望,并允许第二次 wait 调用立即完成。