主题
界面
主题
《JUnit 5:测试结构与断言》 等课讲了工具与结构。本节给出一份能直接拿来做代码审查的清单:FIRST 五条原则。
它的价值在于把"这个测试写得好不好"从主观判断变成五个可检查的问题。
完成本节后,你应该能够:
两个测试,都能跑、都能抓 bug,但只有一个值得留下:
// 测试 A
@Test
void t1() throws Exception {
Thread.sleep(2_000); // 慢
UserService s = new UserService(); // 依赖内部 new(不可替换)
assertNotNull(s); // 断言无意义
}// 测试 B
@Test
@DisplayName("数量为 0 时抛出 IllegalArgumentException")
void rejects_zero_quantity() {
IllegalArgumentException exception = assertThrows(
IllegalArgumentException.class,
() -> calculator.payableInCents(100L, 0));
assertThat(exception).hasMessage("数量必须为正");
}差别不在"能不能跑",而在反馈质量:
| 维度 | 测试 A | 测试 B |
|---|---|---|
| 耗时 | 2 秒 | 微秒 |
| 失败时能定位到什么 | "t1 失败了" | "数量为 0 时抛的异常消息不对" |
| 会不会随机失败 | 可能(依赖环境) | 不会 |
| 三个月后还看得懂吗 | 看不懂 | 一眼看懂 |
FIRST 就是把这四条差别(加上"写的时机")标准化。
要求:单元测试应该是毫秒级。
为什么重要:因为测试的价值取决于它被执行的次数(T1-005)。慢测试会被跳过,被跳过就等于不存在。
本项目实测数字:
86 个单元测试,测试本身累计约 4 秒
Maven 全量执行(含编译与 JVM 启动):10.763 秒违反的样子:
| 反例 | 为什么慢 | 改法 |
|---|---|---|
Thread.sleep(2000) | 真的等了 2 秒 | 注入 Clock(T1-006) |
| 连真实数据库 | 每次建立连接 | 用替身;真实库交给集成测试 |
| 读写真实文件 | 磁盘 IO | 用 ByteArrayInputStream / 临时目录 |
| 调用真实 HTTP | 网络往返 | Mock 客户端 |
判断方法:跑单个测试,超过 100 毫秒就该问"它在等什么"。
要求:任意顺序、单独或批量执行,结果都一样。
违反的样子:
private static final List<String> ITEMS = new ArrayList<>(); // 静态可变状态
@Test
void adds_first() {
ITEMS.add("键盘");
assertThat(ITEMS).hasSize(1); // 单独跑通过
}
@Test
void adds_second() {
ITEMS.add("鼠标");
assertThat(ITEMS).hasSize(1); // 一起跑失败(看到 2 个)
}改法:去掉 static,改成实例字段 + @BeforeEach 重建(T1-002 的 5.2 节)。
判断方法:把测试类里的测试方法随机打乱顺序跑一遍(或用 @TestMethodOrder(MethodOrderer.Random.class))。如果结果变了,就有依赖。
一个更强的检验:单独运行每个测试方法。全部通过但合起来失败 → 几乎一定是共享状态。
要求:同样的代码,任何时候跑都得到同样的结果。
违反的样子:
| 依赖 | 症状 |
|---|---|
| 当前时间 | 周三写的测试,周日变红 |
| 随机数 | 偶发失败,重跑就好 |
| 环境变量 / 系统属性 | 本地通过,CI 失败 |
| 网络服务 | 服务抖动导致测试红 |
| 执行顺序 | 单独跑通过,一起跑失败(其实是 Independent 问题) |
| 数据残留 | 第二次运行才失败(上一次没清理干净) |
改法:
// 时间:注入 Clock
Clock.fixed(Instant.parse("2026-09-21T10:00:00Z"), ZoneId.of("UTC"))
// 随机:注入固定种子的 Random
new Random(42)
// 环境:用参数注入而不是读 System.getenv()判断方法:同一个测试连跑十次(@RepeatedTest(10))。出现任何一次不同,它就不满足 Repeatable。
要求:成败由断言自动判定,不需要人去看输出。
违反的样子:
@Test
void prints_result() {
System.out.println(service.rename("u1", "新名"));
// 没有断言:CI 上永远是绿的
}两种改进:
// 1. 加断言(最基本)
assertThat(result).isEqualTo("新名");
// 2. 给断言加业务描述,让失败信息自解释(更强)
assertThat(payable)
.as("9999 分未达满减阈值(10000 分),不应减免")
.isEqualTo(9_999L);第二种的价值:失败信息会变成
[9999 分未达满减阈值(10000 分),不应减免]
expected: 9999L
but was: 9000L一个不熟悉这段代码的人,只看报告就知道"业务规则被破坏了"——这是"测试即文档"最实际的落点。
判断方法:把测试的所有 System.out 删掉,如果它仍能判定成败,就满足 Self-validating。
要求:测试在需要它的时候写出来——通常是"写完被测代码的同时",甚至更早。
为什么这条最难也最重要:
| 写测试的时机 | 它对设计的影响 |
|---|---|
| 写实现之前/同时 | 测试塑造了接口。你会自然地写出"依赖可注入、职责单一"的代码——因为否则测试很难写 |
| 实现完成之后 | 测试只能适配已有设计。难测的地方你会绕过去(比如跳过那个分支) |
| 上线前补 | 测试变成"记录现状",抓不到已经在代码里成型的问题 |
这也是"测试驱动开发(TDD)"的核心动机之一:它不只是"先写测试",而是"让测试来驱动设计的可测性"。
注意:Timely 不是"必须 TDD"。它说的是"别把测试拖到最后"。写完一个方法立刻写它的测试,就已经满足 Timely。
| 特征 | 说明 | 例子 |
|---|---|---|
| 一个测试一个失败原因 | 失败时能立刻指出"哪条规则破了" | 把"校验 5 个字段"拆成 5 个测试 |
| 名字说清意图 | 不看代码就知道测什么 | rejects_zero_quantity 优于 test1 |
| 失败信息可定位 | 报告里能看出"哪里不符预期" | .as("...") + 精确断言(T1-009) |
| 测试代码同样可读 | 它也是要维护的代码 | 抽公共方法、用清晰的变量名 |
"一个测试一个失败原因"展开说:
// 反例:一个测试校验四个字段
@Test
void validates_all_fields() {
assertThat(form.name()).isNotNull();
assertThat(form.email()).contains("@");
assertThat(form.age()).isGreaterThan(0);
assertThat(form.phone()).hasSize(11);
}问题:第一个断言失败时,后面三个根本没执行。你要改一次、跑一次、发现下一个问题——四轮。
修复:拆成四个测试(或对同一对象的并列属性用 SoftAssertions,T1-009)。
代码审查时逐条问:
| 原则 | 审查问题 |
|---|---|
| F | 这个测试跑了多久?有没有 sleep、真实 IO、网络? |
| I | 它依赖别的测试留下的状态吗?单独跑能过吗? |
| R | 它依赖时间、随机、环境变量、执行顺序吗?连跑十次结果一样吗? |
| S | 它有成败断言吗?断言消息能让人不看代码就懂吗? |
| T | 它是和实现一起写的,还是最后补的?(看注释与提交历史) |
assertThat(payable)
.as("9999 分未达满减阈值(10000 分),不应减免")
.isEqualTo(9_999L);Clock fixed = Clock.fixed(Instant.parse("2026-09-21T10:00:00Z"), ZoneId.of("UTC"));
assertThat(fixed.millis()).isEqualTo(Instant.parse("2026-09-21T10:00:00Z").toEpochMilli());这个测试同时满足 Fast(不等)与 Repeatable(时间固定)。
见 5.6 节。
文件:labs/testing-unit/src/test/java/cn/kb/learn/testing/FirstPrinciplesDemoTest.java
@DisplayName("FIRST 原则在代码里的体现")
class FirstPrinciplesDemoTest {
/** Independent:每个测试都从 @BeforeEach 拿到全新的容器 */
private List<String> cart;
@BeforeEach
void setUp() {
cart = new ArrayList<>();
}
@Test
@DisplayName("Fast:用固定时钟代替真实等待,耗时是微秒级")
void fast_without_waiting() {
Clock fixed = Clock.fixed(Instant.parse("2026-09-21T10:00:00Z"), ZoneId.of("UTC"));
assertThat(fixed.millis()).isEqualTo(Instant.parse("2026-09-21T10:00:00Z").toEpochMilli());
// 对比:如果这里写 Thread.sleep(1000) 来「模拟时间流逝」,测试就慢了 6 个数量级
}
@Test
@DisplayName("Independent(第一个):往购物车加商品")
void independent_first() {
cart.add("键盘");
assertThat(cart).hasSize(1);
}
@Test
@DisplayName("Independent(第二个):看不到上一个测试加的商品")
void independent_second() {
assertThat(cart).isEmpty();
}
@RepeatedTest(value = 3, name = "第 {currentRepetition} / {totalRepetitions} 次")
@DisplayName("Repeatable:同一输入重复执行,结果一致")
void repeatable_across_runs() {
OrderCalculator calculator = new OrderCalculator();
assertThat(calculator.payableInCents(5_000L, 2)).isEqualTo(9_000L);
}
@Test
@DisplayName("Self-validating:断言带上业务描述,失败时无需打开代码")
void self_validating_with_description() {
long payable = new OrderCalculator().payableInCents(9_999L, 1);
assertThat(payable)
.as("9999 分未达满减阈值(10000 分),不应减免")
.isEqualTo(9_999L);
}
@Test
@DisplayName("Timely:这条测试的价值取决于「什么时候写的」")
void timely_is_about_when_not_how() {
// 同一行断言:
// - 在 OrderCalculator 写完后立刻写 → Timely,它塑造了实现
// - 上线前补的 → 只是记录,抓 bug 的收益已经错过
assertThat(new OrderCalculator().payableInCents(5_000L, 2)).isEqualTo(9_000L);
}
}运行命令:
cd labs
./mvnw -pl testing-unit test -Dtest=FirstPrinciplesDemoTest预期输出:
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.062 s
-- in cn.kb.learn.testing.FirstPrinciplesDemoTest8 个测试里有 3 个来自 @RepeatedTest(3)——Repeatable 那条原则的实现,本身也让测试数增加了(这也是为什么参数化与重复测试会让 Tests run 数字变大,T1-002 有同样的现象)。
五条原则的对照:
| 原则 | 代码里的体现 | 一句话 |
|---|---|---|
| F | fast_without_waiting 用固定时钟 | 不等待 |
| I | independent_first / independent_second 共用 @BeforeEach 重建的字段 | 不共享 |
| R | repeatable_across_runs 重复 3 次 | 不靠运气 |
| S | self_validating_with_description 的 .as(...) | 不靠人看 |
| T | timely_is_about_when_not_how 的注释 | 不拖到最后 |
independent_first 与 independent_second 里各打一个断点,观察 cart 的 id 不同——每个测试拿到新容器;fast_without_waiting 里断点,观察 fixed.millis() 永远返回同一个值——无论你什么时候跑;cart 改成 static,重跑:观察 independent_second 失败——亲手验证"独立"这条(做完改回来);self_validating_with_description 里把 9_999L 改成 9_000L(错误的期望),重跑,观察失败信息里第一行就是你写的中文描述——这就是"自解释"的效果。System.out.println 当作"验证" System.out.println("结果: " + result); // 打印出来"看一眼"问题:CI 上没人看输出,这个测试永远绿。修复:改成断言。
@Test
@Order(1)
void create_user() { ... }
@Test
@Order(2)
void update_user() { ... } // 依赖上一个测试创建的数据问题:这实际上是一个测试被拆成了两半。任何一半单独跑都会失败,而且第一个失败会让第二个产生连锁失败("看起来全挂了",实际只有一个问题)。
修复:让每个测试自己准备数据(@BeforeEach 或测试方法内)。
@RepeatedTest(5)
void flaky_test() { ... } // "有时候失败,重跑几次总会有一次通过"问题:重试是掩盖问题的止痛药。真正要修的是"为什么会偶发失败"(通常是时间、随机、共享状态)。
注意区分:@RepeatedTest 的正确用途是验证稳定性(T1-002),不是"重试到通过"。
见 5.6 节。判断方法:这个测试失败时,你能一句话说出"哪条规则破了"吗? 不能,就该拆。
快 ≠ 少验证。它是"在同样的验证量下用更快的实现方式":
| 慢的做法 | 快的做法 | 验证量 |
|---|---|---|
Thread.sleep(1000) | Clock.fixed(...) | 更多(能测边界) |
| 连数据库 | Mock 仓储 | 相同 |
| 真发邮件 | Mock 发送器 | 相同 |
"先上线,测试以后补"的结果通常是"永远不补"——因为代码一旦能跑,写测试就变成了"额外的活"。
而且越晚写测试,成本越高:你需要重新理解当时的业务场景、手工构造数据、绕开那些"已经不太好测"的设计。
目标:用 FIRST 给一个测试类做体检。
步骤:
cart 改成 static → 违反 I;把 fast_without_waiting 里的固定时钟换成 Clock.systemUTC() 并断言一个具体日期 → 违反 R;把 self_validating_with_description 的断言删掉换成 System.out.println → 违反 S。每次只改一处,跑一次,记录现象;T1-001 到 T1-012 里你自己写过的任意一个测试类,逐条打勾;.as("..."),然后故意改错期望值跑一次,对比加与不加的失败信息;验收方式:能说出第 2 步每处违反导致的具体现象。
扩展任务:给你项目里最"讨厌"的一个测试做改造——它可能是因为太慢、太脆、或失败信息看不懂而让人讨厌的。用 FIRST 定位它违反的是哪一条,然后改掉。
基础题
@RepeatedTest(5) 是解决"偶发失败"的正确方式。(对 / 错)代码阅读题
class OrderTest {
private static BigDecimal total = BigDecimal.ZERO;
@Test
void add_first_item() {
total = total.add(new BigDecimal("100"));
assertThat(total).isEqualByComparingTo("100");
}
@Test
void add_second_item() {
total = total.add(new BigDecimal("200"));
assertThat(total).isEqualByComparingTo("200"); // 期望 200?
}
@Test
void total_is_zero_initially() {
assertThat(total).isEqualByComparingTo("0");
}
}编程题
PasswordValidator): Clock 或固定数据;综合任务
提示 1(方向):第 4 题的关键是 static 字段——三个测试都在读写同一份状态,结果取决于执行顺序。
提示 2(关键点):第 5 题的修复方向是"每个测试用自己的局部变量或实例字段",total_is_zero_initially 则应该测"新建对象的初始值"。
提示 3(接近答案):第 7 题的争议通常集中在"S 与 F 的取舍"——加业务描述、拆细测试会让代码变长,有人会觉得"啰嗦"。
@RepeatedTest 的用途是验证稳定性(确认一个测试确实每次都通过),不是"重试到通过"。用它来掩盖偶发失败,等于把"未知的 flaky 问题"变成了"已知但被忽略的问题"。遇到偶发失败的正确顺序:① 复现(固定种子/时间);② 定位依赖(时间?随机?共享状态?顺序?);③ 消除依赖(注入可控实现)。
而且慢会引发连锁反应:跑得少 → 发现问题晚 → 修复成本高 → 大家更不想跑。"快"是测试能被长期使用的物理前提。
第 4 题:违反 Independent,且连带违反 Repeatable。
total 是 static 字段,三个测试都在读写同一份状态:
| 测试 | 单独跑 | 按某种顺序一起跑 |
|---|---|---|
add_first_item | 100 ✓ | 取决于前面有没有别的测试跑过 |
add_second_item | 200 ✓ | 如果第一个测试先跑,total 已经是 100,加 200 得 300 → 失败 |
total_is_zero_initially | 0 ✓(首次) | 只要前面有测试跑过,就失败 |
JUnit 5 默认不保证方法顺序,所以这三个测试"时红时绿"——典型的顺序依赖。
第 5 题:修复后:
class OrderTest {
private BigDecimal total;
@BeforeEach
void setUp() {
total = BigDecimal.ZERO; // 每个测试都有干净的起点
}
@Test
void add_first_item() {
total = total.add(new BigDecimal("100"));
assertThat(total).isEqualByComparingTo("100");
}
@Test
void add_second_item() {
total = total.add(new BigDecimal("200"));
assertThat(total).isEqualByComparingTo("200"); // 起点是 0,结果就是 200
}
@Test
void total_is_zero_initially() {
assertThat(total).isEqualByComparingTo("0");
}
}关键改动:static → 实例字段 + @BeforeEach 重建。
顺带一个观察:修复后 add_second_item 的期望值从"不确定"变成了明确的 200——这就是"独立"带来的确定性:每个测试的期望值只取决于它自己准备了什么。
package cn.kb.learn.testing;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneId;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* FIRST 自评:
* F:全部测试耗时 < 5 ms,无 sleep、无 IO、无网络
* I:passwordValidator 由 @BeforeEach 重建,无静态可变状态
* R:时间固定(Clock.fixed),不依赖随机与环境变量;连跑十次结果一致
* S:每个测试都有断言,第 4、5 个带业务描述
* T:被测类与测试同时编写(本类随 PasswordValidator 一起提交)
*
* AAA:每个测试都是"准备 → 执行 → 断言",且只有一个 Act
* 一个测试一个失败原因:长度/字符类型/空白/空值各自独立
*/
@DisplayName("密码校验器(FIRST 自评版)")
class PasswordValidatorFirstTest {
/** 注入固定时钟:让"密码有效期"这类规则可复现 */
private static final Clock FIXED_CLOCK =
Clock.fixed(Instant.parse("2026-09-21T10:00:00Z"), ZoneId.of("UTC"));
private PasswordValidator validator;
@BeforeEach
void setUp() {
validator = new PasswordValidator();
}
@Test
@DisplayName("长度不足 8 位:拒绝")
void rejects_short_password() {
assertThat(validator.isValid("Ab1!")).isFalse();
}
@Test
@DisplayName("缺少数字:拒绝")
void rejects_password_without_digit() {
assertThat(validator.isValid("Abcdefg!")).isFalse();
}
@Test
@DisplayName("长度为 8 位且含大小写、数字:通过")
void accepts_valid_password() {
assertThat(validator.isValid("Abcdefg1")).isTrue();
}
@Test
@DisplayName("空白字符串:拒绝且不抛异常")
void rejects_blank_without_throwing() {
assertThat(validator.isValid(" "))
.as("8 个空格满足长度要求,但不应被当作有效密码")
.isFalse();
}
@Test
@DisplayName("null 输入:抛出 IllegalArgumentException 而不是 NullPointerException")
void rejects_null_with_clear_exception() {
assertThatThrownBy(() -> validator.isValid(null))
.as("null 属于调用方错误,应给出明确的参数异常")
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("密码");
}
@Test
@DisplayName("固定时钟可用于验证时间相关规则")
void fixed_clock_is_reproducible() {
// 如果后续加入"密码 90 天过期"的规则,用它来验证边界
assertThat(FIXED_CLOCK.millis()).isPositive();
}
}注意第 4、5 个测试的 .as(...):它们把"为什么这条断言重要"写进了失败信息——isFalse() 本身看不出业务含义,加上描述后,"空格密码被当成有效密码"这个问题一眼可见。
关于 Timely 的自评:我把它写成注释——因为Timely 无法从代码本身验证,只能靠"提交历史/开发流程"来保证。这也是 FIRST 五条里唯一一条"无法自动检查"的(F、I、R、S 都能靠工具或习惯检查)。
测试代码审查清单(可直接用于 PR 评审):
## 测试代码审查清单
### 一、必须修(阻塞合并)
- [ ] **有成败断言吗?**(只有打印/无断言的测试必须改)
- [ ] **单独运行能通过吗?**(依赖其他测试的数据 → 必须改)
- [ ] **有静态可变状态吗?**(`static` 非 final 字段 → 必须改)
- [ ] **依赖当前时间/随机数吗?**(未注入 → 必须改)
- [ ] **会真的连接外部系统吗?**(单测里连库/发 HTTP → 必须改)
- [ ] **断言了实现细节吗?**(`verify` 了查询类调用、次数 → 必须改,见 T1-012)
- [ ] **一个测试里有多个 Act 吗?**(拆开)
- [ ] **测试真的在测被测代码吗?**(mock 了被测类本身 → 必须改)
### 二、建议修
- [ ] 测试名/`@DisplayName` 能说清意图吗?
- [ ] 复杂的断言有 `.as("业务描述")` 吗?
- [ ] 用了 AAA 结构且有空行分隔吗?
- [ ] 替身类型选对了吗?(值对象用真实实例、有状态的用 Fake)
- [ ] 参数化测试的 `name` 有占位符吗?
- [ ] 单测耗时 > 100 ms 吗?(问它在等什么)
### 三、可选
- [ ] 测试类按场景用 `@Nested` 分组了吗?
- [ ] 边界值都有单独用例吗?
- [ ] 异常场景都验证了消息吗?
- [ ] 新增的公共逻辑有对应测试吗?最可能出现的争议:S(Self-validating)与"简洁"的冲突。
具体来说,会有人提这些意见:
| 意见 | 背后的合理诉求 | 怎么处理 |
|---|---|---|
"每个断言都加 .as(...) 太啰嗦" | 代码简洁 | 只对"含义不直观的断言"加描述(数字比较、布尔判断),语义已经清楚的(hasMessage("..."))不必加 |
| "为什么把 5 个字段校验拆成 5 个测试?" | 一个测试看一次就懂 | 解释"一个失败原因"的收益:一次跑完知道所有问题;如果字段间确实无关,拆;如果是一个对象的并列属性,用 SoftAssertions |
| "断言写这么细,实现一改就红" | 测试要稳定 | 区分"业务要求"与"实现细节"——该稳的是前者(T1-012) |
| "写测试的时间比写功能还长" | 交付压力 | 用 T1-005 的成本曲线回应:这里多花 10 分钟,生产环境少一次事故;同时承认"探索性代码可以不写测试,但一旦要长期维护就得补" |
处理原则:清单是"护栏"不是"判决"。出现争议时,回到那个唯一的标准:
这个测试失败时,它能不能指出"哪条业务规则被破坏了"?
能,就是好测试;不能,无论多简洁都不合格。
最后一个提醒给 reviewer:不要只说"这里不对",要说出"违反了哪一条、会导致什么现象"。清单的价值在于让讨论从"我觉得"变成"哪种做法在什么场景下更合适"。
@RepeatedTest 是"验证稳定"而不是"重试通过";.as("业务描述");测试思维三节到此完成。接下来:
暂无