90% 覆盖率不等于没 Bug:用风险配置测试组合
《工程化决策树》第 4 篇
主题:质量工程化。覆盖率表示代码执行过,不表示关键行为验证正确。

覆盖率接近九成,支付回调还是出了事故
我参与过一次匿名项目的高优先级事故:用户已经支付,订单状态却没有更新。客服电话被打爆时,测试仪表盘显示的覆盖率仍接近九成。
我打开支付回调的测试,看到的是这些:
java
/**
* 验证支付回调处理器已完成装配。
*/
@Test
@DisplayName("支付回调处理器应存在")
void paymentCallbackShouldExist() {
assertNotNull(paymentCallback);
}
/**
* 验证支付回调处理器实现了约定接口。
*/
@Test
@DisplayName("支付回调处理器应实现指定接口")
void paymentCallbackShouldImplementHandler() {
assertInstanceOf(PaymentCallbackHandler.class, paymentCallback);
}
/**
* 验证支付回调方法接收标准事件对象。
*/
@Test
@DisplayName("支付回调处理方法应接收事件参数")
void paymentCallbackShouldAcceptEvent() throws NoSuchMethodException {
assertNotNull(paymentCallback.getClass().getMethod("handle", PaymentEvent.class));
}
类似用例写了很多,却没有一个断言"收到支付成功事件后,目标订单应变为已支付"。团队测到了函数存在、类型正确、代码被调用,唯独没有测业务结果。
覆盖率表示哪些语句、分支或函数在测试中执行过。它不能说明断言是否有意义,也不能说明最危险的场景是否被覆盖。事故暴露的不是覆盖率还不够高,而是指标和风险之间断了联系。
执行过,不等于验证正确
看一个价格函数:
java
/**
* 根据数量、单价和折扣率计算价格。
*/
BigDecimal calculatePrice(int quantity, BigDecimal unitPrice, BigDecimal discountRate) {
BigDecimal subtotal = unitPrice.multiply(BigDecimal.valueOf(quantity));
return discountRate.signum() > 0
? subtotal.multiply(BigDecimal.ONE.subtract(discountRate))
: subtotal;
}
下面两个测试可能跑到全部分支,也可能得到 100% 行覆盖率:
java
/**
* 验证有折扣时会执行价格计算。
*/
@Test
@DisplayName("有折扣时执行计算")
void shouldCalculatePriceWithDiscount() {
calculatePrice(2, new BigDecimal("100"), new BigDecimal("0.1"));
}
/**
* 验证无折扣时会执行价格计算。
*/
@Test
@DisplayName("无折扣时执行计算")
void shouldCalculatePriceWithoutDiscount() {
calculatePrice(2, new BigDecimal("100"), BigDecimal.ZERO);
}
把乘法误写成减法,这两个测试仍会通过。补上 assertEquals(0, calculatePrice(2, new BigDecimal("100"), new BigDecimal("0.1")).compareTo(new BigDecimal("180"))),测试才开始验证行为。
覆盖率仍然有用。它能发现从未走过的分支,帮助 Review 追问遗漏,也可以阻止一次提交让覆盖范围大幅倒退。但它更像扫描仪,不是质量结论。团队至少还要看断言质量、缺陷逃逸和关键风险清单;对高风险、纯逻辑密集的模块,可以抽查变异测试,验证断言是否真能杀死错误实现,不必全库常态运行。
测试金字塔是起点,不是配额表

经典测试金字塔常被画成:
text
单元 70% / 集成 20% / E2E 10%
这个比例适合说明"底层测试通常更快、更便宜,因此数量可以更多",不适合作为所有团队的统一目标。一个规则密集的计费库可能以单元测试为主;一个集成多个 SaaS 的工作流产品,需要更多契约和集成测试;一个设计系统可能投入大量视觉回归;遗留系统难以切开时,短期内更多端到端测试也可能是现实选择。
我会用四个问题决定组合,而不是先分配比例:
- 失败的业务损失有多大:金额、权限、数据完整性和合规风险排在展示瑕疵之前。
- 这块代码变更有多频繁:频繁修改需要更快、更稳定的反馈。
- 最早在哪一层能发现问题:能在纯函数验证的规则,不必等浏览器跑完。
- 真实环境的成本有多高:数据库、消息系统、第三方沙箱和浏览器都会增加时间与维护成本。
这里没有一种测试能单独覆盖所有风险:
| 类型 | 主要回答的问题 | 反馈与环境 | 常见盲区 |
|---|---|---|---|
| 单元测试 | 规则、计算、状态转换是否正确 | 最快,环境最少 | 发现不了装配和协议问题 |
| 集成测试 | 模块、数据库、队列能否按预期协作 | 较快,部分真实环境 | 覆盖不了完整用户旅程 |
| 契约测试 | 服务或第三方的请求响应约定是否一致 | 快于完整 E2E,可独立运行 | 不证明对方生产环境可用 |
| E2E | 真实入口到结果是否走通 | 慢,环境最接近用户 | 定位成本高,容易受环境波动影响 |
| 视觉回归 | 页面在目标视口是否出现非预期变化 | 依赖稳定截图环境 | 不验证业务语义 |
测试组合的目标不是形成漂亮的金字塔,而是让高损失风险至少有一条可靠、足够快的反馈路径。
回到支付回调:应该验证哪些风险
支付回调至少涉及签名验证、金额与订单匹配、状态转换、幂等和持久化。单元测试可以覆盖状态规则,集成测试用真实数据库验证写入和事务,契约测试校验事件结构,少量第三方沙箱测试确认签名与回调配置没有偏差。
下面这条集成测试比"函数存在"更接近事故本身:
java
/**
* 验证支付成功后的订单状态以及重复回调幂等性。
*/
@Test
@DisplayName("支付成功后更新对应订单且重复回调保持幂等")
void shouldUpdateOrderAndKeepCallbackIdempotent() {
OrderEntity order = orderFixture.create(20000L, OrderStatus.PENDING);
PaymentEvent event = paymentFixture.succeeded(
"evt_payment_001", order.getId(), 20000L);
paymentCallback.handle(event);
paymentCallback.handle(event);
OrderEntity savedOrder = orderRepository.findById(order.getId()).orElseThrow();
assertAll(
() -> assertEquals(OrderStatus.PAID, savedOrder.getStatus()),
() -> assertEquals(20000L, savedOrder.getPaidAmount()),
() -> assertEquals(1L, paymentRepository.countByEventId(event.eventId()))
);
}
这还不够证明支付链路万无一失。若生产配置把回调地址填错,代码测试无法发现;若支付平台改变签名字段,本地 mock 也可能继续通过。因此要在发布前用沙箱做少量真实验证,并监控生产回调积压、签名失败率和订单状态差异。
"少量"也不是固定次数。沙箱昂贵或不稳定时,用契约测试承担日常反馈,把真实验证放在定时任务和发布门禁;高风险变更则临时扩大验证范围。
哪些代码不必重复测试,哪些例外要保留
框架与第三方库
通常不需要重测 Spring MVC 如何匹配路由、ORM 如何实现基本 CRUD,也不需要穷举验证库的邮箱正则。但项目对框架的配置、路由装配、过滤器顺序和升级兼容性属于自己的风险,应该通过集成或冒烟测试验证。
第三方调用不能只靠 mock。mock 适合制造成功、超时、拒绝等分支;契约测试确保双方理解一致;沙箱或测试账号用于少量验证真实签名、权限和网络行为。三者回答的问题不同。
展示组件与视觉
"纯展示不用测"只在视觉变化损失低、人工检查稳定时成立。结算金额、医疗提示、响应式导航、品牌组件即使业务逻辑少,也可能因样式回归造成严重问题。此时视觉回归、无障碍扫描或组件快照有价值。
快照只有在差异可读、评审者会认真检查时才有效。大面积自动更新快照,只是把错误重新盖章。
E2E 的范围
"E2E 只测核心路径"是一条常用的成本控制建议,不是禁令。低频但高损失的退款、账号恢复、权限撤销、数据导出,也值得 E2E;某些跨浏览器交互只能在真实页面暴露。反过来,一个所谓核心流程如果在集成层已充分验证,而 E2E 环境极不稳定,可以保留少量冒烟路径,把细节放到更快的层级。
常见反模式,比数量更值得查
为门槛制造无效断言
给 getter、常量或函数存在性补测试,可能提升数字,却没有降低失败概率。覆盖率门槛应该触发讨论,不应成为奖励占位用例的 KPI。
mock 掉全部边界
如果 Repository、消息队列、JWT 和第三方全部按测试作者设想返回,测试验证的只是内部调用剧本。至少需要一层使用真实数据库或协议实现,校准 mock 与现实是否一致。
测试依赖执行顺序
共享 userId、固定时间或同一数据库记录,会把测试变成串行剧本。单个用例应能独立建立和清理数据;必须共享昂贵环境时,也要隔离命名空间和状态。
在不同层重复同一个断言
同一条折扣规则在单元、API、E2E 各穷举一遍,会同时放大维护成本。更合理的做法是:单元层覆盖规则边界,集成层验证关键装配,E2E 只验证跨系统旅程。
测试文件比实现长,不足以证明过度测试。复杂协议、状态机和属性测试本来可能需要更多用例。判断依据应是它是否覆盖独立风险、失败信息是否可定位、维护成本是否超过风险收益,而不是文件行数。
用风险选择测试的决策树
text
这次变更最可能造成什么损失?
|
+-- 规则算错、状态错、权限错
| +-- 先用单元测试覆盖边界和反例
|
+-- 数据、队列或模块装配错误
| +-- 用集成测试接入真实关键依赖
|
+-- 服务之间字段或语义不一致
| +-- 用契约测试;必要时补联合集成测试
|
+-- 用户跨页面、跨服务流程中断
| +-- E2E 是否是最早能发现它的层级?
| +-- 是 -> 添加对应旅程
| +-- 否 -> 放到更快、更稳定的层级
|
+-- 视觉、响应式或无障碍回归
| +-- 视觉回归 + 针对性交互检查
|
+-- 第三方真实行为与 mock 偏离
+-- 契约测试 + 少量沙箱验证 + 生产监控
对每个高风险场景,再确认四件事:失败时会不会误报或漏报;反馈能否在开发者还记得改动时返回;环境成本是否可持续;这个用例由谁维护。
覆盖率应该怎么用
MVP 阶段也不必追统一数字。先给支付、权限、金额和数据写入等高损失路径建立防线,并确保测试能在日常开发中运行。产品增长后,按缺陷和变更热点补集成、契约和 E2E。系统成熟后,可以设置覆盖率门槛防止明显倒退,但门槛要按模块风险解释,生成代码和不可达防御分支也应有合理排除策略。
我更愿意在 Review 里问下面这些问题:
- 这次变更可能造成的最大业务损失是什么?
- 哪个断言能直接证明关键行为正确?
- mock 与真实依赖由哪条测试校准?
- 失败能否快速定位到规则、协议、装配或环境?
- 这套测试在三个月后还有人愿意维护吗?
覆盖率是参考坐标,不是终点。真正有用的测试策略,是在反馈速度、环境真实性和维护成本之间做选择,并让选择对准业务风险。
下一篇讨论发布后的恢复能力:应用可以切回旧版本,配置和数据却需要各自的版本与处置策略。
上一篇:Controller 为什么会变胖:先拆职责,再谈分层
《工程化决策树》第 4 篇 · lytao123