单元测试写了两千个,线上还是天天出事——这不是"测得不够",是测试的分布放错了位置。金字塔被引用得太多,被理解得太少:它描述的是反馈速度与单位缺陷成本的关系,不是"单元测试该占 70%"这种数量配比。把它当成配额表去凑数,得到的就是一个跑得快、拦不住的测试集。
1. 金字塔描述的是成本曲线,不是比例
| 类型 | 反馈速度 | 稳定性 | 能拦住的缺陷 | 单次维护成本 |
|---|---|---|---|---|
| 单元 | 毫秒级 | 高(无外部依赖) | 分支逻辑、边界条件、算法错误 | 低,但与被测代码强耦合 |
| 集成 | 秒级到十秒级 | 中(依赖容器/网络/时钟) | 契约错配、SQL 语义、序列化、事务边界 | 中,环境漂移会持续消耗 |
| 端到端 | 分钟级 | 低(UI/网络/时序) | 装配错误、配置错误、跨服务链路断裂 | 高,定位成本远超修复成本 |
判断依据不是数量,是这条测试失败时,你能不能在一分钟内说出原因。说不出来的测试,无论在哪一层,都是负债。
2. 单元测试该覆盖分支与边界,不是每个方法
"每个方法一个测试"产出的是覆盖率幻觉:getter 有测试,if (amount > limit) 的两个方向却只有一个被测。真正的分母是分支与边界组合,不是方法数。
@ParameterizedTest
@CsvSource({ "0,false", "1,true", "9999,true", "10000,true", "10001,false" })
void shouldRejectOverLimit(long amount, boolean expected) {
assertEquals(expected, policy.check(amount)); // 边界值各测一侧
}
参数化测试的价值在于让边界显式成表:上面这张表本身就是需求文档。缺陷高发区通常是四类——空值、临界值(0、max、max+1)、状态迁移(未支付→已支付)、并发交错。这四类不覆盖,测一百个方法也没用。
反过来,纯委托方法(return repo.find(id))的测试价值接近零:它只能拦住"有人改了调用目标",而这种改动通常伴随编译错误。
3. 集成测试的收益峰值在跨边界契约
| 边界 | 值得写集成测试的理由 | 不值得的理由 |
|---|---|---|
| 数据库访问 | SQL 语义、索引、事务传播、类型映射,mock 全部测不到 | 单表 CRUD 且已有框架保证 |
| 外部 HTTP API | 请求体形状、鉴权头、错误码分支 | 供应商稳定且契约测试已覆盖 |
| 序列化/反序列化 | 字段名、时区、精度、可选字段缺失 | 只在进程内传值 |
| 消息队列 | 投递语义、重试、死信、顺序 | 仅用于解耦且语义已由框架保证 |
关键判据:mock 掉的部分是否可能出错,且出错后单元测试仍然全绿。如果是,那这块必须有一条真集成测试。数据库时区、decimal 精度、外键级联这类问题,全部属于"mock 永远正确,真实环境永远不对"。
4. 端到端为什么贵
| 成本项 | 表现 | 后果 |
|---|---|---|
| 脆弱 | UI 选择器改动、异步渲染时序、测试数据被别的用例污染 | 一次重构要改几十条测试 |
| 慢 | 每条分钟级,串行跑一小时 | 只能在夜间跑,反馈延迟到次日 |
| 定位难 | 失败只告诉你"首页打不开" | 排查时间常常超过修复时间 |
| 环境耦合 | 依赖共享测试环境、第三方沙箱 | 别人部署一次你就红一片 |
端到端该留的是关键路径,而且是"钱和登录"那类路径:
1. 登录 → 下单 → 支付回调 → 订单状态可见
2. 注册 → 邮件激活 → 首次登录
3. 退款 → 余额变动 → 账单可查
三条够了。第四条开始,边际收益迅速下降,维护成本线性上升。端到端的价值不是"覆盖广",是"证明装配没坏"——它测的是接线,不是逻辑。
5. 断言行为,不要断言实现
// 坏:断言实现细节,重构即红
verify(repo, times(2)).save(any());
assertEquals("{\"id\":1,\"name\":\"a\"}", json);
// 好:断言可观察行为
assertEquals(OrderStatus.PAID, order.getStatus());
assertThat(parsed.name()).isEqualTo("a"); // 解析成对象再比字段
两种断言的区别在于重构自由度:断言实现细节的测试会在你改内部结构时集体爆红,于是团队学会"改测试比改代码快"——测试从此失去警戒作用,只剩仪式感。
测试替身同理。为一个类注入五个 mock,说明这个类的依赖边界有问题;测一个流程要摆十个 mock,说明该测的是契约而不是协作顺序。mock 断言调用次数是最典型的实现耦合,除非次数本身就是业务约束(比如"只扣一次款")。
6. flaky 的处置纪律:隔离、修复或删除
| 处置 | 适用 | 代价 |
|---|---|---|
| 立即隔离(独立流水线/标签) | 影响主干、原因未明 | 覆盖真空,必须设复查期限 |
| 修复(定位根因) | 时钟、异步、共享状态、端口冲突 | 通常要改生产代码的可测性 |
| 删除 | 价值低、成本高于收益 | 需要记录决策,避免反复重建 |
| 重试掩盖 | 任何情况 | 把偶发缺陷变成统计噪声,最坏的选择 |
重试的唯一"合理"场景是外部不可控因素(第三方沙箱抖动),且必须记录重试次数并单独告警——重试率上升本身就是信号。
flaky 的根因清单很短:没固定时钟、没隔离数据、没等待条件(用 sleep 代替条件等待)、共享端口、测试间有顺序依赖,这五项占了绝大多数。隔离要落在构建配置里,而不是靠口头约定:
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<excludedGroups>flaky</excludedGroups> <!-- 主干流水线先排除 -->
</configuration>
</plugin>
被排除的用例必须进一张有复查期限的清单,否则"隔离"就是删除的委婉说法。
7. 覆盖率作为指标的失效方式
| 失效模式 | 表现 | 为什么指标看不出来 |
|---|---|---|
| 断言空洞 | 调用了但没断言,或只断言 assertNotNull |
行覆盖照样 100% |
| 追行数 | 为覆盖 catch 块写空测试 |
覆盖率涨,缺陷率不动 |
| 排除配置 | 把难测模块加进排除列表 | 报表变好看,风险留在原地 |
| 单点指标 | 只看总覆盖率,不看增量 | 老代码拉高均值,新代码裸奔 |
覆盖率当下限守门是合理的(比如新增代码不低于某阈值),当目标就会立刻被优化。更可靠的替代指标:逃逸缺陷数(上线后发现的、本该被测试拦住的缺陷)、测试失败的平均定位时长、flaky 率。
8. 执行位置决定反馈延迟
| 位置 | 延迟 | 适合放什么 | 不适合放什么 |
|---|---|---|---|
| 本地提交前 | 秒级 | 单元测试、静态检查、格式化 | 集成与 E2E |
| CI 每次推送 | 分钟级 | 单元 + 集成 + 契约测试 | 全量 E2E |
| 合并前门禁 | 十分钟级 | 关键路径 E2E、迁移校验 | 性能测试 |
| 预发/夜间 | 小时级 | 全量 E2E、压测、混沌实验 | 需要快速反馈的任何东西 |
延迟超过十分钟的检查,人就会绕过去。这不是纪律问题,是排队论:反馈越晚,上下文重建成本越高,绕过它的动机越强。
9. 遗留系统补测试的入口
不要从"补单元测试"开始,从**特征测试(characterization test)**开始:先把当前行为(包括你认为错误的行为)钉住,再重构。
@Test
void currentBehaviorIsPinned() {
// 不判断对错,只记录现状,重构后必须仍一致
String out = legacy.calculate(input); // 输入取自生产样本
assertEquals(GOLDEN_OUTPUT, out);
}
顺序:挑一条最核心的用例 → 用真实数据构造输入 → 记录输出为黄金值 → 加断言 → 再动代码。每补一条特征测试,就获得一小块可以安全重构的区域。反过来先重构再补测试,等于在没有护栏的路上换轮胎。
10. 排错:测试集本身的六种病
| 现象 | 根因 | 处理 |
|---|---|---|
| 改一行代码红一片 | 断言实现细节、mock 过度 | 改成断言行为,减少协作断言 |
| CI 偶发失败、本地不复现 | 时钟、数据、端口、顺序依赖 | 固定时钟、每用例独立数据、随机端口 |
| 测试跑得比构建还慢 | E2E 过多、容器重复启动 | 复用容器、分层执行、只留关键路径 |
| 覆盖率 90% 但线上照崩 | 断言空洞、只测 happy path | 补分支与边界,加逃逸缺陷复盘 |
| 测试没人看失败 | 长期红灯、重试掩盖 | 清理并设门禁,红灯即阻断合并 |
| 新人不敢改测试 | 测试是唯一的需求文档且已腐坏 | 测试命名写行为,与需求编号关联 |
11. 边界
| 场景 | 判断 |
|---|---|
| 一次性脚本、数据迁移工具 | 不铺测试,跑一次即弃,用 dry-run 校验 |
| 纯展示页面、静态文案 | 断言快照即可,不值得写交互测试 |
| 探索期原型 | 先验证可行性,测试在需求稳定后再补 |
| 强监管/资金路径 | 测试密度应高于常规,尤其补偿与对账 |
| 团队只有两人 | 关键路径 E2E + 核心分支单元,别搭全套流水线 |
测试策略的失败很少是"测得少",多半是"测在了不产生信息的地方"。每加一条测试都该能回答:它失败时,我会因此改变什么?
12. 自测
- 金字塔的纵轴到底表示什么?为什么不是数量比例?
- 一个方法有五个分支,只写一个测试,覆盖率缺口在哪?
- mock 掉的部分满足什么条件时,必须补一条真集成测试?
- 端到端测试的价值是"覆盖广"还是别的什么?
- 为什么"断言 mock 调用次数"会在重构时制造阻力?
- 覆盖率作为下限和作为目标,行为差异体现在哪里?