单元测试
很多关于单元测试的介绍,会从定义开始:什么是单元,什么是隔离,什么是自动化测试。这些定义没有错,但对日常工程实践的帮助有限。真正困扰开发者的问题通常不是“单元测试是什么”,而是:哪些代码值得写测试,测试应该怎样表达业务意图,Mock 应该用到什么程度,以及测试怎样才能在重构时提供信心,而不是反过来成为负担。
单元测试的价值,也不在于证明系统没有缺陷。任何测试都只能覆盖有限场景,不可能穷尽所有输入和运行环境。它更像一套低成本的反馈机制:当我们修改代码、修复缺陷、整理结构、合并分支时,能够尽早知道某条重要规则是否被破坏。
很多系统在早期没有单元测试,也可以正常开发和上线。问题通常出现在维护期。功能越来越多,人员不断变化,最初的设计意图逐渐丢失,每一次修改都要依赖开发者的记忆、人工回归和少量运气。团队并不是不知道有些代码应该重构,而是不敢重构;也不是看不出边界混乱,而是缺少足够快、足够稳定的反馈来支撑整理。单元测试保护的不是某一次发布,而是系统持续演进的能力。
它还有一个经常被低估的作用:检验设计。一个业务规则如果必须启动完整 Spring 容器、连接数据库、准备消息队列、构造大量无关对象之后才能验证,往往说明规则本身已经和基础设施耦合在了一起。难以测试的代码,通常也难以理解、难以复用、难以维护。
从业务规则推导测试用例
以我参与过的 GPU 算力平台为例。对用户来说,平台提供的是一种可租赁的算力资源:用户选择 GPU 规格、镜像或模板,提交创建请求,随后获得一个可以运行任务的 GPU Pod。对系统来说,这个动作背后包含多项业务判断和基础设施协作:指定规格是否还有库存,租户余额是否充足,Pod 记录如何初始化,创建成功后是否需要发布事件交给后续流程处理。
这类场景很适合说明单元测试的写法。它不是简单的数据搬运,也不是单纯的框架调用;它有业务规则,有外部依赖,有状态变化,也可能产生副作用。测试如果写得不好,很容易变成对实现过程的复述。
常见的写法是从方法出发:看到 createGpuPod(),就写一个 testCreateGpuPod(),然后在一个测试里准备库存、余额、模板、租户、Pod 记录和事件发布,再一次性断言很多结果。这样的测试看起来覆盖了主流程,但它并没有清楚表达任何一条业务规则。测试失败时,读者仍然要回到实现里判断到底是余额判断坏了、库存判断坏了、状态初始化坏了,还是事件发布出了问题。
更合适的起点,是先问这段代码对外承诺了哪些行为。站在业务规则的角度,创建 GPU Pod 至少可以拆出这些场景:余额不足时,创建请求应该被拒绝;库存不足时,创建请求应该被拒绝;余额和库存都满足时,系统应该创建出一个处于初始状态的 Pod;创建失败时,不应该留下创建记录,也不应该发布创建成功事件。
这些场景才应该成为测试用例。测试名也应该直接表达被验证的业务语义,而不是重复方法名:
@Test
void shouldRejectGpuPodCreationWhenTenantBalanceIsInsufficient() {
CreateGpuPodCommand command = new CreateGpuPodCommand(TENANT_ID, SPEC_ID, IMAGE_ID);
given(stockService.getAvailableStock(SPEC_ID)).willReturn(stockWithAvailableCount(1));
given(balanceService.hasEnoughBalance(TENANT_ID, SPEC_ID)).willReturn(false);
assertThatThrownBy(() -> gpuPodService.create(command))
.isInstanceOf(BalanceInsufficientException.class);
then(podRepository).should(never()).save(any());
then(eventPublisher).shouldHaveNoInteractions();
}
这段测试中,库存返回有余量只是当前场景的前置条件,真正要验证的是余额不足时创建会被拒绝,并且不会产生后续副作用。测试并不关心 getAvailableStock() 被调用了几次,也不关心实现内部先检查库存还是先检查余额。只要业务结果清楚,内部流程就应该保留重构空间。
这就是 Arrange、Act、Assert 三段式的价值。准备阶段只放当前场景需要的条件;执行阶段只触发一个核心动作;断言阶段只观察和这条规则有关的结果。一个测试可以有多个断言,但它们应该共同服务于同一个业务结论。比如余额不足时抛出异常、不保存 Pod、不发布创建成功事件,本质上都在说明“请求被拒绝且没有副作用”;但库存不足、创建成功、状态流转就应该拆成其他测试。
按照这种方式写测试,测试代码会自然围绕业务决策、金额或容量计算、状态流转、边界条件、错误路径、历史缺陷来组织。简单 getter/setter、ORM 基础 CRUD、框架注解是否生效,通常不应该成为单元测试的重点。单元测试应该优先保护那些容易被改坏、又值得长期维护的业务行为。
Mock 只应该隔离边界
Mock 的问题不在工具本身,而在使用尺度。很多测试一开始就把所有依赖都替换成 Mock,只要被测类调用了别的对象,就配置返回值、验证调用次数和调用顺序。这样写出的测试很容易和实现路径绑定。业务结果没有变化,只是拆了类、换了调用顺序,测试就开始失败。
在 GPU Pod 创建场景中,库存服务和余额服务提供的是外部事实:某个规格是否还有资源,某个租户是否有足够余额。它们往往依赖数据库、计费系统或资源管理系统,不适合在单元测试中真实访问。此时可以用测试替身准备返回值,让被测逻辑运行在稳定、快速、可重复的环境中。
但这类依赖更像 Stub,而不是需要重点验证的 Mock。对于查询型依赖,测试通常只关心它返回什么,不关心它被调用了几次。余额不足时创建被拒绝,才是当前测试的业务结论;hasEnoughBalance() 是否刚好调用一次,通常只是实现细节。
另一类依赖代表对外命令,例如保存 Pod、发布 GpuPodCreated 事件、发送通知、提交扣费请求。它们是否发生,可能就是业务行为的一部分。创建成功时,可以验证事件是否发出、事件内容是否正确;创建失败时,也可以验证没有保存记录、没有发布成功事件。这里验证的重点不是“调用了某个方法”,而是系统是否产生了对外可见的副作用。
还有一些对象只是普通协作者,例如规格校验器、价格计算策略、状态流转规则。只要它们没有进程外依赖,也不会产生外部副作用,通常应该使用真实对象。真实协作比复杂的 Mock 更可信,也更能保护重构后的行为一致性。
一个简单的判断标准是:越靠近数据库、消息队列、第三方接口、系统时间、随机数等边界的依赖,越适合通过接口隔离;越靠近领域规则和纯业务计算的对象,越应该保留真实实现。Mock 是为了把不稳定的边界挡在测试之外,而不是把业务模型拆成一堆互相模拟的空壳。
保持测试的可信度
测试一旦失去可信度,就很难再发挥作用。最常见的原因,是测试过度关注私有实现。为了覆盖某个分支去反射调用私有方法,或者验证某个内部方法一定被调用,短期看似精确,长期却会把测试和实现绑在一起。实现一旦被重构,即使外部行为完全不变,测试也会失败。这样的失败并不说明业务被破坏,只是在惩罚代码整理。
另一个问题,是把多个业务行为塞进一个大测试。测试先构造复杂对象,再执行一长串操作,最后断言多个互不相关的结果。它失败时很难定位问题,阅读时也看不出真正想保护的规则。单元测试应该尽量让失败信号明确:一个测试对应一个主要场景,一个失败对应一条清晰的业务含义。
覆盖率也容易制造误导。覆盖率低通常意味着风险高,因为大量代码从未被测试执行;但覆盖率高并不等于测试有效。没有断言的测试、只验证对象不为空的测试、为了跑过某一行代码而写的测试,都能提高覆盖率,却不能提高信心。覆盖率适合用来发现盲区,不适合用来证明质量。
测试代码过度抽象,同样会伤害维护性。生产代码里抽象是为了控制复杂度;测试代码里如果把关键输入藏进多层 builder、fixture 和 helper,读者就必须不断跳转才能理解场景。测试可以有公共构造方法,但这些方法不应该隐藏当前场景真正重要的条件。很多时候,测试里保留几行明确的重复,比抽出一个含义模糊的 prepareSuccessCase() 更容易理解。
测试还必须尽量排除不稳定因素。当前时间、随机数、线程 sleep、真实网络、共享数据库,都会带来偶发失败。偶发失败最伤害测试套件的信誉:一旦团队习惯了“这条测试偶尔会失败”,真正的回归也容易被忽略。时间、随机数和外部依赖应该通过接口注入或测试替身控制,让每次运行面对同一个确定场景。
让测试参与设计
对新代码来说,单元测试最好和设计一起发生。先把业务规则拆清楚,再让测试推动接口变得可调用、依赖变得可替换、状态变化变得可观察。如果测试很难写,不要急着寻找更复杂的 Mock 技巧,应该先看业务逻辑是不是放错了位置,规则是不是被数据库、框架或远程调用包裹得太深。
对遗留代码来说,不必一开始追求全面覆盖。更稳妥的方式,是从正在修改的地方开始补测试。修复缺陷前,先写一个能复现问题的回归测试;增加分支前,先补上原有关键行为;遇到难测的逻辑,就逐步把计算、判断和状态流转从基础设施代码里拆出来。单元测试不是一次性工程,而是在系统演进中持续积累的反馈网络。
评审单元测试时,也不应该只看有没有测试文件、覆盖率有没有增加。更重要的是看测试是否表达了业务行为,失败后能否快速判断哪条规则被破坏,断言观察的是对外结果还是内部路径,Mock 是否只用在合适的边界上,测试数据是否只保留当前场景必需的信息。如果内部实现被重构而业务行为不变,测试原则上应该继续通过;如果它因为普通重构大量失败,说明测试保护的可能不是业务,而是实现细节。
好的单元测试不会让代码变得完美,但会让团队更敢修改代码,更快发现问题,也更容易看见设计边界是否清楚。它的价值不在数量,而在信号质量:业务规则被破坏时能够及时失败,内部实现调整而行为不变时不制造噪音。能做到这一点,单元测试才真正成为系统演进的一部分。
延伸阅读
- 《Unit Testing Principles, Practices, and Patterns》
- Software Engineering at Google: Unit Testing
- Software Engineering at Google: Test Doubles
- Martin Fowler: Software Testing Guide
- Martin Fowler: Test Pyramid
- Martin Fowler: Test Coverage