Engineering guide

云端 Mac 上搭建可重复的 iOS 模拟器测试矩阵

云端 Mac 上搭建可重复的 iOS 模拟器测试矩阵

同一批 UI 测试在本地通过,换到远程节点后却随机超时,常见原因不是业务代码,而是模拟器残留状态、分片边界变化或并发过高。可靠的测试矩阵应让每次运行都能回答三个问题:用了哪个运行时、哪些用例被分到一起、失败现场保存在哪里。下面用 xcodebuildsimctl 建立一套可重复执行的流程。

先定义矩阵边界

不要一开始就遍历所有设备与系统版本。先选择一个主运行时和一个代表性设备,跑通稳定基线,再增加兼容性组合。运行前记录 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。路径重复会覆盖最有价值的首次失败现场。失败后只重跑对应分片一次,不要立即重跑整个矩阵。

首次失败、再次通过通常意味着等待条件、动画、网络依赖或状态竞争不稳定。两次都在同一步失败,更可能是确定性回归。两次失败位置不同,则应优先检查节点资源和测试间共享状态。

上线前可按以下顺序检查:

  1. 运行时与设备类型均来自当前节点查询结果。
  2. 每个分片使用独立 UDID 和独立结果目录。
  3. 编译产物只生成一次,分片不重复构建。
  4. 测试清单有版本记录,不在运行前随机重排。
  5. 首次失败结果不会被重跑覆盖。
  6. 中断后只删除本次创建的模拟器。

常见问题

一台云端 Mac 应同时运行多少个 iOS 模拟器?

先从两个并发分片开始,再根据内存压力、CPU 占用和测试时长逐步增加。出现内存交换、启动超时或用例耗时明显波动时,应降低并发数。

为什么每个分片都要使用独立模拟器?

独立模拟器可以隔离应用数据、权限状态、剪贴板和后台进程,避免不同测试分片互相污染,也便于按分片删除并重建环境。

失败后是否应该直接重跑全部测试?

不建议。应先保存首次失败的 xcresult,再只重跑失败分片一次;若重跑通过,应将用例标记为疑似不稳定并保留两次结果进行对比。

ArmMacs Cloud Mac

按项目周期使用独享物理节点

选择固定芯片、内存、存储、租期与在售节点,库存可用时进入交付流程。

选择机型并订购