CI 夜间任务偶尔会报出一个本地无法重现的 XCTest 失败:输入由随机工厂生成,测试采用随机顺序,失败后流水线又自动重试。第二次执行变绿,但第一次失败时究竟用了什么数据、前面跑过哪些用例,已经无法回答。解决这类问题的关键不是增加重试次数,而是把一次运行变成可以完整回放的实验。
先定义可回放契约
随机测试至少受三类变量影响:业务数据的随机值、测试用例的执行顺序,以及模拟器和进程留下的状态。只记录一个 SEED=42 并不够,因为代码提交、测试清单或区域设置变化后,相同种子也可能进入不同路径。
每次任务开始时生成一份运行清单,并让后续步骤只读取这份清单。建议至少保存以下字段:
| 字段 | 示例 | 用途 |
|---|---|---|
| commit | Git 提交哈希 | 固定被测代码 |
| seed | 无符号整数 | 重放随机数据 |
| tests | 有序测试标识列表 | 固定执行顺序 |
| destination | 模拟器 UDID | 固定执行目标 |
| runtime | 系统与 Xcode 版本 | 识别工具链差异 |
| locale | zh_CN 或 en_US |
避免格式化差异 |
| result bundle | .xcresult 路径 |
保存日志与附件 |
“同一个测试又通过了”不是复现结果。只有输入、顺序和环境都相同,才能判断缺陷是否仍然存在。
运行清单应与测试产物一起归档,不能只打印在容易被截断的控制台日志中。
给随机数据提供稳定种子
Swift 的系统随机源适合生产代码,却不能按指定种子回放。可将随机数生成器作为依赖传入数据工厂,测试使用可播种实现,正式代码继续使用系统实现。
struct SplitMix64: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) {
state = seed
}
mutating func next() -> UInt64 {
state &+= 0x9E3779B97F4A7C15
var value = state
value = (value ^ (value >> 30)) &* 0xBF58476D1CE4E5B9
value = (value ^ (value >> 27)) &* 0x94D049BB133111EB
return value ^ (value >> 31)
}
}
func makeQuantity<R: RandomNumberGenerator>(using rng: inout R) -> Int {
Int.random(in: 1...20, using: &rng)
}
测试入口从环境变量读取种子,并在缺失时直接失败,而不是静默退回系统随机源:
let rawSeed = ProcessInfo.processInfo.environment["TEST_SEED"]
let seed = rawSeed.flatMap(UInt64.init)
XCTAssertNotNil(seed, "TEST_SEED must be provided")
同一任务中的所有随机工厂应共享明确的派生规则。例如以主种子加稳定的用例标识计算子种子,避免某个测试多取一次随机数后改变整个测试套件的数据序列。
固定测试清单与执行顺序
测试顺序不应依赖文件系统遍历结果。先维护一份文本清单,每行一个完整标识,例如 AppTests/LoginTests/testExpiredSession。再用种子和标识的摘要进行稳定排序:
import hashlib
import os
seed = os.environ["TEST_SEED"]
with open("ci/tests.txt", encoding="utf-8") as source:
tests = [line.strip() for line in source if line.strip()]
def key(name):
value = f"{seed}:{name}".encode()
return hashlib.sha256(value).hexdigest()
for test in sorted(tests, key=key):
print(test)
这样既能覆盖不同顺序,又能在任意节点重建相同列表。执行前把排序结果保存为 ordered-tests.txt,再逐项组装 xcodebuild 参数:
set -euo pipefail
mkdir -p artifacts
python3 ci/order_tests.py > artifacts/ordered-tests.txt
args=()
while IFS= read -r test_id; do
args+=("-only-testing:${test_id}")
done < artifacts/ordered-tests.txt
xcodebuild test \
-workspace App.xcworkspace \
-scheme App \
-destination "id=${SIMULATOR_UDID}" \
-resultBundlePath "artifacts/test-${TEST_SEED}.xcresult" \
"${args[@]}"
不要同时启用另一套不可记录的随机排序,否则清单顺序与实际执行顺序会再次分离。
隔离模拟器状态而不抹掉证据
回放前应使用专用模拟器,避免人工调试与 CI 共用设备。测试开始前可以关闭并擦除指定设备,但必须确认 UDID 来自本任务,不能对共享设备执行无差别清理。
xcrun simctl shutdown "$SIMULATOR_UDID" 2>/dev/null || true
xcrun simctl erase "$SIMULATOR_UDID"
xcrun simctl boot "$SIMULATOR_UDID"
xcrun simctl bootstatus "$SIMULATOR_UDID" -b
区域、语言、时区和启动参数也应由测试入口显式设置。网络响应、当前时间和 UUID 若会影响断言,应该通过协议注入固定实现,而不是期待模拟器恰好处于相同状态。
如果问题疑似由前序测试污染引起,不要一开始就把每个用例拆进全新模拟器。先按原顺序回放,确认失败;随后用二分法缩短前置清单,找出最小污染链。这样能区分“随机数据触发”与“前序状态泄漏”。
首次失败时保存完整现场
失败任务至少保留 xcresult、有序测试清单、运行清单、标准输出和代码提交。还应记录 xcodebuild -version、sw_vers 与模拟器运行时。敏感环境变量只记录变量名和是否存在,不保存实际值。
自动重试可以作为分类手段,但必须发生在首次现场归档之后。建议把结果分为三类:
同契约稳定失败
相同提交、种子、顺序与模拟器状态下持续失败,优先检查确定性的业务逻辑和断言。
改变顺序后失败
固定数据不变,仅调整前序测试便改变结果,重点检查单例、通知监听、数据库、缓存和未恢复的全局配置。
相同契约仍漂移
继续核对异步任务是否真正结束、系统时间是否被读取、网络响应是否未固定,以及测试是否调用了未注入的随机源。
最终目标不是让红灯通过重试消失,而是让每次失败都附带一条可执行的回放命令。做到这一点后,偶发问题就从“概率事件”变成了可缩减、可比较、可修复的输入集合。
常见问题
只记录随机种子,能否完整复现一次 XCTest 失败?
通常不能。还要固定代码提交、测试清单及顺序、模拟器运行时、区域设置和外部输入;任何一项变化都可能让同一种子走向不同路径。
失败后是否应该立即使用 XCTest 重试参数?
不应把重试当作默认修复。先保存首次失败的 xcresult、种子和顺序,再单独执行同一回放契约;重试通过只能作为偶发性的证据,不能证明问题消失。
为什么要把随机数生成器注入业务代码?
系统随机源无法按指定种子回放。通过 RandomNumberGenerator 协议注入可播种实现,测试可以重复生成相同数据,同时让生产代码继续使用系统随机源。
为下一次构建准备独享物理 Mac
按任务选择机型、机房与租期。节点全年正常运行,实际可用状态以控制台实时返回为准。