超越覆盖率:测试用户实际遇到的故障情况
为何较高的代码覆盖率会掩盖未被测试的故障模式,它所催生的三种徒有其表的测试类型,以及如何编写能够保障实际行为的测试。
当所有测试都通过且代码覆盖率超过98%时,人们会认为系统是安全的。然而,一旦API返回了UI预期中的对象应该是的null值,或在移动网络延迟的情况下请求处理顺序出错,又或是有人误双击了提交按钮,前端就会立刻变成空白屏幕。事后分析时人们总会问同一个问题:既然代码覆盖率这么高,为什么还会出故障?本文将解释覆盖率究竟衡量的是什么,那些只会提高覆盖率却无法提供实际保护的测试模式,以及如何让测试套件更专注于那些真正重要的故障场景。
覆盖率衡量什么,又不能衡量什么
代码覆盖率工具仅记录测试运行时执行了哪些行、分支和函数,仅此而已。它无法判断是否有断言对结果进行了验证,输入数据是否类似于真实场景的数据,或者当依赖项出现异常时代码是否能正常运行。执行与验证是两个不同的概念,而覆盖率工具仅能报告前者。
一个恰当的类比是建筑检查:检查人员会用手电筒逐一查看每个房间。虽然所有房间都被检查过了,但没人会在暴风雨中测试屋顶的稳定性。高覆盖率只能说明测试覆盖了代码,却不能表明这些测试真正对代码进行了严格验证。
三种会提升覆盖率却无法增强安全性的模式
一旦将百分比设定为目标,人们就会致力于达成这一比例。结果就是人们会用最少的精力编写出能让工具满意的测试。总有三种模式反复出现。
理想化的美好幻象
想象这样一个辅助函数,它负责解析用户输入并更新状态。它的测试会使用格式正确、结构完好的字符串,检查预期输出,并实现完全的分支覆盖。但它从未尝试过处理空字符串、异常字符、undefined类型的参数、响应延迟或格式错误的JSON数据。虽然所有代码行都得到了执行,但那些可能导致实际问题出现的输入却从未被测试过。
被过度模拟的组件
在这里,每一个 API 调用、上下文提供者以及嵌套子组件都会被替换为模拟对象。整个测试流程在几毫秒内完成,且能覆盖所有渲染路径。但在实际生产环境中,真实接口返回的数据结构可能与模拟对象假设的略有不同,或者库在升级后会改变事件触发方式。由于这些测试仅基于开发者自身的假设,因此依然能够通过,而真正的集成测试则是在用户的浏览器中进行的。
缺乏有效断言的测试
最糟糕的情况出现在有严格要求的场景下,比如要求整个团队测试通过率必须达到 90%。这类测试仅仅调用函数来记录代码执行行数,几乎不进行任何断言。尽管没有任何检查机制隔在代码与未来的逻辑变更之间,测试报告仍会显示为通过状态。
如何测试行为而非代码行数
那些能在用户发现之前捕获错误的测试,关注的是系统的功能表现,而非它涉及了哪些代码行。
- 测试状态与转换,而非单个函数。 用户并不关心某个辅助函数是否被执行。他们关心的是在提交表单过程中网络突然中断,或是在文件上传仍在进行时试图离开页面时会发生什么。需要测试加载指示器、错误状态、重试机制以及错误处理边界。
- 在可行情况下优先采用集成测试。 对真正的外部系统,如第三方支付网关进行模拟是合理的做法。而对自己内部的辅助函数或数据层进行模拟,往往只能掩盖它们之间的缺陷。应在成本允许的范围内让各个组件和模块共同运行。
一种相关且成熟的技术是变异测试,它通过刻意修改代码(如改变条件、删除某行代码)来检查是否有测试会失败。那些仍然通过测试的变异直接指向了那些被执行但未被验证的代码,而这正是覆盖率无法显示的内容。关于组件层面的具体内容,请参阅这些会带来错误信任感的 React 测试反模式。
将覆盖率视为信号而非目标
代码覆盖率并非毫无用处。关键模块的较低覆盖率确实是一个警示信号,而测试报告还能揭示那些根本无人测试过的代码路径。问题在于当人们将覆盖率视为唯一目标时,就会更注重测试数量而非测试质量。
如果选择覆盖率约为65%的精简测试套件,并将重点放在高风险工作流程、复杂的业务规则以及故障恢复机制上,其预防事故的效果远胜于那种仅基于正常流程和模拟数据构建、覆盖率高达95%却脆弱不堪的测试套件。在添加新测试之前,与其问“哪些代码行未被覆盖?”,不如问问“这种测试能否捕捉到哪种真实的故障?”
核心要点
- 代码覆盖率仅能显示哪些代码被执行过,无法表明其行为是否经过验证。
- 基于正常流程的输入数据、大量的内部模拟以及不包含断言的测试,虽然会提升覆盖率,却无法降低风险。
相关阅读
- 超越包大小限制:探究究竟是什么让您的网页应用变慢 —— 为何减少几千字节的数据几乎无法解决应用速度缓慢的问题,以及如何追踪服务器、数据流处理、资源加载、第三方脚本和图片所带来的实际等待时间。
- 超越P95:衡量用户实际体验的延迟 — 为何良好的P95数值仍可能与缓慢的产品共存,队列时间和数据扩散如何在监控面板中被隐藏,以及逐步骤计时如何终结对延迟责任的推诿。