两千条测试涨到六千条,全量跑一次四十分钟。于是本地只跑改动涉及的那三个类,CI 的失败名单挂着一长串红色没人认领,发布前靠一句"上次也是这么发的"过关。这时候套件已经不再是保护措施了,它变成了组织里的另一种存在:写在流程文档里,但没人真正执行。
看清这件事只有一个视角:把测试当成一笔要按年付维护费的资产。维护费不在写它那天付,而在它之后每一次变更里付——改一个方法就要改掉几条用例,换一个接口就要动几个夹具,改一条业务规则就要全局搜一遍散落的期望值。删测试是预算决策,不是态度问题:留下来的每一条都在占用两类稀缺资源,一是每次变更时工程师的注意力,二是每次提交时流水线的墙钟时间。
1. 维护费从哪几个口子流出去
| 变更类型 | 触碰的测试面 | 维护动作 | 代价信号 |
|---|---|---|---|
| 改方法签名 | 所有直接调用它的用例 | 改调用、改夹具 | 一次小改动的红测数量 |
| 改内部结构 | 断言中间状态的白盒用例 | 重写断言 | 重构时必须同步改测试 |
| 改接口或协议 | 集成用例、契约桩、测试替身 | 更新桩与录放数据 | 替身自身的维护量 |
| 改业务规则 | 期望值散落在多处 | 全局搜期望值并逐条核对 | 同一条规则被十处重复断言 |
| 新增分支 | 参数化数据、夹具 | 补数据行 | 用例数随分支线性增长 |
关键判断:用例数量线性增长,变更时的维护成本却接近超线性,放大系数不是数量本身,而是"断言与实现的耦合度"。同样六千条用例,断言都压在行为层与断言都压在内部字段层,维护成本差一个量级——前者改实现不用改测试,后者改任何一行都要陪跑。
第二口子是执行时长,它决定套件会不会被真的运行:跑不完的全量等于不存在。它不会报错,只会被绕过;而被绕过一次之后,绕过就变成了默认路径。
// 分层预算是时长分配,不是道德要求
// ─── 提交触发:单元层,预算分钟级(目标是"每次提交都跑")
// ─── 合并门禁:集成 + 契约,预算十分钟级
// ─── 每日 / 发布前:端到端 + 慢用例,预算可达半小时级
// 超预算的用例:要么优化、要么移层、要么删
// 判断依据是分层后的墙钟时间,不是用例条数
2. 测试失效的四种形态
| 形态 | 典型表现 | 危害 | 处置 |
|---|---|---|---|
| 永远通过 | 无断言、断言恒真、被替身架空了真实调用 | 最危险:提供虚假安全感 | 补断言,补不出来就删 |
| 永远失败 | 长期红,被排除在门禁之外 | 训练团队忽略红色,真失败被淹没 | 限期修或删,禁止长期挂着 |
| 随机失败 | 重跑才过,依赖时序、端口、残留数据 | 用重跑代替排查,信任流失 | 修根因或删,不许标记忽略 |
| 测了错的东西 | 断言实现细节、断言替身被调用了几次 | 锁死重构自由,变更成本翻倍 | 改写成行为断言或删 |
四种里最容易被放过、也最贵的是第一种。一条永远通过的测试在报表上是绿灯,在事故现场是干扰项——它占着位置,让"这块有覆盖"的判断落空,而它其实只证明了"这段代码能被调用"。它比"永远失败"更难发现:红色至少有人在看,绿色没人复核。
// 审计被静默的用例:忽略标记是欠账,不是结论
$ grep -rn "@Disabled\|@Ignore\|it.skip\|t.Skip(\|mark.skip" src/test tests \
| tee skip-audit.txt
// 再找一条断言都没有的用例:这类只证明"代码能被调用"
$ grep -rL "assert" tests/ | tee no-assert.txt
// 统计随机失败:同一用例最近二十次执行的通过率
$ grep -h "FAIL" ci-runs/*.log | awk '{print $3}' | sort | uniq -c | sort -nr | head
// 路径按仓库实际结构核对;注释里出现的 skip 要人工剔除
审计结果通常很难看:忽略标记的数量与团队规模成正比,而每一条忽略标记背后都是一次"先这样,回头再说"。不稳定测试必须修或删,不能标记忽略后就当不存在——忽略等于把它从预算里划掉但保留在账面上,两边的数都不真实。
3. 删除的判据
删测试比写测试更需要判据:写错的成本是维护费,删错的成本是失守。
| 判据 | 可验证信号 | 反例(别删) |
|---|---|---|
| 覆盖的行为已不存在 | 被测代码已删除,用例挂在孤立符号上 | 行为因兼容或外部约定随时可能复活 |
| 与其他用例重复 | 断言集合是另一条的真子集 | 重复的那条恰是唯一覆盖边界值的 |
| 断言的是实现而非行为 | 断言指向私有方法、内部字段、调用次数 | 协议与客观约束(字段顺序、错误码) |
| 维护成本高于它能发现的问题 | 台账里拦住的缺陷为 0,且所在模块改动最频繁 | 该区域刚出过回归事故 |
| 用例其实是一次性验证脚本 | 人工执行、依赖本地文件、名字带日期 | 它其实是监控探测或对账作业 |
| 依赖的替身已经失真 | 替身行为与真实依赖长期不一致 | 真实依赖不可得,替身是唯一手段 |
判据之后是动作规范,而动作规范更容易被跳过:
// 删除四步,禁止一步到位
// 1) 先降级:移出门禁,仍留在仓库里观察一个完整周期
// 2) 观察期内出现任何一次"本该它拦住"的失败 → 恢复并补强
// 3) 确认安全后删除,提交信息写明依据哪条判据
// 4) 归档交给版本历史,不要留注释掉的大段用例
第 4 条常被违反:注释掉的用例既不被执行也不被清理,比删除更糟——后来者不敢动它,又不能信任它,于是它永久停在那里,每次搜索都会命中一次,消耗一次注意力。
4. 归属、配额与度量
| 问题 | 常见做法 | 判据与代价 |
|---|---|---|
| 谁维护 | "谁写谁维护" | 写的人离职或转岗后就是无主资产;按"谁会改坏它"定归属,兜底给模块所有者 |
| 只补不删 | 覆盖率只升不降 | 套件单调膨胀,最终没人跑全量 |
| 只删不补 | 清理行动的产物 | 关键行为失守,事故反噬比维护费更贵 |
| 测试重构的成本 | 被当成零 | 夹具抽取、断言改写、数据隔离,量级常与业务改动相当 |
| 改写测试的排期 | 不算工时 | 低估的后果是"写了但不跑",因为没时间调试它 |
正确做法是把补与删放进同一本账:每个周期交付三个数——新增 N 条、删除 M 条、修稳 K 条,其中"修稳"指把随机失败改成确定性。三个数长期失衡(M 与 K 恒为 0),说明套件在往不可维护的方向走;而只有第三个数字长期为零,套件其实已经在慢慢腐化,只是还没到痛的那天。
"谁写谁维护"这条原则的失败方式很固定:作者是当时最懂这段逻辑的人,一年后他调走了,用例还在,但没人敢改,因为不知道断言里那个魔数代表什么。更稳的归属是按模块所有者:谁负责这块代码,谁负责让它的测试通过、变快、变准,这样归属随组织图走,而不是随人名走。
// 套件台账:一行一条记录,按周期聚合
// ─── 有效性比例 近期变更中被它拦住的失败占比 长期为 0 → 在陪跑
// ─── 平均执行时长 全量跑完的墙钟时间 持续上涨且无人削
// ─── 不稳定率 需重跑才通过的用例数 ÷ 总用例数 用重跑掩盖
// ─── 覆盖行为数 与规则条目一一对应的用例数 用行覆盖率替代
// ─── 变更红测数 一次典型改动触发的失败用例数 只涨不降
// ─── 删除速率 每周期删除条数与新增条数之比 恒为 0 → 只进不出
其中覆盖行为数要用来替代行覆盖率:行覆盖率对"这段代码被调用了"敏感,对"这条规则被验证了"麻木。同一个行为在十处被断言,行覆盖率好看,行为覆盖数没变。"测试数量"本身不是质量指标,它常常反向——数字越大,越可能说明断言被摊薄在重复用例里,而不是说明覆盖更完整。
5. 排错:套件过载的六种现场
| 现象 | 根因 | 处理 |
|---|---|---|
| 全量跑不完,没人跑 | 缺分层预算,个别慢用例吃掉额度 | 按触发时机分层,先定位最慢的若干条 |
| 失败名单长期挂 N 条 | 没有到期日,红色常态化 | 每条失败给负责人与到期日,过期强制处理 |
| 门禁被绕过合并 | 红色频繁,绕过成了习惯 | 门禁只收确定性失败;修稳优先于加新 |
| 新人只写实现不写测试 | 缺可模仿的样板,改测试不计入排期 | 提供夹具与模板,把"改测试"写进任务估算 |
| 删测试后出了事故 | 删掉的是约定而不是重复 | 恢复删除四步,降级观察期不可省 |
| 行覆盖率涨了事故没少 | 断言强度低,覆盖的是调用不是规则 | 用变异测试或故障注入抽样验证断言强度 |
6. 边界:这些情况不必做套件治理
| 场景 | 是否值得 | 替代动作 |
|---|---|---|
| 测试总数不足百条 | 收益低 | 先把关键路径补上,谈不到删 |
| 系统即将重写 | 收益低 | 只保留行为约定用例,不投资套件重构 |
| 用例本质是一次性验证脚本 | 不适用 | 归入运维脚本目录,带到期日单独管理 |
| 需求本身高频变动 | 部分适用 | 保留端到端行为断言,压缩白盒断言 |
| 没有 CI 环境 | 前置缺失 | 先建最小反馈环,再谈分层与删减 |
| 单人项目、短期原型 | 简化 | 守住"每次提交自动跑"这一条即可 |
一句话收束:判断一套测试该不该留,不看它写了多久、覆盖了多少行,而看它在下一次变更里要你付多少钱,以及上一次事故它有没有出声。
自测
- 变更是怎样把测试从"资产"变成"负债"的,维护费的放大系数取决于什么?
- 四种失效形态里哪一种最危险,为什么它比"永远失败"更难被发现?
- "覆盖的行为已不存在"与"行为随时可能复活"之间,判据是什么?
- 为什么删除要先降级观察,而不是直接删?
- 用"覆盖行为数"替代行覆盖率,避免了哪种误判?
- 什么情况下做正式套件治理的收益低于成本?