工程实践

云端 Mac 复现随机测试失败:固定种子与执行顺序

云端 Mac 复现随机测试失败:固定种子与执行顺序

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 协议注入可播种实现,测试可以重复生成相同数据,同时让生产代码继续使用系统随机源。

ArmCLI 云端 Mac

为下一次构建准备独享物理 Mac

按任务选择机型、机房与租期。节点全年正常运行,实际可用状态以控制台实时返回为准。