Diagnose unreliable code with test repetitions
Developer Tools 进阶 11m

使用测试重复执行诊断不可靠代码

Diagnose unreliable code with test repetitions

2021年6月9日

在 Apple 官方观看视频

一句话判断

Flaky test(不稳定测试)是 CI 的大敌——Xcode 13 的测试重复执行功能让你终于有了系统性的排查手段,而不是靠运气反复跑。

这场 Session 讲了什么

Xcode 13 引入了测试重复执行(Test Repetition)功能,帮助你发现和诊断不稳定的测试用例。所谓不稳定的测试,是指同一个测试有时通过有时失败——不是代码有 bug,而是测试本身有隐患(竞态条件、依赖外部状态、时间敏感等)。

你可以在 Xcode 的 Test Plan 中配置重复执行策略:重复次数(比如跑 100 次)、失败时的行为(遇到失败立刻停止还是继续跑完)、以及重复模式(直到失败、直到成功、固定次数)。配置完成后,Xcode 会自动反复执行选定的测试,并在 Report Navigator 中汇总结果——你可以看到每次执行的耗时、通过/失败状态,以及失败时的具体错误信息。

Session 还展示了命令行工具的使用方式。在 CI 环境中,你可以通过 xcodebuild-repeat-count-retry-count-on-failure 参数实现同样的功能,这对于在 Xcode Cloud 或其他 CI 系统上自动检测 flaky test 非常有用。

值得深挖的点

「直到失败」模式的实用价值。 三种重复模式中,「直到失败」(repeat until fail)是最常用的调试模式。它的工作方式是持续重复执行测试,直到出现一次失败为止。对于一个「大概每 50 次出现一次失败」的 flaky test,你可能需要设置最大重复 100 次来捕获问题。当你怀疑某个测试有竞态条件时,这种模式比手动反复点 Run 高效得多。但要注意:如果你的测试确实有 bug(不是 flaky 而是每次都失败),这个模式会在第一次执行就停下来——所以它只适合排查「有时通过有时失败」的情况。

xcodebuild-retry-count-on-failure 在 CI 中的应用。 这个参数让 xcodebuild 在测试失败时自动重试。但它的设计意图不是「忽略 flaky test」,而是「区分真正的失败和偶发的失败」。Session 建议:在 CI 的主分支构建中设置 retry-count-on-failure: 0(不重试,任何失败都标红),在开发分支或 nightly build 中设置 retry-count-on-failure: 2(给 flaky test 一些宽容度,同时收集重试数据)。通过对比两次执行的日志,你可以定位 flaky 的根源。

代码片段

# 在命令行中运行测试重复执行
# 重复执行某个测试 50 次
xcodebuild test \
    -project MyApp.xcodeproj \
    -scheme MyApp \
    -destination 'platform=iOS Simulator,name=iPhone 13' \
    -only-testing:MyAppTests/MyFlakyTestClass/testSomething \
    -repeat-count 50

# 重复执行直到测试失败
xcodebuild test \
    -project MyApp.xcodeproj \
    -scheme MyApp \
    -repeat-count 100 \
    -repeat-until-fail

# 测试失败时自动重试(用于 CI)
xcodebuild test \
    -project MyApp.xcodeproj \
    -scheme MyApp \
    -retry-count-on-failure 2 \
    -test-iterations 3
// 在 Xcode Test Plan 中配置重复执行
// 1. 编辑 Test Plan(Product > Scheme > Edit Scheme > Test > Test Plans)
// 2. 选择 "Configure Test Repetition"
// 3. 设置:
//    - Repetition Mode: 
//      * Fixed Count (固定次数)
//      * Repeat Until Failure (直到失败)
//      * Repeat Until Pass (直到成功)
//    - Maximum Repetitions: 100
//    - Stop on First Failure: Yes/No

// 示例:一个可能不稳定的时间敏感测试
func testDebounce() async throws {
    let expectation = XCTestExpectation(expectedFailingCount: 0)
    expectation.expectedFulfillmentCount = 1

    debouncer.debounce(delay: .milliseconds(100)) {
        expectation.fulfill()
    }

    // 这个等待时间如果太紧,在 CI 上可能偶尔超时
    wait(for: [expectation], timeout: 0.15)
}
// 使用 XCTExpectation 处理异步测试的常见 flaky 来源
func testAsyncOperation() async throws {
    // 不好的做法:硬编码等待时间
    // Thread.sleep(forTimeInterval: 2)  // CI 上可能不够

    // 好的做法:用 expectation 并设置合理的 timeout
    let expectation = expectation(description: "操作完成")
    apiClient.fetchData { result in
        switch result {
        case .success:
            expectation.fulfill()
        case .failure(let error):
            XCTFail("请求失败: \(error)")
        }
    }

    // timeout 设置为正常耗时的 3-5 倍,给 CI 环境留余地
    waitForExpectations(timeout: 10)
}

最佳实践

  • 发现 flaky test 后,先用「直到失败」模式确认复现率,再针对性修复。不要用 retry 来掩盖问题
  • CI 环境的测试超时应该比本地开发环境长 2-3 倍——CI 机器的性能通常不如你的开发机
  • 排查 flaky test 时关注几个常见原因:时间依赖、文件系统状态、网络请求、随机数生成
  • 把不稳定的测试标记为 XCTExpectation(expectedFailingCount:) 而不是注释掉——这样你还能追踪它的状态变化

还有什么值得关注

  • Xcode 13 的测试报告新增了「Test Duration」列,帮你快速识别突然变慢的测试
  • Swift Concurrency 的 async/await 测试在 Xcode 13 中有原生支持,减少了异步测试的样板代码
  • xcodebuild 新增了 -resultBundlePath 参数用于导出测试结果的机器可读报告
WWDC 2021