一套 iOS 工程有几千个单元测试和 UI 测试时,直接执行一次 xcodebuild test 往往会同时承担编译、启动模拟器、安装应用和运行全部用例。失败发生在最后几分钟,重跑却又从编译开始。云端 Mac 更适合把这条链路拆成两个阶段:先生成一次可测试构建,再让多个边界清晰的测试分片复用它。
先把编译和执行分开
Xcode 提供的 build-for-testing 会构建应用、测试宿主和测试包,并生成描述执行环境的 .xctestrun 文件。后续使用 test-without-building,可以避免每个分片重复解析依赖和编译源码。
先固定工作区、Scheme、目标设备和 DerivedData 路径:
set -euo pipefail
DERIVED_DATA="$PWD/.ci/DerivedData"
RESULTS="$PWD/.ci/Results"
DESTINATION="platform=iOS Simulator,name=iPhone 16,OS=latest"
rm -rf "$DERIVED_DATA" "$RESULTS"
mkdir -p "$RESULTS"
xcodebuild build-for-testing \
-workspace App.xcworkspace \
-scheme App \
-configuration Debug \
-destination "$DESTINATION" \
-derivedDataPath "$DERIVED_DATA" \
CODE_SIGNING_ALLOWED=NO
find "$DERIVED_DATA/Build/Products" -name "*.xctestrun" -print
CODE_SIGNING_ALLOWED=NO 只适用于不依赖真机签名的模拟器测试。若测试目标包含必须签名的组件,应删除该参数,并让项目自己的签名配置生效。构建成功后不要移动 DerivedData;.xctestrun 可能引用其中的绝对路径。
分片解决的是测试调度问题,不会修复测试之间的共享状态。一个用例若依赖另一个用例先执行,即使单机串行时看似稳定,拆分后也会暴露真实缺陷。
用测试清单定义稳定分片
不要按“每组大约一百个方法”手工切分。方法名会频繁变化,维护成本很高。更稳妥的做法是按测试 Target、测试类或业务域划分,并把清单纳入仓库。
| 分片 | 建议范围 | 适合放入的测试 |
|---|---|---|
| unit-core | 纯逻辑层 | 数据转换、校验、状态机 |
| unit-storage | 持久化层 | 数据库、缓存、迁移 |
| ui-account | 账户流程 | 登录、设置、权限页面 |
| ui-checkout | 交易流程 | 商品选择、确认与异常路径 |
清单可以保持为普通文本:
AppTests/ParserTests
AppTests/SessionReducerTests
AppTests/ValidationTests
执行时把每一行转换为一个 -only-testing: 参数。测试标识通常采用 Target/Class 或 Target/Class/testMethod。先运行 xcodebuild -list 核对 Scheme,再用一次小范围命令验证标识,避免分片因拼写错误实际执行零个测试。
单独执行一个分片
XCTESTRUN="$(find "$DERIVED_DATA/Build/Products" -name '*.xctestrun' -print -quit)"
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-destination "$DESTINATION" \
-parallel-testing-enabled NO \
-only-testing:AppTests/ParserTests \
-only-testing:AppTests/SessionReducerTests \
-resultBundlePath "$RESULTS/unit-core.xcresult"
每个分片必须使用不同的 resultBundlePath。复用同一路径会覆盖证据,也可能让并发进程争用同一个目录。
给每个并发任务独立模拟器
两个 xcodebuild 进程若同时命中同一台模拟器,安装、启动、权限状态和剪贴板数据都可能互相影响。应为每个并发槽位准备专用设备,并通过 UDID 指定目标。
先查看可用运行时:
xcrun simctl list runtimes
xcrun simctl list devicetypes
取得当前环境中的运行时标识后创建专用设备:
xcrun simctl create \
ci-shard-1 \
com.apple.CoreSimulator.SimDeviceType.iPhone-16 \
"$RUNTIME_ID"
把返回的 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
随后把 destination 改为 platform=iOS Simulator,id=$SIMULATOR_UDID。对于需要预置语言、权限或测试数据的任务,应在 erase 之后统一注入,不要依赖上一次执行遗留的状态。
并发数量要由资源压力决定
显式启动两个分片后,又让每个分片启用 Xcode 内建并行,会形成嵌套并发。模拟器数量、测试进程和编译辅助进程会一起增长,最终可能比串行更慢。第一版应设置 -parallel-testing-enabled NO,一次只增加一个分片槽位。
观察时至少记录三类信号:
memory_pressure是否持续进入高压力状态;vm_stat中压缩与换页是否快速增长;- 每个分片的用例数、执行时间和失败位置是否稳定。
分片不应只追求数量相等。一个包含大量应用重启的 UI 测试类,可能比数百个纯函数测试更慢。可以根据最近若干次流水线的持续时间调整清单,但不要在运行时随机分配,否则失败重现和趋势对比会变得困难。
让失败可以单独重跑和取证
每个分片都应保存退出码、控制台日志和独立的 .xcresult。流水线汇总阶段可以失败,但不应因为第一个分片失败就删除其他分片的证据。对失败组重跑时,继续使用原始 .xctestrun,同时换一个结果路径:
xcodebuild test-without-building \
-xctestrun "$XCTESTRUN" \
-destination "platform=iOS Simulator,id=$SIMULATOR_UDID" \
-parallel-testing-enabled NO \
-only-testing:AppUITests/CheckoutTests \
-resultBundlePath "$RESULTS/ui-checkout-retry-1.xcresult"
若源码、编译参数、Xcode 版本或模拟器运行时发生变化,就应重新执行 build-for-testing,不能把旧产物当作新提交的证据。最终保留测试清单、构建日志、分片结果和环境版本,才能判断失败来自代码、测试状态还是执行环境。
这套结构的关键不是把测试命令复制多份,而是明确三个所有权:构建产物只生成一次,模拟器由单个分片独占,结果包永不互相覆盖。做到这三点后,扩缩分片和定向重跑才不会牺牲可复现性。
常见问题
为什么不直接开启 Xcode 的并行测试?
内建并行适合单个测试目标内部的自动调度;显式分片更便于固定测试范围、单独重跑失败组并分别保存结果。两者不应在资源有限时无上限叠加。
test-without-building 可以把产物复制到另一台 Mac 执行吗?
不建议直接复制。xctestrun 和构建产物可能包含与 DerivedData、工具链及运行时有关的路径,应先保证 Xcode、模拟器运行时和目录结构一致,再做专门的可移植性验证。
需要一台由单一订单独享的物理 Mac mini?
核对 M4 与 M4 Pro 两档配置、四种租用周期和 5 个可选节点,再按工作流选择设备。