4. 单元测试设计原则
写单元测试最容易被忽视的一点是:测试代码和产品代码一样,需要被设计。一个能通过、但读起来费劲、改起来牵一发动全身的测试,长期看是负债而不是资产。本节围绕三条主线展开:用 FIRST 原则判断一个测试是否“合格”,用 Given-When-Then 结构组织测试体,用命名与重构让测试自解释。
FIRST 原则:判断测试质量的五把尺子
FIRST 是五个英文单词的首字母缩写,分别对应单元测试应具备的五项属性:Fast(快速)、Isolated(隔离)、Repeatable(可重复)、Self-validating(自验证)、Timely(及时)。它不是语法规则,而是评审测试时的检查清单。
- Fast(快速):单个测试应在毫秒级完成,整个测试类通常在数百毫秒内跑完。一旦测试依赖真实数据库、网络或文件系统,速度会骤降,开发者就会减少运行频率,反馈闭环随之断裂。
- Isolated(隔离):测试之间不能有顺序依赖,也不能共享可变状态。每个测试自己准备数据、自己清理,单独运行与批量运行结果一致。
- Repeatable(可重复):在任何机器、任何时间运行都应得到相同结果。依赖当前时间、随机数、时区、环境变量的测试,必须把这些不确定性显式注入。
- Self-validating(自验证):测试必须用断言自动判定通过或失败,而不是靠人看日志、看控制台输出。没有断言的测试等于没有测试。
- Timely(及时):测试应在产品代码之前或同时编写。事后补测往往只覆盖“已经写出来的实现”,容易漏掉边界条件。
下面这张图把 FIRST 五项属性与常见的“反模式信号”对应起来,便于在评审时快速定位问题。
Given-When-Then:把测试体切成三段
Given-When-Then(简称 GWT)源自行为驱动开发,本质是把一个测试方法拆成三个语义段落:Given 描述前置状态与输入,When 描述被触发的那个动作,Then 描述期望的可观察结果。它解决的是“测试体一长就分不清哪句是准备、哪句是断言”的问题。
以“订单满 100 元减 20 元”的折扣计算为例,用 JUnit 5 与 AssertJ 写出来大致如下:
@Test
void should_discount_20_when_order_amount_reaches_100() {
// Given:订单金额 100 元,折扣规则为满 100 减 20
Order order = new Order(new BigDecimal("100.00"));
DiscountRule rule = new ThresholdDiscountRule(
new BigDecimal("100.00"), new BigDecimal("20.00"));
// When:对订单应用折扣规则
BigDecimal payable = rule.apply(order);
// Then:应付金额为 80 元
assertThat(payable).isEqualByComparingTo("80.00");
}
三段之间用空行分隔,是社区里非常有效的可读性约定。它带来的直接好处是:评审时一眼能看出“准备了几件事、触发了什么、验证了什么”。如果 Given 段超过五六行,通常说明被测对象依赖过多,应该先重构产品代码或引入测试夹具(Fixture)。
测试命名:让失败信息自己说话
测试失败时,最先看到的是方法名。命名质量直接决定排障速度。推荐使用 should_期望结果_when_触发条件 或 方法名_场景_期望结果 这类结构化命名,把“行为、条件、结果”三要素写进名字里。
| 命名方式 | 示例 | 可读性评价 |
|---|---|---|
| 无意义编号 | test1() | 失败后完全不知道测什么 |
| 只写方法名 | testApply() | 无法区分正常与边界场景 |
| 结构化命名 | should_discount_20_when_amount_reaches_100() | 失败信息即场景说明 |
| 中文场景命名 | 订单满100元_应减20元() | 业务方也能读懂,需团队约定 |
命名之外,还有几处高频的可读性重构点:把魔法数字提取为具名常量;把重复的 Given 段抽成 @BeforeEach 或私有工厂方法;用参数化测试(@ParameterizedTest)替代复制粘贴的多个相似用例;断言尽量使用 AssertJ 的链式 API,让失败信息包含实际值与期望值。
一个可复用的测试用例模板
把上述原则落成模板,团队新人可以直接套用。下面这份模板覆盖了命名、结构、断言与边界四类要素,可直接作为代码评审的检查依据。
/**
* 被测对象:ThresholdDiscountRule
* 场景:订单金额达到阈值
* 期望:按固定额度减免
*/
@Test
void should_discount_20_when_amount_reaches_100() {
// Given
Order order = new Order(new BigDecimal("100.00"));
DiscountRule rule = new ThresholdDiscountRule(
new BigDecimal("100.00"), new BigDecimal("20.00"));
// When
BigDecimal payable = rule.apply(order);
// Then
assertThat(payable).isEqualByComparingTo("80.00");
}
// 边界用例:金额恰好低于阈值,不应减免
@Test
void should_not_discount_when_amount_below_100() {
Order order = new Order(new BigDecimal("99.99"));
DiscountRule rule = new ThresholdDiscountRule(
new BigDecimal("100.00"), new BigDecimal("20.00"));
BigDecimal payable = rule.apply(order);
assertThat(payable).isEqualByComparingTo("99.99");
}
99.99 与 100.00 必须各有一个用例。只测“满 100 减 20”而漏掉“差一分不减”,是折扣类逻辑最常见的缺陷来源。