NoBug World NoBug World

单元测试的真正目标:从覆盖率到可持续增长

这篇文章提炼自《Unit Testing: Principles, Practices, and Patterns》的英文原文内容。它不是逐章摘要,而是把全书压缩成一套可以直接用于判断和改进测试质量的思维框架。

术语约定

为了避免概念混乱,本文统一采用以下译法。

英文术语统一译法说明
unit test单元测试验证一个行为单元、执行快速、与其他测试隔离的自动化测试
system under test, SUT被测系统当前测试直接验证的对象或行为入口
test suite测试套件一组自动化测试的集合
test double测试替身测试中替代真实依赖的非生产对象
mockMock用来模拟并检查传出交互的测试替身
stubStub用来提供输入数据的测试替身
spySpy手写 Mock
fakeFake可工作的简化实现,归入 Stub 类用途
dummyDummy只为满足参数要求而传入的对象,归入 Stub 类用途
observable behavior可观察行为客户端或外部系统真正依赖的结果、状态或副作用
implementation detail实现细节达成结果的内部步骤,不应被测试绑定
protection against regressions防止回归测试发现已有功能被破坏的能力
resistance to refactoring抵御重构测试不因正确重构而误报失败的能力
fast feedback快速反馈测试执行并反馈结果的速度
maintainability可维护性测试是否易读、易维护、易运行
false positive假阳性功能没坏但测试失败
false negative假阴性功能坏了但测试没发现
out-of-process dependency进程外依赖数据库、文件系统、消息总线、SMTP 服务等进程外资源
managed dependency受管理依赖只能通过本应用访问的进程外依赖,例如私有数据库
unmanaged dependency非受管理依赖其他应用也可观察或访问的进程外依赖,例如消息总线
output-based testing基于输出的测试给输入、检查返回值
state-based testing基于状态的测试执行操作后检查系统状态
communication-based testing基于交互的测试使用 Mock 检查对象之间的交互
functional core, mutable shell函数式核心、可变外壳核心做决策,外壳处理输入输出和副作用
Humble Object谦逊对象把复杂业务逻辑从难测的外壳中提取出来

一句话主线

单元测试的目标不是写更多测试,也不是追求更高覆盖率,而是让软件项目能够长期、稳定、可重构地增长。

好测试应该像可靠的安全网:当你引入回归时,它能及时报警;当你只是重构实现时,它保持安静。如果测试经常误报,团队会逐渐不信任测试套件,最终要么忽略失败,要么禁用测试。这样的测试不但没有降低维护成本,反而会制造额外负担。

因此,测试代码和生产代码一样都是负债。每个测试都必须证明自己的净价值:它带来的回归保护,要大于编写、阅读、运行和维护它的成本。

1. 覆盖率只能说明“不够”,不能说明“够好”

覆盖率是一个好的负面指标:覆盖率很低,通常说明测试套件有明显缺口。但覆盖率不是好的正面指标:覆盖率很高,并不代表测试真的有价值。

原因很简单:覆盖率只知道代码被执行过,却不知道测试有没有验证正确结果。一个没有有效断言的测试也能提高覆盖率;一个绑定实现细节的测试也能提高覆盖率;一个频繁误报的测试也能提高覆盖率。覆盖率无法衡量测试是否理解了业务行为,也无法衡量测试是否能承受重构。

更危险的是,把某个覆盖率数字设为硬性目标,会制造错误激励。团队为了满足数字,可能给低价值代码补大量脆弱测试,却忽略真正重要的领域逻辑。正确做法是:在核心业务区域保持较高覆盖,但不要把覆盖率当成质量本身。

2. 单元测试验证的是行为单元,不是代码单元

书中采用经典学派的定义:单元测试验证一个行为单元,执行快速,并与其他测试隔离。

这里最容易误解的是“单元”。单元不是一个类、一个方法或一段代码,而是一个对业务有意义的行为。一个行为单元可能只落在一个方法里,也可能横跨多个类。只要测试讲述的是一个清晰的业务场景,它就是好的候选单元。

这也是经典学派和伦敦学派的关键分歧。伦敦学派倾向于把类当作单元,并用测试替身隔离类的合作者;经典学派认为需要隔离的是测试之间的执行环境,而不是被测代码内部的所有对象。经典学派只替换共享依赖,因为共享依赖会让测试之间相互影响。

伦敦学派的好处是测试粒度更小、依赖图更容易搭建、失败定位看起来更直接。但它的代价也很高:大量 Mock 会让测试绑定到实现细节,形成过度指定。测试不再关心“系统产生了什么结果”,而是关心“系统通过哪些内部步骤产生结果”。一旦内部结构调整,功能没坏,测试却失败。

3. 好测试的四个属性

全书最重要的判断框架,是用四个属性评估测试价值。

第一,防止回归。测试执行的代码越多、覆盖的业务风险越高,发现回归的概率越大。端到端测试通常在这一点上很强,因为它穿过了更多真实组件。

第二,抵御重构。测试应该验证可观察行为,而不是实现细节。如果一次不改变行为的重构导致测试失败,那就是假阳性。抵御重构几乎是非黑即白的属性:一个测试要么绑定行为,要么绑定实现。它也是最不可妥协的属性,因为误报会摧毁团队对测试套件的信任。

第三,快速反馈。测试越快,开发者越愿意频繁运行它,越早发现问题。单元测试在这一点上通常优于集成测试和端到端测试。

第四,可维护性。测试必须容易理解,也必须容易运行。测试越长、准备数据越复杂、依赖的外部资源越多,维护成本就越高。

这四个属性不是简单相加,而更像相乘:任何一个属性接近零,测试的整体价值都会接近零。一个非常慢、非常脆弱或完全看不懂的测试,即使能覆盖大量代码,也很难算作高价值测试。

4. 假阳性、假阴性与测试准确性

假阳性是假警报:功能本身仍然正确,但测试失败。它通常发生在重构时:你改变了实现方式,却没有改变可观察行为,测试仍然变红。假阴性则相反:功能已经坏了,但测试没有发现。

从测试准确性的角度看,防止回归和抵御重构是一对互补指标。防止回归减少假阴性,让测试更擅长指出“这里真的有 bug”;抵御重构减少假阳性,让测试更擅长确认“这里没有 bug”。一个准确的测试套件,既要能发现真实错误,也要尽量少制造错误警报。

可以把这件事理解成信噪比。真实 bug 是信号,假阳性是噪音。提升测试准确性有两条路:增加信号,也就是让测试更能发现回归;减少噪音,也就是让测试更少误报。只提高信号不够。如果测试能发现很多 bug,但同时制造大量无关失败,开发者会在噪音里丢掉真正有价值的信号。

假阳性对测试套件的破坏有一个渐进过程。项目早期,假阳性看起来没那么严重,因为代码新、规模小、重构成本低,开发者还能凭记忆判断测试是不是误报。项目变大后,情况会反过来:代码需要持续清理,遗留逻辑需要拆分,团队成员也不可能记住所有上下文。此时每一次误报都会削弱团队的重构意愿。

更糟的是,频繁假阳性会破坏团队对测试套件的信任。刚开始,开发者会认真调查每个失败;后来发现大部分失败都是误报,就会逐渐习惯失败;再后来,真正的失败也会和误报一起被忽略。测试套件从“安全网”退化成“噪音源”。当真正的回归出现时,测试可能已经报警了,但团队已经不再听它。

这也是为什么抵御重构是不可妥协的属性。防止回归和快速反馈可以做程度上的取舍:多跑一些真实组件,回归保护更强但速度更慢;少跑一些组件,速度更快但保护更弱。但抵御重构很难只让出一点点。测试一旦绑定实现细节,就会在正确重构时失败;这种失败不是“稍微差一点”,而是直接把测试拖入脆弱测试的范畴。

假阳性的根因通常不是测试数量太多,而是测试结构错了。测试越关心被测系统如何完成工作,越容易误报;测试越关心被测系统最终交付什么可观察结果,越能抵御重构。好的测试应该像一个领域故事:当它失败时,失败本身就说明业务结果和预期故事发生了偏离。除此之外的失败,大多只是把注意力从真正问题上移开的噪音。

原文中的 MessageRenderer 示例很好地说明了这一点。检查它内部是否按顺序使用 HeaderRenderer、BodyRenderer、FooterRenderer,看起来像是在测试逻辑,实际上是在锁死实现。只要换一种内部组合方式,即使最终 HTML 不变,测试也会失败。更好的测试是直接验证最终渲染出的 HTML,因为这才是客户端真正关心的可观察行为。

因此,遇到测试失败时,不应只问“生产代码哪里坏了”,还要问“这个测试有没有资格报警”。如果失败无法追溯到业务需求、用户可见结果或外部可观察契约,它很可能是脆弱测试。这样的测试应该被重写、上移到可观察行为,或者删除。

5. 测试金字塔的本质是取舍

不存在四项满分的理想测试。防止回归、抵御重构、快速反馈之间存在天然取舍。

端到端测试防止回归强,但反馈慢、维护成本高。简单测试反馈快,但如果只覆盖微不足道的代码,回归保护很弱。脆弱测试可能执行大量代码,却因为绑定实现细节而缺乏抵御重构能力。

测试金字塔的意义,就是把不同测试放在合适位置:大量单元测试覆盖领域模型和算法的边界情况;少量集成测试覆盖跨组件协作和主要成功路径;更少的端到端测试从用户视角验证关键流程。

不过金字塔形状不是教条。复杂业务系统通常需要大量单元测试,因为领域模型值得细测;简单 CRUD 系统领域逻辑很薄,单元测试数量可能不多,集成测试反而更有价值。测试策略要跟代码的复杂度和业务重要性匹配。

6. AAA、命名和测试可读性

单个测试应遵循 AAA 模式:准备、执行、断言。

如果一个测试有多个执行阶段,通常说明它验证了多个行为单元,应拆分。尤其是单元测试,不应该出现多次执行。多阶段测试只有在处理难以准备状态的进程外依赖时才可能合理,这类测试往往更接近端到端测试。

执行阶段超过一行,也常常暴露出被测系统的 API 设计问题。如果客户端必须记住一组调用必须按顺序一起执行,代码就容易出现不变量破坏。更好的设计是通过封装让一次操作表达完整意图。

测试命名不应机械套模板。好名字应该像在向熟悉业务的非程序员描述场景:什么情况下,期望出现什么结果。不要把被测方法名塞进测试名,因为测试应面向行为,而不是面向实现入口。

7. Mock 和 Stub 的边界

测试替身分为很多名字:Dummy、Stub、Spy、Mock、Fake。但从使用目的看,核心只有两类:Mock 和 Stub。

Mock 用于传出交互:被测系统调用依赖,并改变外部可观察状态。例如发送消息、调用支付网关、写入其他系统可见的日志。Mock 不只是提供数据,还要检查交互是否发生。

Stub 用于传入交互:被测系统从依赖获取输入数据。例如读取配置、查询只读数据、返回固定结果。Stub 是为了让被测系统能继续运行。

关键规则是:不要断言与 Stub 的交互。被测系统向 Stub 查询了几次、按什么顺序查询,通常是实现细节。断言这些细节会让测试脆弱。测试应该断言最终行为,而不是断言走到最终行为的中间步骤。

命令查询分离原则能帮助判断:命令会产生副作用,对应 Mock;查询返回数据,对应 Stub。混合命令和查询的方法会让测试意图变得混乱,也会让设计更难推理。

8. 只测试可观察行为

测试脆弱性的根源,是测试绑定了实现细节。要避免它,必须区分两个维度:公开 API 与私有 API,可观察行为与实现细节。

设计良好的代码,公开 API 应该基本等于可观察行为,私有 API 应该隐藏实现细节。如果公开 API 暴露了不该暴露的中间步骤,客户端就能绕过对象的不变量,封装就被破坏。测试也会被诱导去验证这些中间步骤。

同样,系统内通信通常是实现细节。领域对象之间如何协作,只要没有形成外部可观察副作用,就不应通过 Mock 断言。系统间通信才可能是可观察行为,但也要看外部世界是否真的能观察到该副作用。

因此,Mock 的合法使用范围很窄:只用于跨越应用边界、且副作用对外部世界可见的通信。把 Mock 用在领域对象之间,通常是在测试实现细节。

9. 三种测试风格的优先级

基于输出的测试质量最高。它输入数据、检查返回值,没有隐藏输入和隐藏输出,最不容易绑定实现细节,也最短小可读。

基于状态的测试次之。它执行操作后检查系统状态,适合有状态对象和领域模型。但要小心:不要为了测试而暴露原本私有的状态。否则测试质量提升是假的,封装损失是真的。

基于交互的测试最需要克制。它通过 Mock 检查通信,容易变长,也容易绑定内部协作方式。只有当交互本身是外部可观察行为时,才值得这样测。

函数式架构把这个原则推到极致:让函数式核心只做决策,让可变外壳负责读取输入、提交副作用。这样核心逻辑可以大量使用基于输出的测试。但这不是免费午餐,它可能牺牲性能,并带来初始设计成本。越复杂、越重要的业务,越值得采用这种分离;简单系统不必强行函数式化。

10. 让代码变得值得测试

不是所有代码都值得同等测试。可以从两个维度判断:复杂度或领域重要性,以及合作者数量。

领域模型和算法通常复杂或重要,而且合作者少,是单元测试投资回报最高的区域。平凡代码既不复杂也不重要,不值得测试。控制器本身通常不复杂,但合作者多,适合用少量集成测试覆盖。最麻烦的是过度复杂代码:既有复杂业务逻辑,又协调大量依赖。它测试成本高、设计也往往有问题。

改进方向是拆分:让复杂代码变深而不变宽,让协调代码变宽而不变深。换句话说,业务逻辑应该复杂但少依赖;控制器可以协调多个依赖,但自身不应承载复杂决策。

谦逊对象模式正是这个思想:把难测外壳里的业务决策提取到独立对象中,让外壳只负责输入输出和编排。六边形架构和函数式架构都在不同程度上应用了这个模式。

11. 集成测试应该测什么

集成测试是任何不满足单元测试定义的测试。它通常验证系统与进程外依赖的协作。

受管理依赖应该尽量使用真实实例,例如应用独占的数据库。因为它的最终状态只通过本应用可见,与它的交互属于实现细节。集成测试应穿过所有相关层,最后独立检查数据库状态。

非受管理依赖应该用 Mock 替代,例如消息总线、SMTP 服务、其他系统也会读取的外部通道。因为这些交互本身会被外部系统观察到,属于应用的可观察行为。

有些依赖混合两种属性,例如一个数据库同时被多个应用访问。此时要拆开处理:外部可观察的部分按非受管理依赖处理,用 Mock 验证;其余部分按受管理依赖处理,检查最终状态而不是交互。

集成测试的标准要比单元测试更高。因为它更慢、更难维护,所以必须提供更强的防止回归能力和抵御重构能力。一般策略是:单元测试覆盖业务边界情况;集成测试覆盖主要成功路径,以及无法用单元测试覆盖的重要边界情况。

12. Mock 的实践规则

Mock 应尽量放在系统边缘。一次对非受管理依赖的调用,在离开应用之前通常会经过多层封装。测试应 Mock 最靠近外部依赖的那一层,而不是 Mock 中间层。这样可以让集成测试执行更多真实代码,提高防止回归能力,同时减少对内部实现的绑定。

在系统边缘,Spy 往往比框架生成的 Mock 更好。Spy 是手写 Mock,可以把断言逻辑封装成领域化方法,减少测试体积,提高可读性。名称上不必纠结,团队不熟悉 Spy 时,把它命名为某某 Mock 也可以;关键是它独立检查了发往外部系统的消息结构。

Mock 还应遵循几条规则:

  1. 只在集成测试中使用 Mock,不在单元测试中使用 Mock。
  2. Mock 的数量不重要,重要的是参与该行为的非受管理依赖有多少。
  3. 既要验证预期调用存在,也要验证非预期调用不存在。
  4. 只 Mock 自己拥有的类型。第三方库应包一层适配器,测试 Mock 适配器,而不是直接 Mock 第三方接口。

这些规则背后的统一原则是:Mock 的目标不是隔离所有依赖,而是保护你和外部系统之间的可观察契约。

13. 数据库测试的实践重点

数据库测试很有价值,尤其能保护数据库重构、ORM 替换、数据库厂商切换等高风险变更。但它也很容易变慢、变脆、变难维护。

首先,数据库模式要像代码一样进入版本控制。表、视图、索引、存储过程、参考数据都属于数据库模式。参考数据是应用运行必须预置、且应用本身不修改的数据;普通数据则是应用会修改的数据。

其次,每个开发者应使用独立数据库实例,最好在本机运行,以减少相互干扰并提高速度。数据库交付更推荐基于迁移的方式,因为迁移显式表达从一个状态到另一个状态的变化,尤其有利于处理数据迁移。

业务操作应依赖数据库事务保证原子性。测试中不要在准备、执行、断言三个阶段复用同一个事务或工作单元;每个阶段应有自己的事务边界,避免测试观察到生产中不会出现的状态。

测试数据清理应放在测试开始时,而不是结束时。开始时清理更稳定:即使上一次测试中断,也不会把脏数据留给下一次运行。

不要用内存数据库假装测试真实数据库。如果生产使用某个数据库管理系统,测试也应使用同类数据库。不同数据库在 SQL 行为、约束、事务、索引和 ORM 适配上都可能不同,替代品会削弱回归保护。

读操作测试门槛应高于写操作。复杂或关键读操作值得测,简单查询通常不值得单独测。仓储也不应直接作为测试目标;它应作为整体集成测试路径的一部分被覆盖。

14. 常见反模式

不要为了测试私有方法而把它公开。私有方法应通过整体可观察行为间接测试。如果私有方法太复杂,说明可能缺少抽象,应把复杂逻辑提取到独立类中。如果代码不可达,那更可能是死代码,应删除。

不要为了测试暴露私有状态。测试应该像生产代码一样使用被测系统,不应拥有特殊权限。为了测试而扩大公开 API,会把实现细节变成长期维护负担。

不要把领域知识泄露到测试中。测试应从黑盒视角验证行为,而不是复刻生产代码的内部算法。否则测试和生产代码可能同时犯同一个错误,形成没有验证价值的同义反复。

不要污染生产代码。只为测试而存在的生产代码,会混合测试职责和业务职责,增加维护成本。

不要 Mock 具体类来保留它的一部分真实行为。出现这种需求,往往说明类违反了单一责任原则。更好的做法是拆成两个类:一个承载领域逻辑,一个负责进程外通信。

处理当前时间时,也不要使用全局环境上下文污染代码。优先把时间作为显式依赖传入;能传普通值时,不必传服务对象。

15. 一套可执行的判断清单

写测试或重构测试时,可以按以下顺序检查。

  1. 这个测试验证的是一个业务行为,还是一个类或方法的内部步骤?
  2. 如果只重构实现、不改变行为,这个测试会失败吗?
  3. 测试失败时,它提供的是强信号,还是容易被忽略的噪音?
  4. 这个测试覆盖的代码是否复杂、重要,或处在高风险边界?
  5. 它是否为了测试而暴露私有方法、私有状态或额外接口?
  6. 如果使用 Mock,它验证的是非受管理依赖的外部可观察交互吗?
  7. 如果使用 Stub,测试有没有断言与 Stub 的交互细节?
  8. 准备、执行、断言是否清晰?执行阶段是否只有一个动作?
  9. 测试名是否描述了业务场景,而不是描述实现入口?
  10. 这个测试的维护成本是否低于它提供的回归保护?

结论

单元测试的核心不是“隔离一切”,也不是“覆盖一切”,而是持续识别哪些测试真正支持软件的长期演进。

高价值测试通常具备三个特征:它们面向可观察行为,而不是实现细节;它们集中覆盖复杂且重要的业务逻辑;它们把 Mock 限制在真正跨越应用边界、且外部可观察的交互上。

当测试套件遵守这些原则时,它会鼓励重构,而不是阻碍重构;它会帮助团队保持开发速度,而不是用脆弱断言拖慢项目。这才是单元测试真正要解决的问题。