ENGINEERING NOTE

云端 Mac 上拆分 Xcode 测试分片:复用构建产物并隔离模拟器

云端 Mac 上拆分 Xcode 测试分片:复用构建产物并隔离模拟器

一套 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/ClassTarget/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,一次只增加一个分片槽位。

观察时至少记录三类信号:

分片不应只追求数量相等。一个包含大量应用重启的 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、模拟器运行时和目录结构一致,再做专门的可移植性验证。

VMDebug 云端 Mac

需要一台由单一订单独享的物理 Mac mini?

核对 M4 与 M4 Pro 两档配置、四种租用周期和 5 个可选节点,再按工作流选择设备。

选择配置并订购