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

相关推荐
miss4 小时前
AI 改完不敢上线?OpenSpec 规约驱动开发实战
前端·代码规范·前端工程化
AiMan5 小时前
腾讯ima还是不可以导出左侧所有对话,只能单条导出,方法不对吗
前端
去伪存真5 小时前
并发量一高就装死?Agent 全链路“防猝死”手册
前端·agent
Moment5 小时前
AI 编程时代,聊聊我的 Mac mini 双屏桌面
前端·后端
AiMan5 小时前
先确认能不能一次把Microsofat Copilot所有内容打包一个文件导出
前端
总要学点什么5 小时前
第97篇:三维数据加载-城市建筑白模自定义着色器——让一条光带沿高度扫上去,快慢却由帧率说了算
前端·gis·webgl·cesium·着色器·空间分析·白模
上海懒图科技6 小时前
工厂厂区电子地图定位导航:厂区人车精准定位,安全合规双保障
前端·arcgis·vue
西安小哥6 小时前
端侧跑大模型,真正的门槛不在参数量:一次 596 秒到 10 秒的压缩路径复盘
前端·人工智能
陈随易6 小时前
Cloudflare 出钱,Vite 团队挑人,44 个维护者分 30 万美元
前端·后端·程序员
恋猫de小郭7 小时前
Flutter THLC 的 WebView 滚动终于从 70 FPS 提升到 120 FPS
android·前端·flutter