一次长任务回归测试提效实践
本文是一套面向多仓、多阶段、数小时级甚至数天回归的工程实践。当测试链路从分钟级扩展到数小时级,真正拖慢交付的往往不是“测试太多”,而是执行顺序不合理、证据失效判断粗糙、构建和环境被重复创建,以及失败后从头盲目重跑。
本文给出一套不减少测试、不放宽断言、不降低覆盖率门槛的提效方法:把测试执行组织为 Fast、Impacted、Candidate、Release 四层;用“输入闭集”判断哪些证据必须失效;把通过后的 artifact 视为不可变证据并做只读复验;在长阶段之前完成零成本预检和分钟级 fail-fast;最后用失效矩阵指导恢复路径。
适用场景(本文面向的团队形态):
- 完整回归由多个仓库、多个阶段组成,关键门禁耗时达到数小时级;
- 经常需要回答“这次变化到底要不要重跑长任务、哪些证据还能复用”;
- 对质量门槛有硬约束:不砍测试、不放宽断言、不降覆盖率。
不太适合:完整回归几分钟内就能跑完的团队。此时粗放重跑的成本通常可以接受,证据化带来的额外成本不一定划算(见第 11 节)。
脱敏说明:本文由一份真实内部测试提效流程整理而成。为适合公开分享,已移除或泛化项目名称、仓库代号、脚本名、环境变量、绝对路径、精确测试数量、精确覆盖率阈值、精确墙钟时间以及外部基础设施标识。保留的是可复用的工程原则、流程结构和判断方法。
全文按“为什么慢 → 如何组织执行 → 如何判断失效 → 如何恢复与落地”展开:第 1~2 节讲问题与墙钟排序;第 3 节给出四层执行模型;第 4~7 节讲证据化与失效判断;第 8~10 节讲失败恢复与日常执行顺序;第 11 节总结适用边界。附录 A / B / C 分别提供落地 Checklist、可优先自动化的能力与术语速览;正文中的 stage、attempt、sealed、verifier、join 等英文术语可随时到「附录 C」对照。
1. 问题不是“测试太多”,而是关键路径被浪费
在大型工程里,完整回归很容易演化成长任务:服务端要跑单元、集成、并发和迁移检查;中间层要验证生成物、锁文件、运行时和跨仓契约;移动端还可能需要数小时的 host/static 测试和单模拟器完整回归。
如果把所有门禁机械地从头到尾跑一遍,任何靠后的失败都会把前面的数小时成本变成沉没成本。更常见的问题是:某个无关文档、独立 verifier 或另一个仓库的身份变化,也触发了完整移动端回归。最终大家会误以为“只有砍测试才能提效”。
这套实践的前提恰好相反:测试标准不降,优化的是关键路径和证据复用。
- 生产回归直接相关的正向、负向、并发、失败/重启和 absence guard 继续保留。
- 完整候选门禁仍要求 fresh 环境、matching build、完整 discovery、未过滤执行以及既定并行度。
- 失败产物必须保留,不能通过删断言、放宽 mock、覆盖失败日志或自动重试“洗绿”。
- 覆盖率、expected skip、发布前置条件继续按既有策略执行;没有执行正式 evaluator,就不能把原始数据冒充门禁结果。
关键结论:真正可持续的提效,不来自少测,而来自:更早失败、精确失效、证据只读复验,以及避免共享资源冲突。
2. 先看墙钟:把“便宜且上游”的门禁前置
一次真实执行中,不同阶段的耗时跨度可以从分钟级到数小时级。只要有这种数量级差异,执行顺序就应该被当作关键路径优化问题,而不是一张静态 checklist。
| 阶段类型 | 典型耗时级别 | 主要资源 | 应该承担的职责 |
|---|---|---|---|
| 隔离准备/运行时检查 | 分钟级 | 包管理器、轻量运行时 | 尽早暴露锁、环境、stage、runtime 问题 |
| 跨仓契约审计 | 分钟级 | 多语言编译/生成工具 | 发现契约、生成物、peer、inventory 不一致 |
| Host/Static 完整门禁 | 数小时级 | CPU、编译与测试框架 | 覆盖大量 host 逻辑、静态与 inventory guards |
| 完整 Candidate | 小时级 | 单一模拟器/设备环境 | 在 fresh 状态下执行完整候选回归 |
排序原则很简单:
- 上游优先:越可能导致改代码的门禁,越应该在长阶段之前完成。
- 便宜优先:同样能阻断后续工作时,先跑分钟级检查,再启动小时级资源。
- 共享资源后置且串行:模拟器、数据库 reset、共享 codegen 等不能靠“更多并发”解决。
这意味着,长达数小时的移动端完整回归应该尽量靠后;在它之前,先完成零成本输入预检、服务端完整门禁、隔离准备、跨仓契约审计和 host/static 阶段。只要这些前置阶段中的任何一个失败,就能避免启动最昂贵的部分。
3. 用四层执行模型替代“改一点就全量跑”
可以把日常测试组织成四层:Fast、Impacted、Candidate 和 Release。四层不是测试集合越来越少,而是回答不同问题。
3.1 Fast:每几个紧密改动就给反馈
Fast 层服务于开发反馈,不启动昂贵环境。它只围绕当前变更的直接风险,但保留真实生产条件需要的正负回归。
- 运行变更 package、目标 test class 或定向测试;涉及并发时同时跑对应 race。
- 契约变化立即执行 codegen、compile、fixture bytes/hash 和 no-diff guard。
- schema 或 migration 变化要同步触发严格迁移链;缺可信 baseline 时保持 BLOCKED,而不是用普通 GREEN 代替。
- 先保存 RED 的 source hash、命令、seed 和日志,再做单 case 最小复现。
- 默认不启动模拟器或其他重资源。
3.2 Impacted:沿消费者图扩展,而不是按仓库粗放扩展
“某仓改了,所以三个仓全跑”是一种容易执行但代价很高的近似。更好的做法是从 canonical source 出发,沿 generated output、production caller、runtime owner、peer lock 等实际依赖边扩展。
影响面应该由消费者关系决定,而不是由仓库边界决定。这样既不会漏掉真实消费者,也不会把完全独立的 stage 无差别失效。
- 消费者需要覆盖所有真实镜像/变体,不能只跑最常见路径。
- 测试数量用当次 discovery 的实际结果,不硬编码历史数量。
- 只有彼此只读、无共享资源的审计才并行;共享 codegen、数据库 reset、模拟器等始终串行。
3.3 Candidate:冻结最终树后,再跑完整候选门禁
Candidate 是昂贵但可信的“最终树证据”。它应该建立在源码、契约、测试策略和 runner 已冻结的前提上。一个推荐顺序是:
- 服务端 full / integration / race / migration 与安全门禁。
- 轻量中间层 isolated prepare。
- 跨仓 final contract audit:全量 regen no-diff、exact peer、inventory 和 clean 状态。
- 移动端 Host/Static。
- 完整 Candidate:fresh artifact/cache、matching build、fresh reset 后执行一次未过滤完整回归。
- 独立只读 verifier 重新计算 sealed artifact、当前身份和终态,生成外置 receipt。
- 最后重建 review manifests,并做精确状态差分。
冻结后如果产品源码、canonical contract、test policy 或 runner 字节发生变化,不应该让长任务“继续跑完再说”,而应停止并重新计算失效范围。边跑边改,会让最终证据对应不上任何稳定输入。
3.4 Release:永远是独立层
本地 Candidate GREEN 不等于可以发布。Release 需要额外的外部条件,例如 provider 状态、签名、真实设备、备份/回滚、部署权限以及控制面 readback。缺少这些输入时,正确状态是 BLOCKED/UNVERIFIED,而不是用 dry-run、fake provider 或静态检查替代真实发布链。
实践要点:Release 的失败恢复规则可以比 Candidate 更严格:诊断可以定向补跑,但最终放行应使用新的 gate/namespace,从头执行完整 orchestrator,旧失败目录只读保留。
4. 最关键的抽象:把每次门禁变成“统一证据单元”
要安全地复用长任务结果,必须回答一个问题:这份 GREEN 到底证明了什么?如果答案只是“某个仓、某次测试跑过了”,复用一定会越来越靠人工判断。
更稳妥的做法是:每个 stage 启动时创建唯一 attempt ID 和 checkout 外的 fresh namespace,并冻结一个“输入闭集”。receipt 的有效性只按这个闭集判断。
| 输入类别 | 应冻结的内容(公开版示例) |
|---|---|
| 源码身份 | 相关仓库的 commit/tree、clean 状态,以及作为正式 source identity 的文档变化 |
| 契约与策略 | canonical contract、peer lock、fixture、test/guard、selection、coverage、expected-skip policy、inventory/registry |
| 执行实现 | runner/verifier 的内容 hash,避免“脚本名没变但字节变了” |
| 工具链 | 实际可执行文件与版本、依赖来源、网络依赖策略 |
| 测试选择 | discovery count、并行度、destination、build key、freshness 条件 |
| 证据路径 | artifact、cache、terminal、failure marker、mutex 等精确位置及存在性要求 |
| 外部证据 | identity、bytes、权限/所有权、有效期与 trust policy 等 |
这里最重要的一点是:
不要按“看起来相关”判断复用:任何 reuse 都应该来自机器对输入闭集的比较,而不是“仓名看起来没关系”“应该只是文档变化”“代码大概没动”。机器能证明输入未变,才允许只读复验;否则就按失效处理。
5. 失效矩阵:决定“重跑”还是“只读复验”
长任务提效的收益,主要来自失效判断的精确度。可以把常见变化整理成一张矩阵:
| 发生了什么变化 | 必须重新执行 | 可以保留什么 |
|---|---|---|
| 普通说明文档变化,且不属于 manifest source | 文档/diff/hash 检查 | 产品测试、sealed artifact 和不受影响的派生证据 |
| review manifest source 变化 | manifest 重建、生成器单测、source hash/status 断言 | 输入闭集未受影响的产品测试和 sealed artifact |
| 独立 verifier 变化 | verifier 自身正负测试;用新 verifier 只读复算旧 artifact | artifact 内原始产品测试结果;旧 receipt 仅作历史事实 |
| 某 stage runner/harness 变化 | runner 单测 + 该 stage 完整重跑 + 下游 join | 不包含该 runner 的其他独立 stage seal |
| 无关仓身份变化,但目标 stage 的 source/build/toolchain/policy/contract 未变 | exact identity/peer 检查 + 新 join receipt | 目标 sealed artifact |
| 产品源码、generated bytes、build config 或测试选择策略变化 | 对应 Fast/Impacted,及受影响的完整 Candidate/contract audit | 与变化闭集无依赖边的独立证据 |
| schema/migration 或 strict baseline 变化 | 严格 migration、integration/e2e/drills,以及受影响 Candidate/Release | 完全不消费该 schema/baseline 的独立 stage |
| 工具链、运行时、coverage/selection policy 变化 | 受该工具/策略影响的 stage 及其下游 join | 不消费该工具/策略的独立 stage |
| 外部 provider/control-plane/deployed version 变化 | 当前 Release readback / promotion / rollback 链 | 本地 Candidate,但不能用旧外部 receipt 放行新 Release |
一个非常典型的场景是:昂贵的移动端 child 已经 PASS 且 sealed,源码、build key、toolchain、policy、命令和完整测试结果都没变;变化只发生在外层 identity join 或 verifier。此时正确动作是提交新的 verifier 后只读复算并生成新的 join receipt,而不是重新跑数小时模拟器回归。
反过来,如果 child 本身失败、根本没有执行,或者 sealed 输入中的任意一项发生变化,就不能靠 verifier 把它“变绿”。证据复用不是结果缓存,而是对同一输入事实的重复验证。
6. Build、Cache、Artifact:三者必须分清
长任务系统里最容易混淆的三个概念是 build、cache 和 artifact。它们的复用边界不同。
- Artifact:是证据。成功封存后只读,不能被后续过程覆盖。
- Cache:是加速手段。可以按策略复用,但永远不能冒充 sealed evidence。
- Build:只有完整 build key 不变时才可复用;build key 至少应该覆盖 source/generated bytes、工具链/runtime、device type、test plan、build config 和依赖解析结果。
尤其要避免一个常见误区:复用 build 不等于复用运行时状态。完整 Candidate 即使复用了匹配 build,仍然要遵守 fresh simulator/device、fresh artifact root、单次完整调用等环境合同。
对于需要 fresh 的 namespace,必须在启动前证明路径不存在;允许依赖 cache 的 stage,则应该记录 cache 的 provenance 和 integrity。这样才能把“速度优化”和“证据可信度”解耦。
7. 并发不是越多越好:以关键路径和证据确定性为准
在长任务里,“并发更多”很容易成为一个看起来正确的 KPI,但它可能同时增加 CPU、磁盘和内存竞争,让真正的关键路径变慢,还会引入共享状态不确定性。
| 适合并行 | 应保持串行 |
|---|---|
| artifact 只读盘点 | 共享 codegen owner |
| manifest 集合复算 | 数据库 reset |
| 文档/checkbox 边界审计 | 模拟器/设备测试 |
| 互不写共享状态的静态审计 | 任何持有同一 mutex 或修改同一 runtime 的 stage |
在每个长阶段启动前,还应该一次性做资源预检:磁盘门槛、路径 owner/mode、symlink/special node、现存进程/句柄、mutex 等。提前几十秒发现“磁盘不够”或“锁被占用”,远好于跑几个小时后才失败。
8. 失败恢复:先保留 RED,再缩小复现,再按失效范围恢复
失败后的第一反应不应该是清目录、重新点一次。一个可审计的恢复流程可以是:
- 原样保留失败 attempt、terminal、step/meta、source/runner hash 和 failure marker,不覆盖原 namespace。
- 把失败分类为 production、test harness、tooling、environment、disk/resource 或 flaky evidence,分类必须有可复现事实。
- 用单 test/package 或最小 stage 复现,只修复与真实回归直接相关的最小改动。
- 先恢复 Fast,再跑最近受影响的 Impacted/Candidate stage;不要默认从头重跑全部长门禁。
- 机器比较输入闭集:未失效的 sealed artifact 只读复验,真正失效的长阶段才重跑。
- 新 attempt 使用新路径;旧 RED 只读保留,不续跑、不改写成 GREEN。
清理也应该遵循证据边界:只删除 checkout 外、明确属于当前任务、可以精确重建且未被 seal 引用的 literal path。不要用宽泛 glob 或危险的递归删除命令替代路径验证。
9. 每个 Stage 的最小完成条件
为了让不同语言、不同仓库、不同 runner 的 stage 都能被统一复验,可以给每个 stage 定义相同的完成合同:
| 时点 | 最小要求 |
|---|---|
| 开始前 | 路径 fresh;源码/runner/toolchain/hash 冻结;仓库 clean;资源门槛满足;共享锁唯一 |
| 运行中 | 完整命令与 closed environment 可回放;记录实际 discovered/observed/selected 计数 |
| 结束时 | exit 0;不存在未被策略明确允许的 failed/skipped/0-test 异常;postflight 成为最终状态 |
| 覆盖率 | 由正式 threshold evaluator 判定;各 overall/critical floor 与既有政策完全一致 |
| 封存 | 输入闭集 manifest 逐文件复算;外置 seal/terminal no-clobber;明确内容 seal 与 metadata seal 的边界 |
| 复核 | 无残留相关进程/句柄;运行时回到要求状态;产品仓保持 exact identity 且 clean |
| 结论 | 只关闭本 stage 文字精确覆盖的 task;相邻 finding、Deploy、Release 不自动传递 GREEN |
10. 一套可以直接落地的日常执行顺序
- 定义变更闭集和真实生产回归用例。
- Fast GREEN 后做阶段提交或冻结小步。
- Impacted GREEN 后冻结最终多仓身份。
- 先完成全部零成本 external-input 预检,再跑上游和分钟级门禁。
- 再跑 Host/Static,最后启动单一重资源环境的完整 Candidate。
- 全部 artifact 用独立 verifier 只读复算,并按当前身份重新 join。
- 只重建受影响的 review manifests,做精确状态集合差分。
- 证据不足时停在 Candidate/BLOCKED,不伪造 Release。
一句话版:标准、测试数量、freshness 和证据闭包保持不变;节省的时间来自更早失败、精确失效、只读复验和更少资源冲突。
11. 这套方法为什么适合“经常跑大任务”的团队
当一次完整回归只需要几分钟时,粗放重跑通常还能接受;当关键门禁进入小时级,同样的粗放策略会快速放大成本。此时最值得建设的不是“更多缓存”,而是可机器判断的证据模型。
这套方法的长期价值有三点:
- 把测试结果从“某次流水线日志”升级为绑定输入闭集的可验证证据。
- 把“要不要重跑”从经验判断变成失效矩阵和机器比较。
- 把优化目标从“少跑多少测试”改成“减少多少无意义关键路径时间”,从而避免质量与效率被错误地放在对立面。
对于跨仓、多语言、多平台系统,这种证据化思路尤其重要:真正需要复用的不是一个 GREEN 标签,而是对“同一组输入产生了同一组完整、可信结果”的证明。只要把这个边界定义清楚,很多数小时级重复工作就可以安全地变成秒级或分钟级只读复验。
附录 A:落地 Checklist
| 检查项 | 完成标准 |
|---|---|
| 变更闭集 | 明确 source、generated output、consumer、policy、runner 的影响边 |
| Fast | 直接回归、race/失败路径按需覆盖;RED 证据先保存 |
| Impacted | 按消费者图扩展;只并行只读且无共享资源的任务 |
| 冻结 | 最终源码/契约/测试策略/runner 不再边跑边改 |
| 零成本预检 | 身份、外部 receipts、工具链、签名/权限、磁盘、锁、fresh root 一次性检查 |
| Candidate | 上游便宜阶段先跑;最昂贵阶段最后启动 |
| 封存 | artifact 只读;cache 与 artifact 分离;receipt 绑定输入闭集 |
| 复验 | 独立 verifier 只读重算;identity 变化只更新 join,不盲目刷新产品测试 |
| 失败恢复 | 旧 RED 保留,新 attempt 新 namespace;按失效范围恢复 |
| Release | 外部条件不足即 BLOCKED;最终放行遵守完整 release orchestrator 合同 |
附录 B:适合优先自动化的三个能力
如果要把这套流程进一步产品化,优先级最高的通常不是再写一个“大一统脚本”,而是把以下能力机器化:
- 输入闭集生成器:为每个 stage 输出稳定、可比较的 source/build/toolchain/policy manifest。
- 失效计算器:输入两个 attempt 的闭集,输出哪些 stage 必须重跑、哪些只需只读复验,以及理由。
- 独立 verifier:不依赖原 runner 的内存状态,重新计算 sealed artifact、终态、身份和 receipt,防止“执行成功”和“证明成功”耦合在一起。
做到这三点后,长任务的提效会从一套人工纪律,逐步变成可持续的工程基础设施。
附录 C:术语速览
正文为保持行文紧凑,直接使用了一批英文术语。这里按“一次门禁从执行到证明”的视角给出公开版含义,方便不熟悉这套流程的读者对照阅读。
| 术语 | 公开版含义 |
|---|---|
| stage | 一个可独立判断的门禁/测试单元(四层模型中的某一层,或其中某个 runner 负责的一段检查)。 |
| attempt | 对某个 stage 的一次执行尝试;每次 attempt 有唯一 attempt ID,并使用独立的 namespace。 |
| Fast / Impacted / Candidate / Release | 四层执行模型,分别回答“这次改动行不行”“影响面多大”“最终树证据是否可信”“能否发布”。 |
| 输入闭集 | 一次 attempt 生效所依赖的全部输入事实(源码身份、契约与策略、执行实现、工具链、测试选择、证据路径、外部证据);receipt 是否有效只按它判断。 |
| sealed(封存) | stage 通过后把 artifact 与证明固化为只读、不可被后续过程覆盖的证据;内容 seal 与 metadata seal 分开记录。 |
| receipt | 绑定某次输入闭集的“通过证明”,由独立 verifier 复算或 join 生成。 |
| verifier | 独立于原 runner、只读重算 sealed artifact、终态与身份的检查者,避免“执行成功”与“证明成功”耦合。 |
| join | 把多个独立 stage 的 sealed 结果按当前身份汇总成一次 receipt / 结论的过程(如 join receipt、外层 identity join)。 |
| manifest / review manifest | 记录一次变更或发布应覆盖内容与状态的文件;review manifest 由其 source 重建,并做精确状态差分。 |
| discovery | 执行前发现“实际要跑哪些测试”的过程;测试数量以当次 discovery 结果为准,不硬编码历史数量。 |
| fresh / matching build | fresh:环境或路径在启动前被证明不存在或被重置;matching build:build key 完全一致才允许复用构建。 |
| Host/Static | 在宿主机上运行的、覆盖 host 逻辑与静态 / inventory 检查的门禁(数小时级)。 |
| absence guard / no-diff guard | 断言“某种东西不应存在 / 不应有差异”的守卫检查。 |
| expected skip | 被策略明确允许跳过的一组测试;除此之外的 failed / skipped / 0-test 异常不被接受。 |
| canonical contract / peer lock / fixture | 跨仓契约的权威定义 / 与对端仓库锁定的版本关系 / 测试固件(含 bytes/hash)。 |
| inventory / registry | 对生成物、镜像、变体或注册项的盘点记录。 |
| evaluator / threshold | 正式判定覆盖率等指标是否达标的执行器;未执行 evaluator 时,原始数据不能冒充门禁结果。 |
| RED / GREEN / BLOCKED | stage 状态约定:RED=失败(先保存证据再复现),GREEN=通过,BLOCKED=缺少可信输入时的正确状态。 |
| fail-fast | 在昂贵长阶段之前,用零成本或分钟级检查让问题尽快暴露。 |
| provenance / integrity | cache 等加速手段的来源可追溯性与完整性校验。 |
| namespace | 一次 attempt 使用的独立路径/环境命名空间,防止覆盖旧证据。 |
| gate / orchestrator | 门禁(可阻断后续流程的检查关卡)/ 编排一次完整执行链的调度者。 |