WWDC Quick Look 💓 By SwiftGGTeam
Diagnose unreliable code with test repetitions

Diagnose unreliable code with test repetitions

观看原视频

Highlight

Xcode 13 为 XCTest 加入测试重复执行(Test Repetition),开发者可以用固定次数、直到失败、失败重试三种模式复现不稳定测试,并用 Xcode、测试计划和 xcodebuild 收集诊断数据。

核心内容

测试偶发失败最难处理。

同一个测试,今天在本机通过,明天在 CI 失败。再点一次,又通过。问题可能来自竞态条件、环境假设、全局状态,或者外部服务通信。开发者没有稳定复现路径,只能看一次失败日志,猜下一步该查哪里。

Xcode 13 的测试重复执行解决的是这个入口问题。它让一个测试按指定次数反复运行,并给每次运行加上停止条件。失败不再只出现一次。你可以把它压出来,停在失败现场,再进调试器看对象状态。

Session 的例子是一个 IceCreamTruckCountdown App。测试 testFlavorstruckDepot 获取冰淇淋车,调用 prepareFlavors,最后断言车上有 33 种口味。这个测试在 Xcode Cloud 的主分支上偶发失败。开启失败重试后,报告显示前几次失败,最后一次通过。这说明问题会重复出现,只是概率不高。

开发者接着在本地对同一个测试运行 100 次。测试失败了 4 次。再把停止条件改成直到失败,并开启失败暂停,Xcode 在失败处停住。调试器里看到 flavors 还是 0,原因是 XCTestExpectation 在外层 completion handler 中完成,内层 prepareFlavors 还没结束。

修复方式是把测试改成 Swift 5.5 的 async/await。异步流程变成顺序代码,prepareFlavors 完成后才进入断言。重复运行 100 次后全部通过,开发者再移除测试计划里的失败重试配置。

详细内容

测试重复执行的三种模式

00:56)Xcode 13 的测试重复执行可以设置最大迭代次数和停止条件。Session 介绍了三种模式:固定次数(Fixed iterations)、直到失败(Until failure)、失败重试(Retry on failure)。

# 固定次数:运行指定次数,结果不会提前停止
xcodebuild test -test-iterations 100

# 失败重试:失败后重试,直到成功或达到最大次数
xcodebuild test -test-iterations 10 -retry-tests-on-failure

# 直到失败:持续运行,遇到第一次失败就停止
xcodebuild test -test-iterations 100 -run-tests-until-failure

关键点:

  • xcodebuild test 运行测试动作。
  • -test-iterations 100 指定最多运行 100 次。
  • -retry-tests-on-failure 对失败测试进行重试,用来在 CI 中临时收集不稳定测试的数据。
  • -run-tests-until-failure 遇到失败就停止,适合把非确定性失败带进调试器。

01:10)固定次数适合观察测试套件的可靠性。它会运行完整次数,无论中间是否失败。团队可以用它检查新测试加入后,套件是否开始出现偶发失败。

xcodebuild test -test-iterations 100

关键点:

  • 这个命令只设置迭代次数。
  • 没有额外停止条件,所以每次迭代都会执行。
  • 输出结果可以用来统计失败率,例如 100 次中失败 4 次。

01:28)直到失败适合本地复现。它会持续运行测试,直到第一次失败。配合 Xcode 的 Pause on Failure,可以在失败现场停下。

xcodebuild test -test-iterations 100 -run-tests-until-failure

关键点:

  • -test-iterations 100 给重复执行设置上限。
  • -run-tests-until-failure 把停止条件改为失败即停。
  • 本地调试时,开发者可以先用这个模式把偶发失败转成可检查的失败现场。

01:41)失败重试适合临时诊断 CI。它会在测试失败后重试,直到测试成功或达到最大次数。Session 明确提醒,重试失败可能隐藏产品中的真实问题,所以应该在诊断完成后移除。

xcodebuild test -test-iterations 10 -retry-tests-on-failure

关键点:

  • -retry-tests-on-failure 只在失败后重试。
  • 最终成功不代表第一次运行没有问题。
  • 这个模式适合收集失败数据,不适合长期掩盖失败。

一个由 completion handler 引起的竞态条件

02:39testFlavors 的原始版本使用 XCTestExpectation 等待异步回调。代码看起来等待了冰淇淋车准备口味,实际等待点放错了位置。

func testFlavors() {
    var truck: IceCreamTruck?

    let flavorsExpectation = XCTestExpectation(description: "Get ice cream truck's flavors")
    truckDepot.iceCreamTruck { newTruck in
        truck = newTruck
        newTruck.prepareFlavors { error in
            XCTAssertNil(error)
        }
        flavorsExpectation.fulfill()
    }

    wait(for: [flavorsExpectation], timeout: 5)
    XCTAssertEqual(truck?.flavors, 33)
}

关键点:

  • var truck: IceCreamTruck? 先把车声明为可选值,因为它会在异步回调里赋值。
  • XCTestExpectation 用来让测试等待异步操作。
  • truckDepot.iceCreamTruck { newTruck in ... } 异步取回冰淇淋车。
  • newTruck.prepareFlavors { error in ... } 继续异步准备口味。
  • flavorsExpectation.fulfill() 放在外层回调里,早于内层 prepareFlavors 完成。
  • wait(for:timeout:) 只等待 expectation 完成,没有等待口味准备完成。
  • XCTAssertEqual(truck?.flavors, 33) 可能在 flavors 仍为 0 时执行。

05:30)Xcode 在失败处暂停后,调试器显示 flavors 是 0。继续检查断点后,发现内层 completion handler 还没执行。根因在等待位置:测试过早结束等待,断言提前运行。

newTruck.prepareFlavors { error in
    XCTAssertNil(error)
}
flavorsExpectation.fulfill()

关键点:

  • prepareFlavors 的 completion handler 代表口味准备完成。
  • fulfill() 在 completion handler 外部执行。
  • 测试认为异步流程完成时,真正的业务操作还在运行。
  • 这类错误在嵌套 completion handler 中很容易被忽略。

用 async/await 重写测试

06:18)XCTest 支持 Swift 5.5 的 async/await。测试方法可以声明为 async throws,然后直接等待异步 API 完成。

func testFlavors() async throws {
    let truck = await truckDepot.iceCreamTruck()
    try await truck.prepareFlavors()
    XCTAssertEqual(truck.flavors, 33)
}

关键点:

  • async throws 让测试方法可以调用异步 API,并把错误交给 XCTest 处理。
  • await truckDepot.iceCreamTruck() 等待冰淇淋车返回。
  • let truck 不再是可选值,因为代码会等到返回后才继续执行。
  • try await truck.prepareFlavors() 等待口味准备完成,并处理可能抛出的错误。
  • XCTAssertEqual(truck.flavors, 33) 在两个异步步骤完成后执行。

06:31)Session 展示了改写过程的第一步:先给测试方法加上 async throws。这一步只改变方法签名,为后续 await 调用打开入口。

func testFlavors() async throws {
    var truck: IceCreamTruck?

    let flavorsExpectation = XCTestExpectation(description: "Get ice cream truck's flavors")
    truckDepot.iceCreamTruck { newTruck in
        truck = newTruck
        newTruck.prepareFlavors { error in
            XCTAssertNil(error)
        }
        flavorsExpectation.fulfill()
    }

    wait(for: [flavorsExpectation], timeout: 5)
    XCTAssertEqual(truck?.flavors, 33)
}

关键点:

  • async 允许测试体内使用 await
  • throws 允许测试体内使用 try await
  • 此时测试逻辑还没修好,只是为改写异步调用做准备。
  • 后续步骤会移除 expectation 和 completion handler。

06:40)接着用异步版本的 iceCreamTruck,再用异步版本的 prepareFlavors。最终代码保留原来的断言目标,但去掉了手动等待对象。

func testFlavors() async throws {
    let truck = await truckDepot.iceCreamTruck()
    try await truck.prepareFlavors()
    XCTAssertEqual(truck.flavors, 33)
}

关键点:

  • 第一行函数签名说明这是异步测试。
  • 第二行等待 truckDepot 返回 IceCreamTruck
  • 第三行等待车完成口味准备。
  • 第四行检查口味数量是否等于 33。
  • 代码顺序和业务顺序一致,错误的 fulfill() 位置消失了。

在 Xcode 和 Xcode Cloud 中看迭代结果

03:00)Session 先在测试计划中临时启用 Retry on failure,然后到 Report Navigator 查看 Xcode Cloud 报告。报告中的 iteration label 会标出每次运行的序号。开发者展开记录后,看到断言失败相同,最后一次通过。

xcodebuild test -test-iterations 10 -retry-tests-on-failure

关键点:

  • 这个命令对应测试计划里的失败重试思路。
  • CI 报告会保留多次迭代的结果。
  • 多次失败信息相同,说明可以沿着同一个断言继续查。
  • 最后一次通过只说明重试成功,不能说明问题已经消失。

04:03)在本地,开发者可以 Control-click 测试钻石图标,选择 Run "testFlavors()" Repeatedly…。对话框里可以设置停止条件、最大重复次数,以及 Pause on Failure。

xcodebuild test -test-iterations 100 -run-tests-until-failure

关键点:

  • Xcode 的图形界面适合临时复现单个测试。
  • xcodebuild 适合在脚本和 CI 中覆盖测试计划设置。
  • Session 的例子把最大次数设为 100,本地复现出 4 次失败。
  • 第二次运行选择失败即停后,Xcode 自动勾选 Pause on Failure,便于进入调试器。

核心启发

  1. 做什么:给 CI 增加一个“重复执行单个失败测试”的诊断任务。 为什么值得做:Session 展示了用失败重试收集 Xcode Cloud 中的不稳定测试数据。 怎么开始:在 CI 脚本里对目标测试追加 -test-iterations,按场景选择 -retry-tests-on-failure-run-tests-until-failure

  2. 做什么:把偶发失败测试的失败率记录成趋势。 为什么值得做:固定次数模式可以衡量测试套件可靠性,新增测试后更容易发现稳定性下降。 怎么开始:定期运行 xcodebuild test -test-iterations 100,记录通过次数、失败次数和失败断言。

  3. 做什么:审计项目中嵌套 completion handler 的 XCTest。 为什么值得做:Session 的 bug 来自 expectation 在错误的 completion handler 中完成。 怎么开始:搜索 XCTestExpectationwait(for:timeout:) 和多层回调,确认 fulfill() 是否放在真正完成业务操作的位置。

  4. 做什么:把异步 XCTest 迁移到 async/await。 为什么值得做:XCTest 支持 async throws 测试方法,异步步骤可以按业务顺序写出来。 怎么开始:先把测试方法改成 async throws,再把可用的异步 API 替换为 awaittry await 调用。

  5. 做什么:为本地调试建立“失败即停”习惯。 为什么值得做:直到失败模式可以把低概率失败带进调试器,减少只看日志的猜测。 怎么开始:在 Xcode 中 Control-click 测试钻石图标,选择 Run Repeatedly…,把停止条件设为 Until failure,并启用 Pause on Failure。

关联 Session

评论

GitHub Issues · utterances