在云端 Mac 上用 xccov 建立 iOS 代码覆盖率回归门禁

在云端 Mac 上用 xccov 建立 iOS 代码覆盖率回归门禁

团队把一批单元测试迁到云端 Mac 后,最容易犯的错误是只看“测试是否通过”。某次提交可能删掉关键分支的断言,测试仍然全绿,覆盖率却悄悄下降。更稳妥的做法,是让每次构建生成独立的 xcresult,再用 Xcode 自带的 xccov 提取数据,同时检查项目总覆盖率与本次变更文件。

先固定覆盖率的输入条件

覆盖率只有在测试入口稳定时才值得比较。先固定 Xcode 版本、scheme、测试计划、模拟器型号与系统版本;不要让不同执行器自行选择 destination。测试命令也应显式开启覆盖率,并为每次运行创建新的结果目录。

set -euo pipefail

RESULT_DIR="$PWD/Artifacts/Coverage"
RESULT_BUNDLE="$RESULT_DIR/TestResults.xcresult"

rm -rf "$RESULT_BUNDLE"
mkdir -p "$RESULT_DIR"

xcodebuild test \
  -workspace ExampleApp.xcworkspace \
  -scheme ExampleApp-CI \
  -testPlan UnitTests \
  -destination 'platform=iOS Simulator,name=iPhone 16,OS=latest' \
  -enableCodeCoverage YES \
  -resultBundlePath "$RESULT_BUNDLE"

resultBundlePath 指向的路径在执行前必须不存在,否则旧结果可能阻止写入。CI 脚本还应记录 xcodebuild -version、当前提交标识和完整 destination,避免把工具链变化误判成代码回归。

覆盖率门禁衡量的是同一组测试条件下的变化,不是不同模拟器、不同测试计划之间的横向排名。

从 xcresult 提取可审计数据

测试完成后,用 xccov 导出 JSON,而不是解析终端中的格式化文本。JSON 更适合保留文件路径、目标名称和行覆盖率,也不会因显示宽度变化而错列。

xcrun xccov view \
  --report \
  --json \
  "$RESULT_BUNDLE" > "$RESULT_DIR/coverage.json"

test -s "$RESULT_DIR/coverage.json"

解析器应先确认 targets 存在,再按目标聚合文件。若报告为空,不要把它当成 0% 覆盖率继续比较,而应直接标记为采集失败。常见原因包括 scheme 未启用测试、目标没有参与覆盖率统计,或测试进程在报告落盘前异常结束。

保留原始证据

建议将以下内容作为同一次任务的产物保存:

产物 用途
TestResults.xcresult 复核测试、日志与覆盖率来源
coverage.json 供脚本稳定解析
coverage-summary.json 保存阈值、实际值和失败文件
command.txt 还原执行参数与工具链

只上传百分比截图无法回答“哪些测试实际运行过”,原始结果包才是排查依据。

同时设置两层门禁

项目总覆盖率适合发现大幅倒退,但大型代码库新增几十行未测试代码时,总值可能只变化零点几个百分点。因此建议使用两层规则:

  1. 项目总覆盖率不得低于固定底线。
  2. 本次变更涉及的可执行源文件不得低于更高阈值。

例如,总覆盖率底线设为 72%,变更文件设为 85%。阈值应来自团队当前基线,而不是一次性定到无法执行的理想值。下面的 Python 片段演示读取目标覆盖率;实际项目可继续把 files 展开成逐文件判断。

import json
import sys

with open("Artifacts/Coverage/coverage.json", encoding="utf-8") as f:
    report = json.load(f)

targets = report.get("targets", [])
if not targets:
    raise SystemExit("Coverage report has no targets")

tested = [t for t in targets if t.get("name") == "ExampleApp.app"]
if len(tested) != 1:
    raise SystemExit("Expected application target was not found")

coverage = float(tested[0]["lineCoverage"]) * 100
minimum = 72.0

print(f"application_line_coverage={coverage:.2f}")
sys.exit(0 if coverage >= minimum else 2)

脚本应使用不同退出码区分“覆盖率不达标”和“报告无法解析”。前者需要补测试或解释变更,后者应修复采集链路,不能混成同一种失败。

只检查真正需要测试的文件

变更文件可以从 git diff --name-only 获取,再与 xccov 报告中的路径取交集。不要把所有 .swift 文件直接纳入门禁,以下内容通常需要明确排除:

排除规则应放进仓库并接受代码审查,不能在失败后临时加一条通配符。路径比较前还要统一仓库根目录、解析符号链接并去掉临时构建目录前缀,否则同一文件可能因绝对路径不同而匹配失败。

对重命名文件,使用 git diff --name-status -M 比单纯的文件列表更可靠。若文件从旧路径移动到新路径,应按新路径寻找报告项,而不是把它误判成“报告中不存在”。

处理并行测试与波动

覆盖率偶发变化通常不是 xccov 自身随机,而是测试执行不确定。异步测试应等待明确状态,不能靠固定秒数休眠;共享数据库、单例和临时目录要在每个测试前重置。若并行测试会争用同一资源,可先对相关测试目标关闭并行,再定位具体共享点。

模拟器也要有可追踪的生命周期。长驻执行器可以在任务前清理应用数据,但不必每次删除全部运行时。真正需要比较的是同一测试计划在相同工具链下能否连续得到一致结果。可先对同一提交重复运行三次,记录每个文件的差值;持续波动的文件应先修复测试隔离,再纳入严格门禁。

最后,把失败摘要写成人能直接行动的信息:实际总覆盖率、要求值、低于阈值的变更文件、该文件已覆盖与可执行行数,以及原始 xcresult 的归档位置。这样开发者无需重跑整套任务,就能判断该补测试、修脚本,还是处理测试环境的不确定性。

常见问题

代码覆盖率门禁应该只检查项目总覆盖率吗?

不应该。总覆盖率变化迟钝,适合防止整体明显倒退;变更文件覆盖率更能约束本次提交。实践中应同时检查两者,并对生成代码、资源访问器和测试辅助文件设置明确排除规则。

为什么同一提交的覆盖率偶尔会变化?

常见原因包括测试选择范围不同、异步任务未完成、并行测试共享状态、模拟器残留数据以及 Xcode 版本不一致。先固定 scheme、destination、测试计划和工具链,再排查测试本身的非确定性。

门禁失败后需要保留哪些文件?

至少保留原始 xcresult、解析后的 coverage.json、阈值判定摘要、测试命令和当前提交标识。这样既能复核覆盖率,也能定位是测试未运行、报告解析失败还是实际覆盖下降。

独享物理节点

用一台按项目启停的云端 Mac 复现工作流

从三档 Apple Silicon 配置中选择合适资源,按天、周、月或季租用。设备为独享物理机、非虚拟机,实际可用状态以控制台实时返回为准。

选择配置并下单