同一组 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.xcresult 与 attempt-2.xcresult。两次都失败,优先按稳定缺陷处理;第一次失败、第二次通过,则标记为不稳定用例,继续调查等待条件、共享状态和资源竞争。
不要只保留最终退出码
每次失败至少收集以下内容:
- 独立的结果包与测试日志;
- 失败步骤的截图和界面层级;
- Xcode 版本、目标模拟器与启动参数;
- 本次运行标识、用例名称和起止时间;
- 应用退出状态及相关系统日志片段。
结果包路径必须包含运行标识,避免并发任务覆盖。日志中的令牌、凭据和业务数据应在上传前脱敏。
从单用例复现到稳定验收
先用 -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
比较三档固定配置与四个亚洲节点,按实际工作负载选择租用周期。每笔租用对应一台独享物理机,不是虚拟机。