一条原本只需 4 秒的 XCTest 逐渐变成 11 秒,流水线仍然全绿,团队通常要到任务排队明显加重时才发现。总构建时间不适合定位这种变化:依赖解析、编译、模拟器启动和结果写入都混在其中。更可靠的做法,是在 NUMACS 云端 Mac 的每次测试任务中保留 .xcresult,提取用例级耗时,再与受控基线比较。
先定义门禁测量什么
门禁应回答“同一测试是否持续变慢”,而不是“这次任务是否比上次多用几秒”。先把指标分成三层:
| 层级 | 指标 | 用途 |
|---|---|---|
| 测试用例 | 单次执行耗时 | 定位具体回归 |
| 测试套件 | 中位数与高分位数 | 观察一组测试的整体漂移 |
| CI 任务 | 从启动到退出的总时长 | 发现基础设施或编译阶段异常 |
不要用一次结果直接阻断合并。短测试容易受调度抖动影响,长测试则可能被网络、动画或异步等待放大。实用判定是“双阈值”:候选值同时超过基线的相对增幅和绝对增量,才进入复测。例如基线为 2 秒,增加 20% 但只多 0.1 秒不应失败;基线为 40 秒,增加 8 秒则值得检查。
性能门禁的目标不是追求每次数字相同,而是尽早识别可重复、可归因的变慢。
固定执行环境并生成结果包
先固定 Xcode 路径、Scheme、运行目标和并行策略。模拟器型号与系统版本必须明确,不能依赖“当前已启动设备”。同一任务中也不要动态切换并发数量。
set -euo pipefail
RESULT_DIR="$PWD/artifacts"
RESULT_BUNDLE="$RESULT_DIR/RegressionTests.xcresult"
mkdir -p "$RESULT_DIR"
rm -rf "$RESULT_BUNDLE"
xcodebuild test \
-workspace Example.xcworkspace \
-scheme ExampleTests \
-destination 'platform=iOS Simulator,name=iPhone 16,OS=18.0' \
-resultBundlePath "$RESULT_BUNDLE" \
-parallel-testing-enabled NO
示例版本只是执行环境的一部分,实际项目应锁定已验收的 Xcode 与模拟器运行时。先运行一次不计入统计的预热任务,让模拟器完成启动,并让必要的测试资源落盘。正式样本至少执行三次;若测试本身方差较大,应增加次数,而不是放宽到失去意义的阈值。
从 xcresult 提取用例耗时
较新的 Xcode 可通过 xcresulttool 输出测试树。命令接口会随 Xcode 版本演进,因此解析器必须与 CI 使用的 Xcode 版本一起锁定,并在升级前用保存的结果包做兼容性测试。
xcrun xcresulttool get test-results tests \
--path artifacts/RegressionTests.xcresult \
--format json > artifacts/tests.json
jq -r '
.. | objects
| select(.nodeType? == "Test Case" and .duration? != null)
| [.name, .duration] | @tsv
' artifacts/tests.json > artifacts/test-durations.tsv
先检查 TSV 是否为空、测试数量是否符合预期,再进入比较阶段。空文件不能当作“没有回归”放行,它通常意味着命令接口变化、测试未执行,或解析条件不再匹配。测试标识应包含模块、类和方法;参数化测试还要保留参数名称,避免不同样本被错误合并。
归一化时不要丢掉原始证据
将时长统一转换为秒,并为每条记录附上 commit、Xcode 版本、运行目标和任务编号。聚合文件方便比较,但原始 .xcresult 仍要作为任务产物保留。出现异常时,它还能提供失败信息、活动记录和附件上下文。
用稳健基线替代上一次结果
基线不应等于主分支最近一次运行。单次慢启动会污染后续判断,单次异常快也会制造大量误报。更稳妥的办法是收集主分支最近若干次成功样本,对每个测试取中位数,并记录高分位数或中位绝对偏差。
建议为每个测试保存这些字段:
{
"ExampleTests.testParsing": {
"median_seconds": 3.84,
"absolute_limit_seconds": 1.5,
"relative_limit": 0.25,
"sample_count": 9
}
}
候选分支第一次越界后,只复测越界用例或所属套件。若复测中位数仍同时超过 median_seconds + absolute_limit_seconds 和 median_seconds × (1 + relative_limit),再将任务标记为失败。新增测试先进入观察期,样本不足时只报告,不参与阻断。
排除最常见的假回归
模拟器冷启动是首要噪声源。测试数据初始化、首次字体加载、数据库建表也会让第一轮偏慢,应通过预热或明确的 setUp 拆分。其次检查测试是否访问网络、等待真实时间、共享用户默认值或复用前一用例留下的文件。
并行测试可能改变 CPU、内存和磁盘竞争。若目标是建立单用例基线,应关闭并行;若目标是评估真实流水线吞吐,则固定 worker 数,并把该数量写进基线维度。不同 Xcode、系统运行时或硬件配置的数据不能直接混合。
遇到突然变慢时,按顺序检查:
- 测试数量和执行目标是否变化;
- 是否发生编译、安装或模拟器启动等待;
- 超时是否来自轮询和固定睡眠;
- 测试夹具是否扩大或未在结束后清理;
- 多个任务是否同时争用同一工作目录。
让基线变更可以审查
基线文件应进入版本管理,但普通测试任务只能读取,不能自动覆盖。确有合理变慢时,由独立任务基于主分支稳定样本生成差异,评审者需要看到旧值、新值、样本数和变更原因。
最终报告按“新增回归、已恢复、观察中”分组,并列出绝对增量、相对增幅和复测结果。这样门禁既能阻止隐藏的性能退化,也不会因为一次模拟器抖动拖住开发流程。完成后,团队得到的不是一张易碎的秒表,而是一套能复现、能解释、能审查的 XCTest 耗时基线。
常见问题
为什么不能直接用整次 xcodebuild 的总耗时做门禁?
总耗时混入了依赖解析、编译、模拟器启动和结果写入,无法定位具体测试。应从 xcresult 提取单个测试用例耗时,再单独监控任务总时长。
XCTest 耗时阈值应该设成固定秒数还是百分比?
建议同时使用绝对增量和相对增幅。只有两者都越界,并在重复执行后仍成立时才判定回归,可避免短测试被微小抖动误伤。
更新性能基线时需要保留什么证据?
应保留基线文件、对应提交、Xcode 与运行目标版本、原始 xcresult,以及更新原因。不要让普通测试任务自动覆盖主分支基线。
NUMACS 云端 Mac
把构建任务放到独享物理工作站
两档 Apple Silicon 配置均为独享物理机、非虚拟机,可按天、周、月或季度租用,并覆盖新加坡、日本东京、韩国首尔与香港节点。