工程实践

云端 Mac 上治理 XCUITest 偶发失败:等待、状态隔离与证据留存

云端 Mac 上治理 XCUITest 偶发失败:等待、状态隔离与证据留存

同一组 XCUITest 在本地连续通过,迁到云端 Mac 后却偶尔找不到按钮、等待超时或停在启动页,通常不是“机器慢”这么简单。更常见的原因是运行目标漂移、前一次用例留下状态、固定时间等待,以及失败后没有保存足够证据。治理顺序应当是先固定输入,再消除隐式依赖,最后才讨论重跑。

先固定每次运行的输入

先把 Xcode、测试计划、模拟器目标和构建目录写进命令,不要依赖图形界面上一次留下的选择。云端 Mac 可长期承载任务,同一工作目录也可能被不同流水线使用,因此每次执行都应拥有独立的 DerivedData 和结果包。

RUN_ID="${CI_RUN_ID:-local-$(date +%s)}"
ARTIFACTS="$PWD/artifacts/$RUN_ID"
DERIVED="$PWD/.derived/$RUN_ID"

mkdir -p "$ARTIFACTS" "$DERIVED"

xcodebuild test \
  -workspace Sample.xcworkspace \
  -scheme SampleUITests \
  -testPlan Smoke \
  -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
  -derivedDataPath "$DERIVED" \
  -resultBundlePath "$ARTIFACTS/Smoke.xcresult" \
  -parallel-testing-enabled NO

初次治理不稳定用例时先关闭并行测试,建立串行基线。基线稳定后,再逐组开启并行;否则多个模拟器、测试数据和后台任务同时变化,很难确认故障来自用例还是调度。

启动前做最小检查

执行前记录 xcodebuild -version,并用 xcrun simctl list devices available 确认目标确实存在。不要只写设备名称而省略系统条件,也不要让不同 Runner 共用同一个 DerivedData 目录。

用可观察状态替代固定等待

Thread.sleep 看似直接,实质上把“页面准备完成”误写成“经过了几秒”。网络响应快时它浪费时间,动画或磁盘操作稍慢时又会提前结束。

let app = XCUIApplication()
app.launchArguments += ["--ui-testing", "--reset-state"]
app.launch()

let continueButton = app.buttons["onboarding.continue"]
let ready = continueButton.waitForExistence(timeout: 12)

XCTAssertTrue(ready, "Continue button did not appear")
XCTAssertTrue(continueButton.isHittable)
continueButton.tap()

元素应使用稳定的 accessibility identifier,而不是依赖本地化文字或视图层级。若按钮已经存在但不可点击,应继续检查遮罩、动画和滚动位置,不能简单延长超时。

超时值是故障边界,不是性能承诺。它应覆盖正常波动,同时在真正卡住时尽快失败并留下现场。

让每个用例从已知状态开始

偶发失败经常来自前一条用例:欢迎页已经跳过、测试数据仍在、弹窗只展示一次,或者后台进程没有退出。最稳妥的方式是让应用在测试启动参数下执行专用重置逻辑,并为每个测试身份生成唯一数据。

可按下面的边界分层处理:

状态类型 推荐做法 不推荐做法
应用偏好 启动参数触发测试专用重置 假设安装后状态永远为空
本地数据库 导入固定夹具或重建测试库 依赖上一用例写入的数据
服务端数据 使用本次运行唯一命名空间 多个任务共享固定记录
系统权限 在套件前统一准备并验证 在任意用例中临时处理
应用进程 用例结束后明确终止 假设崩溃后会自动恢复

套件级清理可以终止应用;只有确实需要验证首次安装流程时,才执行卸载。频繁抹掉整个模拟器会增加时长,也可能把环境问题隐藏成“清理后通过”。

xcrun simctl terminate booted "$APP_BUNDLE_ID" 2>/dev/null || true

把重跑变成分类工具

自动重跑不能作为通过条件的替代。合理策略是首次失败后仅重跑一次,并分别保存 attempt-1.xcresultattempt-2.xcresult。两次都失败,优先按稳定缺陷处理;第一次失败、第二次通过,则标记为不稳定用例,继续调查等待条件、共享状态和资源竞争。

不要只保留最终退出码

每次失败至少收集以下内容:

  1. 独立的结果包与测试日志;
  2. 失败步骤的截图和界面层级;
  3. Xcode 版本、目标模拟器与启动参数;
  4. 本次运行标识、用例名称和起止时间;
  5. 应用退出状态及相关系统日志片段。

结果包路径必须包含运行标识,避免并发任务覆盖。日志中的令牌、凭据和业务数据应在上传前脱敏。

从单用例复现到稳定验收

先用 -only-testing 连跑目标用例,减少无关变量;随后运行所属测试类,最后再回到完整套件。只在完整套件失败,通常说明存在顺序依赖、共享数据或资源竞争。

xcodebuild test \
  -workspace Sample.xcworkspace \
  -scheme SampleUITests \
  -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
  -only-testing:SampleUITests/CheckoutTests/testSubmit \
  -parallel-testing-enabled NO

验收时不要只看“一次通过”。应连续执行若干轮,确认没有固定顺序依赖,并检查总时长是否因过大的等待上限明显增长。恢复并行测试后,再验证不同分片不会操作同一测试记录或同一输出目录。

最终目标不是把失败率藏在重试后面,而是让每次运行拥有确定输入、明确等待、隔离状态和完整证据。做到这四点,云端 Mac 上的 XCUITest 才能从“偶尔能过”变成可用于合并与发布判断的工程信号。

常见问题

XCUITest 失败后可以直接自动重跑吗?

可以重跑一次用于分类,但不能用无限重试掩盖问题。首次失败、重跑结果和两个结果包都应保留;只有首次失败而重跑通过的用例,应进入不稳定用例清单继续修复。

为什么不建议在 UI 测试里使用固定 sleep?

固定等待既可能在界面尚未就绪时过早结束,也会在界面已完成时浪费时间。应等待可观察状态,例如元素存在、可点击或文本变为预期值,并设置明确超时。

独享物理 Mac mini

为下一条开发流水线选择云端 Mac

比较三档固定配置与四个亚洲节点,按实际工作负载选择租用周期。每笔租用对应一台独享物理机,不是虚拟机。

选择配置并租用