90% 覆盖率不等于没 Bug:用风险配置测试组合

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 的工作流产品,需要更多契约和集成测试;一个设计系统可能投入大量视觉回归;遗留系统难以切开时,短期内更多端到端测试也可能是现实选择。

我会用四个问题决定组合,而不是先分配比例:

  1. 失败的业务损失有多大:金额、权限、数据完整性和合规风险排在展示瑕疵之前。
  2. 这块代码变更有多频繁:频繁修改需要更快、更稳定的反馈。
  3. 最早在哪一层能发现问题:能在纯函数验证的规则,不必等浏览器跑完。
  4. 真实环境的成本有多高:数据库、消息系统、第三方沙箱和浏览器都会增加时间与维护成本。

这里没有一种测试能单独覆盖所有风险:

类型 主要回答的问题 反馈与环境 常见盲区
单元测试 规则、计算、状态转换是否正确 最快,环境最少 发现不了装配和协议问题
集成测试 模块、数据库、队列能否按预期协作 较快,部分真实环境 覆盖不了完整用户旅程
契约测试 服务或第三方的请求响应约定是否一致 快于完整 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 里问下面这些问题:

  1. 这次变更可能造成的最大业务损失是什么?
  2. 哪个断言能直接证明关键行为正确?
  3. mock 与真实依赖由哪条测试校准?
  4. 失败能否快速定位到规则、协议、装配或环境?
  5. 这套测试在三个月后还有人愿意维护吗?

覆盖率是参考坐标,不是终点。真正有用的测试策略,是在反馈速度、环境真实性和维护成本之间做选择,并让选择对准业务风险。


下一篇讨论发布后的恢复能力:应用可以切回旧版本,配置和数据却需要各自的版本与处置策略。

上一篇:Controller 为什么会变胖:先拆职责,再谈分层

下一篇:能部署不算本事,能恢复才算:把恢复能力做进发布流程


《工程化决策树》第 4 篇 · lytao123

相关推荐
mayaairi1 小时前
Vue2 组件通讯(一):Props传值完全指南
前端·javascript·vue.js
码艺-Alimjan1 小时前
Tauri 2.x + Vue 3 桌面应用开发实战:从踩坑到完美落地
前端·javascript·vue.js·rust·typescript·go
qq_570398571 小时前
Three.js基础使用-案例
开发语言·javascript·ecmascript
AINative软件工程1 小时前
编程
前端·llm
晴天161 小时前
peerDependencies 全面解析:前端依赖生态的核心机制与实战指南
前端·npm
I'mChloe1 小时前
群晖部署 image-matting:把人像去背景做成一个随开随用的 Web 工具
android·前端
程序员蜡笔熊1 小时前
Vite 8 换芯实测:Rolldown 替掉双引擎,构建快 3.19 倍
前端·javascript·vue
右耳朵猫AI1 小时前
Web前端周刊2026W36 | pnpm 12 Rust 重写、Remix 3 RC、Node.js 26.8.0、htmx 4.0 大版本
前端·rust·node.js
2601_962077531 小时前
月薪7092?软件工程跌下神坛,背后三个残酷真相
软件工程·ai替代·就业形势·产业转型·细分领域