Skip to content

FIRST 原则与好测试的标准 ​

T1-007必学入门约 45 分钟JDK 21测试专项 A:单元测试 · 测试思维

1. 本节定位 ​

《JUnit 5:测试结构与断言》 等课讲了工具与结构。本节给出一份能直接拿来做代码审查的清单:FIRST 五条原则。

它的价值在于把"这个测试写得好不好"从主观判断变成五个可检查的问题。

2. 学习目标 ​

完成本节后,你应该能够:

  • 说出 FIRST 五条原则的含义;
  • 对每一条给出"违反的样子"与"判断方法";
  • 用 FIRST 清单审查一个测试类并指出问题;
  • 说出"一个测试一个失败原因"为什么重要;
  • 把"测试是文档"这个说法落到具体的命名与断言描述上。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

两个测试,都能跑、都能抓 bug,但只有一个值得留下:

java
// 测试 A
@Test
void t1() throws Exception {
    Thread.sleep(2_000);                    // 慢
    UserService s = new UserService();      // 依赖内部 new(不可替换)
    assertNotNull(s);                       // 断言无意义
}
java
// 测试 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 就是把这四条差别(加上"写的时机")标准化。

5. 核心概念 ​

5.1 Fast(快) ​

要求:单元测试应该是毫秒级。

为什么重要:因为测试的价值取决于它被执行的次数(T1-005)。慢测试会被跳过,被跳过就等于不存在。

本项目实测数字:

text
86 个单元测试,测试本身累计约 4 秒
Maven 全量执行(含编译与 JVM 启动):10.763 秒

违反的样子:

反例为什么慢改法
Thread.sleep(2000)真的等了 2 秒注入 Clock(T1-006)
连真实数据库每次建立连接用替身;真实库交给集成测试
读写真实文件磁盘 IO用 ByteArrayInputStream / 临时目录
调用真实 HTTP网络往返Mock 客户端

判断方法:跑单个测试,超过 100 毫秒就该问"它在等什么"。

5.2 Independent(独立) ​

要求:任意顺序、单独或批量执行,结果都一样。

违反的样子:

java
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))。如果结果变了,就有依赖。

一个更强的检验:单独运行每个测试方法。全部通过但合起来失败 → 几乎一定是共享状态。

5.3 Repeatable(可重复) ​

要求:同样的代码,任何时候跑都得到同样的结果。

违反的样子:

依赖症状
当前时间周三写的测试,周日变红
随机数偶发失败,重跑就好
环境变量 / 系统属性本地通过,CI 失败
网络服务服务抖动导致测试红
执行顺序单独跑通过,一起跑失败(其实是 Independent 问题)
数据残留第二次运行才失败(上一次没清理干净)

改法:

java
// 时间:注入 Clock
Clock.fixed(Instant.parse("2026-09-21T10:00:00Z"), ZoneId.of("UTC"))

// 随机:注入固定种子的 Random
new Random(42)

// 环境:用参数注入而不是读 System.getenv()

判断方法:同一个测试连跑十次(@RepeatedTest(10))。出现任何一次不同,它就不满足 Repeatable。

5.4 Self-validating(自验证) ​

要求:成败由断言自动判定,不需要人去看输出。

违反的样子:

java
@Test
void prints_result() {
    System.out.println(service.rename("u1", "新名"));
    // 没有断言:CI 上永远是绿的
}

两种改进:

java
// 1. 加断言(最基本)
assertThat(result).isEqualTo("新名");

// 2. 给断言加业务描述,让失败信息自解释(更强)
assertThat(payable)
        .as("9999 分未达满减阈值(10000 分),不应减免")
        .isEqualTo(9_999L);

第二种的价值:失败信息会变成

text
[9999 分未达满减阈值(10000 分),不应减免]
expected: 9999L
 but was: 9000L

一个不熟悉这段代码的人,只看报告就知道"业务规则被破坏了"——这是"测试即文档"最实际的落点。

判断方法:把测试的所有 System.out 删掉,如果它仍能判定成败,就满足 Self-validating。

5.5 Timely(及时) ​

要求:测试在需要它的时候写出来——通常是"写完被测代码的同时",甚至更早。

为什么这条最难也最重要:

写测试的时机它对设计的影响
写实现之前/同时测试塑造了接口。你会自然地写出"依赖可注入、职责单一"的代码——因为否则测试很难写
实现完成之后测试只能适配已有设计。难测的地方你会绕过去(比如跳过那个分支)
上线前补测试变成"记录现状",抓不到已经在代码里成型的问题

这也是"测试驱动开发(TDD)"的核心动机之一:它不只是"先写测试",而是"让测试来驱动设计的可测性"。

注意:Timely 不是"必须 TDD"。它说的是"别把测试拖到最后"。写完一个方法立刻写它的测试,就已经满足 Timely。

5.6 补充特征(FIRST 之外) ​

特征说明例子
一个测试一个失败原因失败时能立刻指出"哪条规则破了"把"校验 5 个字段"拆成 5 个测试
名字说清意图不看代码就知道测什么rejects_zero_quantity 优于 test1
失败信息可定位报告里能看出"哪里不符预期".as("...") + 精确断言(T1-009)
测试代码同样可读它也是要维护的代码抽公共方法、用清晰的变量名

"一个测试一个失败原因"展开说:

java
// 反例:一个测试校验四个字段
@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)。

5.7 FIRST 审查清单 ​

代码审查时逐条问:

原则审查问题
F这个测试跑了多久?有没有 sleep、真实 IO、网络?
I它依赖别的测试留下的状态吗?单独跑能过吗?
R它依赖时间、随机、环境变量、执行顺序吗?连跑十次结果一样吗?
S它有成败断言吗?断言消息能让人不看代码就懂吗?
T它是和实现一起写的,还是最后补的?(看注释与提交历史)

6. 递进示例 ​

6.1 最小示例:自解释的断言 ​

java
assertThat(payable)
        .as("9999 分未达满减阈值(10000 分),不应减免")
        .isEqualTo(9_999L);

6.2 业务示例:用可控时间满足 Fast 与 Repeatable ​

java
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(时间固定)。

6.3 反例:一个测试多个失败原因 ​

见 5.6 节。

7. 完整可运行代码 ​

文件:labs/testing-unit/src/test/java/cn/kb/learn/testing/FirstPrinciplesDemoTest.java

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);
    }
}

运行命令:

powershell
cd labs
./mvnw -pl testing-unit test -Dtest=FirstPrinciplesDemoTest

预期输出:

text
Tests run: 8, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.062 s
  -- in cn.kb.learn.testing.FirstPrinciplesDemoTest

8 个测试里有 3 个来自 @RepeatedTest(3)——Repeatable 那条原则的实现,本身也让测试数增加了(这也是为什么参数化与重复测试会让 Tests run 数字变大,T1-002 有同样的现象)。

五条原则的对照:

原则代码里的体现一句话
Ffast_without_waiting 用固定时钟不等待
Iindependent_first / independent_second 共用 @BeforeEach 重建的字段不共享
Rrepeatable_across_runs 重复 3 次不靠运气
Sself_validating_with_description 的 .as(...)不靠人看
Ttimely_is_about_when_not_how 的注释不拖到最后

8. Debug 观察点 ​

  1. 在 independent_first 与 independent_second 里各打一个断点,观察 cart 的 id 不同——每个测试拿到新容器;
  2. 在 fast_without_waiting 里断点,观察 fixed.millis() 永远返回同一个值——无论你什么时候跑;
  3. 把 cart 改成 static,重跑:观察 independent_second 失败——亲手验证"独立"这条(做完改回来);
  4. 在 self_validating_with_description 里把 9_999L 改成 9_000L(错误的期望),重跑,观察失败信息里第一行就是你写的中文描述——这就是"自解释"的效果。

9. 常见错误 ​

错误 1:把 System.out.println 当作"验证" ​

java
System.out.println("结果: " + result);      // 打印出来"看一眼"

问题:CI 上没人看输出,这个测试永远绿。修复:改成断言。

错误 2:测试依赖执行顺序 ​

java
@Test
@Order(1)
void create_user() { ... }

@Test
@Order(2)
void update_user() { ... }        // 依赖上一个测试创建的数据

问题:这实际上是一个测试被拆成了两半。任何一半单独跑都会失败,而且第一个失败会让第二个产生连锁失败("看起来全挂了",实际只有一个问题)。

修复:让每个测试自己准备数据(@BeforeEach 或测试方法内)。

错误 3:为了"稳定"而加重试 ​

java
@RepeatedTest(5)
void flaky_test() { ... }         // "有时候失败,重跑几次总会有一次通过"

问题:重试是掩盖问题的止痛药。真正要修的是"为什么会偶发失败"(通常是时间、随机、共享状态)。

注意区分:@RepeatedTest 的正确用途是验证稳定性(T1-002),不是"重试到通过"。

错误 4:断言太多、失败原因不唯一 ​

见 5.6 节。判断方法:这个测试失败时,你能一句话说出"哪条规则破了"吗? 不能,就该拆。

错误 5:把 FAST 理解成"少写断言" ​

快 ≠ 少验证。它是"在同样的验证量下用更快的实现方式":

慢的做法快的做法验证量
Thread.sleep(1000)Clock.fixed(...)更多(能测边界)
连数据库Mock 仓储相同
真发邮件Mock 发送器相同

错误 6:忽略 Timely 的长期代价 ​

"先上线,测试以后补"的结果通常是"永远不补"——因为代码一旦能跑,写测试就变成了"额外的活"。

而且越晚写测试,成本越高:你需要重新理解当时的业务场景、手工构造数据、绕开那些"已经不太好测"的设计。

10. 动手实验 ​

目标:用 FIRST 给一个测试类做体检。

步骤:

  1. 运行第 7 节的命令,核对 8 个测试通过;
  2. 制造违反:把 cart 改成 static → 违反 I;把 fast_without_waiting 里的固定时钟换成 Clock.systemUTC() 并断言一个具体日期 → 违反 R;把 self_validating_with_description 的断言删掉换成 System.out.println → 违反 S。每次只改一处,跑一次,记录现象;
  3. 用 FIRST 清单审查 T1-001 到 T1-012 里你自己写过的任意一个测试类,逐条打勾;
  4. 找一个"断言消息不清晰"的测试,给它加上 .as("..."),然后故意改错期望值跑一次,对比加与不加的失败信息;
  5. 给你写过的测试做"单跑 vs 批量跑"对比:单独运行每个方法,再整体运行一次,看看结果是否一致。

验收方式:能说出第 2 步每处违反导致的具体现象。

扩展任务:给你项目里最"讨厌"的一个测试做改造——它可能是因为太慢、太脆、或失败信息看不懂而让人讨厌的。用 FIRST 定位它违反的是哪一条,然后改掉。

11. 练习题 ​

基础题

  1. 判断:@RepeatedTest(5) 是解决"偶发失败"的正确方式。(对 / 错)
  2. 填空:FIRST 的五条是 、、、、______。
  3. 简答:为什么"快"这条原则如此重要?

代码阅读题

java
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");
    }
}
  1. 这三个测试违反了 FIRST 的哪一条?具体表现是什么?
  2. 修复后三个测试应该怎么写?

编程题

  1. 写一个满足 FIRST 全五条的测试类(自选被测对象,如 PasswordValidator):
    • 至少 5 个测试;
    • 每个测试只有一个失败原因;
    • 至少一个测试使用注入的 Clock 或固定数据;
    • 至少一个断言的失败信息不看代码就能懂;
    • 写完后用第 5.7 节的清单逐条自评,把自评结果写成注释放在类上。

综合任务

  1. 设计一份"测试代码审查清单"(团队用):
    • 以 FIRST 为主干,加上本节的补充特征与 《可测试性与 Arrange-Act-Assert》 等课给出的检查点(AAA 结构、替身选择、是否过度 Mock);
    • 每条写成可回答"是/否"的具体问题(例如"是否有测试依赖时间或随机数?");
    • 分成三档:必须修(阻塞合并)、建议修、可选;
    • 说明:如果一个团队严格执行这份清单,最可能出现的争议是什么?你会怎么处理?
分级提示

提示 1(方向):第 4 题的关键是 static 字段——三个测试都在读写同一份状态,结果取决于执行顺序。

提示 2(关键点):第 5 题的修复方向是"每个测试用自己的局部变量或实例字段",total_is_zero_initially 则应该测"新建对象的初始值"。

提示 3(接近答案):第 7 题的争议通常集中在"S 与 F 的取舍"——加业务描述、拆细测试会让代码变长,有人会觉得"啰嗦"。

12. 参考答案 ​

基础题
  1. 错。@RepeatedTest 的用途是验证稳定性(确认一个测试确实每次都通过),不是"重试到通过"。用它来掩盖偶发失败,等于把"未知的 flaky 问题"变成了"已知但被忽略的问题"。

遇到偶发失败的正确顺序:① 复现(固定种子/时间);② 定位依赖(时间?随机?共享状态?顺序?);③ 消除依赖(注入可控实现)。

  1. Fast(快)、Independent(独立)、Repeatable(可重复)、Self-validating(自验证)、Timely(及时)。
  2. 因为测试的价值 = 单次价值 × 执行次数。一个 10 秒的测试套件,开发者每次改代码都跑;一个 10 分钟的套件,只能等 CI——同样的断言,实际拦截能力差几十倍。

而且慢会引发连锁反应:跑得少 → 发现问题晚 → 修复成本高 → 大家更不想跑。"快"是测试能被长期使用的物理前提。

代码阅读题(第 4、5 题)

第 4 题:违反 Independent,且连带违反 Repeatable。

total 是 static 字段,三个测试都在读写同一份状态:

测试单独跑按某种顺序一起跑
add_first_item100 ✓取决于前面有没有别的测试跑过
add_second_item200 ✓如果第一个测试先跑,total 已经是 100,加 200 得 300 → 失败
total_is_zero_initially0 ✓(首次)只要前面有测试跑过,就失败

JUnit 5 默认不保证方法顺序,所以这三个测试"时红时绿"——典型的顺序依赖。

第 5 题:修复后:

java
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——这就是"独立"带来的确定性:每个测试的期望值只取决于它自己准备了什么。

编程题(第 6 题)
java
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 都能靠工具或习惯检查)。

综合任务(第 7 题)

测试代码审查清单(可直接用于 PR 评审):

markdown
## 测试代码审查清单

### 一、必须修(阻塞合并)

- [ ] **有成败断言吗?**(只有打印/无断言的测试必须改)
- [ ] **单独运行能通过吗?**(依赖其他测试的数据 → 必须改)
- [ ] **有静态可变状态吗?**(`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:不要只说"这里不对",要说出"违反了哪一条、会导致什么现象"。清单的价值在于让讨论从"我觉得"变成"哪种做法在什么场景下更合适"。

13. 掌握标准 ​

  • [ ] 能说出 FIRST 五条的含义与各自的判断方法;
  • [ ] 能用 FIRST 清单审查一个测试类并给出具体改法;
  • [ ] 知道 @RepeatedTest 是"验证稳定"而不是"重试通过";
  • [ ] 至少给关键断言加过 .as("业务描述");
  • [ ] 理解 Timely 的长期价值:测试不只验证设计,它也塑造设计。

14. 扩展资源 ​

15. 下一步学习 ​

测试思维三节到此完成。接下来:

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引