4. 单元测试设计原则

写单元测试最容易被忽视的一点是:测试代码和产品代码一样,需要被设计。一个能通过、但读起来费劲、改起来牵一发动全身的测试,长期看是负债而不是资产。本节围绕三条主线展开:用 FIRST 原则判断一个测试是否“合格”,用 Given-When-Then 结构组织测试体,用命名与重构让测试自解释。

FIRST 原则:判断测试质量的五把尺子

FIRST 是五个英文单词的首字母缩写,分别对应单元测试应具备的五项属性:Fast(快速)、Isolated(隔离)、Repeatable(可重复)、Self-validating(自验证)、Timely(及时)。它不是语法规则,而是评审测试时的检查清单。

  • Fast(快速):单个测试应在毫秒级完成,整个测试类通常在数百毫秒内跑完。一旦测试依赖真实数据库、网络或文件系统,速度会骤降,开发者就会减少运行频率,反馈闭环随之断裂。
  • Isolated(隔离):测试之间不能有顺序依赖,也不能共享可变状态。每个测试自己准备数据、自己清理,单独运行与批量运行结果一致。
  • Repeatable(可重复):在任何机器、任何时间运行都应得到相同结果。依赖当前时间、随机数、时区、环境变量的测试,必须把这些不确定性显式注入。
  • Self-validating(自验证):测试必须用断言自动判定通过或失败,而不是靠人看日志、看控制台输出。没有断言的测试等于没有测试。
  • Timely(及时):测试应在产品代码之前或同时编写。事后补测往往只覆盖“已经写出来的实现”,容易漏掉边界条件。

下面这张图把 FIRST 五项属性与常见的“反模式信号”对应起来,便于在评审时快速定位问题。

FIRST 原则与常见反模式对照 左侧为应具备的属性,右侧为违反该属性时出现的典型信号 F · Fast 快速 单测毫秒级,整类数百毫秒内完成 反模式:连真实 DB / HTTP 单测耗时数秒,开发者不愿频繁运行 I · Isolated 隔离 无顺序依赖,不共享可变状态 反模式:静态字段累积状态 单独跑通过,批量跑失败 R · Repeatable 可重复 时间、随机、环境显式注入 反模式:直接 new Date() 跨时区、跨天运行结果漂移 S · Self-validating 自验证 断言自动判定通过 / 失败 反模式:只打印不 assert 永远绿灯,缺陷被静默放过 T · Timely 及时 与产品代码同步或先行编写 反模式:上线前集中补测 只覆盖已实现路径,边界遗漏

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)。

GWT 与“Arrange-Act-Assert(AAA)”是同一思想的两种叫法,前者更强调业务语义,后者更强调代码动作。团队内统一一种即可,不要混用。

测试命名:让失败信息自己说话

测试失败时,最先看到的是方法名。命名质量直接决定排障速度。推荐使用 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”而漏掉“差一分不减”,是折扣类逻辑最常见的缺陷来源。