同一批 UI 测试在本地通过,换到远程节点后却随机超时,常见原因不是业务代码,而是模拟器残留状态、分片边界变化或并发过高。可靠的测试矩阵应让每次运行都能回答三个问题:用了哪个运行时、哪些用例被分到一起、失败现场保存在哪里。下面用 xcodebuild 与 simctl 建立一套可重复执行的流程。
先定义矩阵边界
不要一开始就遍历所有设备与系统版本。先选择一个主运行时和一个代表性设备,跑通稳定基线,再增加兼容性组合。运行前记录 Xcode 版本、macOS 版本、运行时标识、提交号和测试清单。设备名称不适合作为唯一键,因为同名模拟器可以同时存在,应保存 UDID。
| 维度 | 基线选择 | 扩展条件 |
|---|---|---|
| 系统运行时 | 当前项目支持的主版本 | 发布前覆盖最低支持版本 |
| 设备类型 | 一种常用屏幕尺寸 | 涉及布局适配时增加尺寸 |
| 测试分片 | 按测试类固定分组 | 单组耗时过长时再拆分 |
| 并发数 | 从两个分片开始 | 资源曲线稳定后逐步增加 |
测试矩阵的目标不是堆出最多组合,而是让同一提交在相同输入下得到可解释的结果。
创建一次性模拟器基线
先查询节点上真实存在的运行时与设备类型,不要把版本号写死在脚本里。
xcrun simctl list runtimes
xcrun simctl list devicetypes
xcrun simctl list devices available
从输出中选出对应标识,再为每个分片创建独立设备。名称只用于阅读,后续命令全部使用 UDID。
RUNTIME_ID="com.apple.CoreSimulator.SimRuntime.iOS-XX-X"
DEVICE_TYPE_ID="com.apple.CoreSimulator.SimDeviceType.iPhone-XX"
UDID=$(xcrun simctl create "ui-shard-01" "$DEVICE_TYPE_ID" "$RUNTIME_ID")
xcrun simctl boot "$UDID"
xcrun simctl bootstatus "$UDID" -b
不要复用开发人员日常调试的模拟器。测试可能改变权限、地区、键盘、应用数据和后台任务。一次性设备能把这些状态限制在单次运行内。若用例依赖相机、通知或定位权限,应由测试准备阶段显式设置,不能假设上次授权仍然存在。
编译一次,再按清单分片
矩阵不应让每个分片重复编译。先执行 build-for-testing,生成统一的测试产物,再让各分片调用 test-without-building。
DERIVED_PATH="$PWD/.derived-test"
xcodebuild build-for-testing \
-workspace App.xcworkspace \
-scheme AppUITests \
-destination "generic/platform=iOS Simulator" \
-derivedDataPath "$DERIVED_PATH"
XCTESTRUN=$(find "$DERIVED_PATH" -name "*.xctestrun" -print -quit)
分片清单应提交到仓库,避免按字母或运行时长临时切分导致每次边界不同。例如登录与退出放在同一组,离线与同步放在另一组。执行时关闭 Xcode 自带的二次并行,让并发只由外层调度器控制。
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-destination "platform=iOS Simulator,id=$UDID" \
-only-testing:"AppUITests/AuthenticationTests" \
-parallel-testing-enabled NO \
-resultBundlePath "$PWD/results/shard-01.xcresult"
如果分片间耗时差异很大,应根据最近多次运行的中位耗时调整清单,但调整后要固定版本并记录变更。不要让调度脚本在每次执行前自动重排,否则失败无法与上一轮直接比较。
控制并发并可靠回收
模拟器并发不是越多越快。每增加一个分片,都会增加启动进程、图形服务、测试进程和应用副本。先用两个分片观察内存压力、CPU 饱和度、启动耗时和总时长;出现内存交换、bootstatus 超时或用例耗时大幅波动时,立即降低并发。
脚本应在成功、失败和中断时都执行回收。可以为已创建的 UDID 注册退出处理,先关机再删除,避免下一轮误用旧设备。
cleanup() {
xcrun simctl shutdown "$UDID" >/dev/null 2>&1 || true
xcrun simctl delete "$UDID" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
不要用全局删除命令清空节点上的全部模拟器,因为同一节点可能还有其他任务。只处理本次运行创建并记录的 UDID。若测试需要保留失败现场,可先归档日志与结果包,再执行删除。
用结果包区分真实失败与偶发失败
每个分片必须拥有独立的 .xcresult 路径,并同时保存标准输出、开始时间、结束时间、退出码和 UDID。路径重复会覆盖最有价值的首次失败现场。失败后只重跑对应分片一次,不要立即重跑整个矩阵。
首次失败、再次通过通常意味着等待条件、动画、网络依赖或状态竞争不稳定。两次都在同一步失败,更可能是确定性回归。两次失败位置不同,则应优先检查节点资源和测试间共享状态。
上线前可按以下顺序检查:
- 运行时与设备类型均来自当前节点查询结果。
- 每个分片使用独立 UDID 和独立结果目录。
- 编译产物只生成一次,分片不重复构建。
- 测试清单有版本记录,不在运行前随机重排。
- 首次失败结果不会被重跑覆盖。
- 中断后只删除本次创建的模拟器。
常见问题
一台云端 Mac 应同时运行多少个 iOS 模拟器?
先从两个并发分片开始,再根据内存压力、CPU 占用和测试时长逐步增加。出现内存交换、启动超时或用例耗时明显波动时,应降低并发数。
为什么每个分片都要使用独立模拟器?
独立模拟器可以隔离应用数据、权限状态、剪贴板和后台进程,避免不同测试分片互相污染,也便于按分片删除并重建环境。
失败后是否应该直接重跑全部测试?
不建议。应先保存首次失败的 xcresult,再只重跑失败分片一次;若重跑通过,应将用例标记为疑似不稳定并保留两次结果进行对比。
ArmMacs Cloud Mac
按项目周期使用独享物理节点
选择固定芯片、内存、存储、租期与在售节点,库存可用时进入交付流程。