Skip to content

JUnit 5:测试结构与断言 ​

T1-001必学进阶约 90 分钟JDK 21测试专项 A:单元测试 · JUnit 5

1. 本节定位 ​

这是测试专项线的第一节课。你已经做过大量功能测试——测试设计能力不缺,缺的是把测试写成代码。

本节不教"怎么设计用例"(那是你的强项),只解决一件事:把脑子里的用例,翻译成结构清晰、能自动运行的 Java 代码。

2. 学习目标 ​

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

  • 写出带 Arrange-Act-Assert 三段结构的测试方法,并解释每一段的作用;
  • 用 assertEquals / assertTrue / assertThrows / assertAll 完成常见断言;
  • 给测试起一个"失败时能一眼看出问题"的名字;
  • 说出"测试即文档"的含义,并写出能当文档用的测试;
  • 识别没有断言的假测试与断言过多的巨型测试。

3. 前置知识检查 ​

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

手工测试的根本问题不是"慢",而是不可重复且没有记录:

手工测试单元测试
执行成本每次都要点一遍一次编写,随时重跑
回归能力改代码后没人愿意全量回归mvn test 几秒钟跑完
记录方式口头/文档,容易过期代码即记录,不会和实现脱节
失败反馈靠人观察精确定位到方法与断言

而且单元测试还有第二个价值:它是唯一不会过期的文档。注释会腐烂,文档会过期,但测试如果过期就会变红——它不允许自己撒谎。

从 QA 到测试开发的关键跃迁

你现在的测试用例多半写在测试管理平台里(步骤、预期结果、实际结果)。写成代码后,这些字段会变成:

  • 前置条件 → Arrange
  • 操作步骤 → Act
  • 预期结果 → Assert

结构是一样的,只是执行者从人变成了 JVM。

5. 核心概念 ​

5.1 测试金字塔里的位置 ​

text
        ▲  UI / E2E 测试(少、慢、脆)
       ▲▲▲  集成测试(中)
     ▲▲▲▲▲  单元测试(多、快、稳)← 本节在这里

单元测试的目标是单个类或方法的行为,不启动 Spring、不连数据库、不打开浏览器。因此它能在毫秒级跑完,也最不容易失败于"环境问题"。

5.2 AAA 结构 ​

java
@Test
void should_apply_discount_when_total_reaches_threshold() {
    // Arrange:准备数据与对象
    OrderCalculator calculator = new OrderCalculator();

    // Act:执行被测行为(只调一次)
    long payable = calculator.payableInCents(6_000L, 2);

    // Assert:验证结果
    assertEquals(11_000L, payable);
}

三条纪律:

  1. 一段一件事:Arrange 里不要调被测方法,Act 里只调一次;
  2. Act 只有一行:如果需要多行才能表达一个行为,说明测试的粒度不对;
  3. Assert 给出失败信息:断言失败时要能看出"期望什么、实际是什么"。

5.3 测试命名:把意图写进方法名 ​

命名问题
test1()失败后完全不知道测什么
testCalculator()还是不知道测什么
should_apply_discount_when_total_reaches_threshold()失败信息自带解释

推荐格式:should_<期望行为>_when_<条件>。中文场景可以配合 @DisplayName:

java
@DisplayName("总额达到 10000 分时,减免 1000 分")

@DisplayName 的中文会出现在 IDEA 的测试报告和 Maven 的测试输出里,比方法名更直观。

5.4 常用断言 ​

java
assertEquals(期望值, 实际值, "失败时的提示");        // 相等(注意参数顺序!)
assertTrue(条件, "失败提示");                       // 为真
assertFalse(条件, "失败提示");                      // 为假
assertNull(对象);                                  // 为 null
assertNotNull(对象, "对象不该为空");                 // 不为 null
assertThrows(IllegalArgumentException.class, () -> calculator.payableInCents(100, 0));
assertDoesNotThrow(() -> calculator.payableInCents(100, 1));
assertAll("订单金额校验",                            // 多断言全部执行,不因第一个失败而中断
        () -> assertEquals(5_000L, result.amount()),
        () -> assertEquals("已支付", result.status())
);

assertAll 是常用技巧:普通断言在第一个失败处停止,assertAll 会把所有失败一次性报出来,适合一次校验多个相关字段。

6. 递进示例 ​

6.1 最小示例:一个断言 ​

java
@Test
void should_return_unit_price_when_quantity_is_one() {
    OrderCalculator calculator = new OrderCalculator();
    long payable = calculator.payableInCents(5_000L, 1);
    assertEquals(5_000L, payable);
}

6.2 业务示例:正常 + 边界 ​

被测方法有两条业务规则:满 10000 分减 1000 分、非法参数抛异常。对应四个测试:

java
@Test
void should_not_apply_discount_when_total_below_threshold() { ... }   // 9999 分,不减

@Test
void should_apply_discount_when_total_equals_threshold() { ... }      // 10000 分,边界值

@Test
void should_throw_when_unit_price_is_not_positive() { ... }           // 0 分,异常

@Test
void should_throw_when_quantity_is_not_positive() { ... }             // 数量 0,异常

边界值分析在这里体现得最直接:9999 / 10000 / 10001 三个点的行为差异,正是你在测试用例设计里做过无数次的事。

6.3 反例:看起来在测试,其实没有 ​

java
@Test
void testCalculator() {
    calculator.payableInCents(5_000L, 1);      // 没有断言,永远不会失败
}

@Test
void testAll() {
    assertEquals(5_000L, calculator.payableInCents(5_000L, 1));
    assertEquals(11_000L, calculator.payableInCents(6_000L, 2));
    assertEquals(2_000L, calculator.payableInCents(2_000L, 1));
    assertThrows(IllegalArgumentException.class, () -> calculator.payableInCents(0, 1));
    // 第一个失败后,后面三个根本不执行——一次只能修一个问题
}

第一条是"假测试",它只证明方法没抛异常;第二条是"巨型测试",失败信息模糊且互相遮挡。一个测试验证一个行为。

7. 完整可运行代码 ​

被测类:labs/testing-unit/src/main/java/cn/kb/learn/testing/OrderCalculator.java

java
package cn.kb.learn.testing;

public class OrderCalculator {
    private static final long DISCOUNT_THRESHOLD_IN_CENTS = 10_000L;
    private static final long DISCOUNT_IN_CENTS = 1_000L;

    /**
     * 计算应付金额:单价 × 数量,总额达到阈值时减免固定金额。
     */
    public long payableInCents(long unitPriceInCents, int quantity) {
        if (unitPriceInCents <= 0) {
            throw new IllegalArgumentException("单价必须为正");
        }
        if (quantity <= 0) {
            throw new IllegalArgumentException("数量必须为正");
        }
        long total = unitPriceInCents * quantity;
        if (total >= DISCOUNT_THRESHOLD_IN_CENTS) {
            total -= DISCOUNT_IN_CENTS;
        }
        return total;
    }
}

测试类:labs/testing-unit/src/test/java/cn/kb/learn/testing/OrderCalculatorTest.java

java
package cn.kb.learn.testing;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertAll;

class OrderCalculatorTest {
    private final OrderCalculator calculator = new OrderCalculator();

    @Test
    @DisplayName("数量为 1 时不触发满减")
    void should_return_unit_price_when_quantity_is_one() {
        // Arrange
        long unitPrice = 5_000L;
        int quantity = 1;

        // Act
        long payable = calculator.payableInCents(unitPrice, quantity);

        // Assert
        assertEquals(5_000L, payable, "未满减时应等于单价");
    }

    @Test
    @DisplayName("总额刚好达到阈值(边界值)时触发满减")
    void should_apply_discount_when_total_equals_threshold() {
        long payable = calculator.payableInCents(5_000L, 2);
        assertEquals(9_000L, payable, "10000 分应减 1000 分");
    }

    @Test
    @DisplayName("差一分未达阈值时不触发满减(边界值)")
    void should_not_apply_discount_when_total_below_threshold() {
        long payable = calculator.payableInCents(9_999L, 1);
        assertEquals(9_999L, payable, "9999 分不减");
    }

    @Test
    @DisplayName("数量为 0 时抛出 IllegalArgumentException")
    void should_throw_when_quantity_is_not_positive() {
        IllegalArgumentException exception = assertThrows(
                IllegalArgumentException.class,
                () -> calculator.payableInCents(100L, 0));
        assertEquals("数量必须为正", exception.getMessage());
    }

    @Test
    @DisplayName("多个结果字段一次校验")
    void should_verify_multiple_values_at_once() {
        long payable = calculator.payableInCents(6_000L, 2);
        assertAll("满减结果",
                () -> assertEquals(11_000L, payable),
                () -> assertEquals(1_000L, 12_000L - payable));
    }
}

运行方式(Maven):

powershell
mvn -pl testing-unit test

运行方式(无 Maven 时的快速验证):

powershell
javac --release 21 -cp ".libs/junit-platform-console-standalone-1.11.3.jar" -d testing-unit/target/test-classes (Get-ChildItem -Recurse testing-unit/src/main/java, testing-unit/src/test/java -Filter *.java).FullName
java -jar .libs/junit-platform-console-standalone-1.11.3.jar execute --class-path "testing-unit/target/test-classes" --scan-class-path

预期输出(Maven):

text
Tests run: 14, Failures: 0, Errors: 0, Skipped: 0
BUILD SUCCESS

预期输出(Console Launcher):

text
[        14 tests found           ]
[        14 tests successful      ]
[         0 tests failed          ]

数量包含本节正文的 5 个测试,以及编程题与综合任务的参考测试——它们都在 labs/testing-unit 里,可以一起跑。

8. Debug 观察点 ​

  1. 在 long payable = calculator.payableInCents(unitPrice, quantity); 处打断点,用 Debug 模式运行测试(点测试方法旁的绿色三角选 Debug),观察 unitPrice、quantity、返回值 payable;
  2. 故意把断言改错(如把 9_000L 改成 8_000L),观察 IDEA 测试面板的差异展示:Expected / Actual 并排显示,右侧还有 Click to see difference;
  3. 在 assertThrows 的 lambda 里打断点,观察异常抛出前的调用栈——这是排查"为什么抛的不是这个异常"的最快路径。

9. 常见错误 ​

错误 1:断言参数顺序颠倒 ​

java
assertEquals(payable, 9_000L);   // 反了:第一个参数必须是期望值

修复:记牢 assertEquals(期望, 实际, 消息)。顺序反了不会报错,但失败信息里 Expected/Actual 会颠倒,排查时会被误导。

错误 2:没有断言的"假测试" ​

java
@Test
void testCalculate() {
    calculator.payableInCents(100L, 1);   // 只证明不抛异常
}

修复:要么加断言,要么用 assertDoesNotThrow 明确表达"只关注不抛异常"。

错误 3:测试之间共享可变的静态状态 ​

java
class CounterTest {
    static int count = 0;                       // 危险:所有测试共享

    @Test
    void should_increment() {
        count++;
        assertEquals(1, count);                 // 单独跑通过,一起跑失败
    }
}

修复:每个测试自己创建所需对象;共享初始化放到 @BeforeEach(T1-002 生命周期)。

错误 4:用打印代替断言 ​

java
System.out.println("结果是 " + payable);   // 人要看,程序不看

修复:断言才是测试的判断依据;打印只在临时排查时使用,提交前删掉。

10. 动手实验 ​

目标:为一个业务规则写一组完整测试,并故意制造一次失败来理解输出。

步骤:

  1. 把第 7 节的两个类复制到本地项目(或 labs/testing-unit),运行测试,确认 5 个全绿;
  2. 把 should_apply_discount_when_total_equals_threshold 的期望值改成 8_000L,运行并截图失败信息;
  3. 给 OrderCalculator 增加一条规则:总额超过 100000 分时,最多减 2000 分;
  4. 为这条新规则补三个测试:低于上限、等于上限、超过上限;
  5. 改回错误的断言,让全部测试重新变绿。

验收方式:能解释第 2 步失败信息里的 Expected 与 Actual 分别来自哪里。

扩展任务:用同样的 AAA 结构,为 payableInCents 写一个"负数单价"的测试,并说明它与"单价为 0"的测试是否重复。

11. 练习题 ​

基础题

  1. 判断:一个测试方法里应该尽量多写断言,一次覆盖多个场景。(对 / 错)
  2. 填空:AAA 分别指 、、______。
  3. 简答:为什么测试方法名要用 should_xxx_when_yyy 这种长名字?

代码阅读题

java
@Test
void check() {
    assertEquals(calculator.payableInCents(5_000L, 2), 9_000L);
}
  1. 这个测试通过吗?如果失败,失败信息会不会误导人?为什么?

编程题

  1. 为下面的方法写测试,覆盖正常、边界、异常三类情况:
java
public class PasswordValidator {
    /** 密码长度必须 ≥ 8,且至少包含一个数字,否则返回 false */
    public boolean isValid(String password) {
        if (password == null) {
            return false;
        }
        if (password.length() < 8) {
            return false;
        }
        return password.chars().anyMatch(Character::isDigit);
    }
}

综合任务

  1. 给 OrderCalculator 加上"会员折扣":普通用户减 500 分,会员再额外减 500 分(用枚举或布尔参数表示)。要求:
    • 先写测试(描述你期望的行为),再实现;
    • 至少包含 6 个测试,覆盖两条规则的组合与边界;
    • 用 @DisplayName 给每个测试写中文说明。
分级提示

提示 1(方向):第 5 题的边界值是"长度 7 / 8 / 9"和"无数字 / 有数字",异常情况是 null。

提示 2(关键点):第 6 题的组合场景至少有四种:非会员未满减、非会员满减、会员未满减、会员满减;边界是"减完刚好落在阈值上"。

提示 3(接近答案):第 6 题先用 assertEquals 把期望值写出来,再改实现让它变绿——这就是测试驱动开发(TDD)的基本节奏。

12. 参考答案 ​

基础题
  1. 错。一个测试验证一个行为。断言过多会导致第一个失败后其余不执行,且失败信息指向不明确。
  2. Arrange(准备)、Act(执行)、Assert(断言)。
  3. 因为测试失败时会直接打印方法名。名字里带"期望行为 + 条件",就等于让失败信息自带解释,不需要再去读代码。
代码阅读题(第 4 题)

这个测试能通过(值相等),但写法有问题:assertEquals 的两个参数顺序反了。

一旦将来失败,失败信息里 Expected 显示的是实际值、Actual 显示的是期望值,排查方向会完全颠倒。这是最隐蔽的测试缺陷之一——平时全绿,出事时误导。

编程题(第 5 题)
java
package cn.kb.learn.testing;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;

class PasswordValidatorTest {
    private final PasswordValidator validator = new PasswordValidator();

    @Test
    @DisplayName("长度足够且含数字时通过")
    void should_accept_valid_password() {
        assertTrue(validator.isValid("abcd1234"));
    }

    @Test
    @DisplayName("长度为 8 且含数字(边界)时通过")
    void should_accept_when_length_equals_minimum() {
        assertTrue(validator.isValid("1234567a"));
    }

    @Test
    @DisplayName("长度为 7(边界外侧)时拒绝")
    void should_reject_when_length_below_minimum() {
        assertFalse(validator.isValid("123456a"));
    }

    @Test
    @DisplayName("长度足够但没有数字时拒绝")
    void should_reject_when_no_digit() {
        assertFalse(validator.isValid("abcdefgh"));
    }

    @Test
    @DisplayName("密码为 null 时拒绝而不抛异常")
    void should_reject_when_password_is_null() {
        assertFalse(validator.isValid(null));
    }
}
综合任务(第 6 题)

先写测试(节选):

java
@Test
@DisplayName("非会员未满减:应付等于总额")
void should_charge_full_price_for_non_member_below_threshold() {
    assertEquals(5_000L, calculator.payableInCents(5_000L, 1, false));
}

@Test
@DisplayName("非会员满减:总额减 500 分")
void should_apply_member_small_discount_for_non_member() {
    assertEquals(10_500L, calculator.payableInCents(5_500L, 2, false));
}

@Test
@DisplayName("会员满减:总额减 1000 分")
void should_apply_member_large_discount_for_member() {
    assertEquals(10_000L, calculator.payableInCents(5_500L, 2, true));
}

@Test
@DisplayName("会员但未满减:仍减 500 分")
void should_apply_member_discount_even_below_threshold() {
    assertEquals(4_500L, calculator.payableInCents(5_000L, 1, true));
}

参考实现:

java
public long payableInCents(long unitPriceInCents, int quantity, boolean member) {
    if (unitPriceInCents <= 0) {
        throw new IllegalArgumentException("单价必须为正");
    }
    if (quantity <= 0) {
        throw new IllegalArgumentException("数量必须为正");
    }
    long total = unitPriceInCents * quantity;
    if (member) {
        total -= 500L;
    }
    if (total >= DISCOUNT_THRESHOLD_IN_CENTS) {
        total -= 500L;
    }
    return total;
}

注意这里的取舍:先减会员、再判断阈值,还是先判断阈值、再减会员,会导致不同的结果。这类"规则顺序"的问题,正是测试能帮你固定下来的设计决策——先用测试写清期望,再让实现去满足它。

13. 掌握标准 ​

  • [ ] 能独立为一个业务方法写出 AAA 结构的测试;
  • [ ] 会为边界值(阈值、临界长度、空值)补测试;
  • [ ] 能用 @DisplayName 让测试报告可读;
  • [ ] 能识别假测试与巨型测试,并知道怎么拆。

14. 扩展资源 ​

15. 下一步学习 ​

《JUnit 5:生命周期与参数化测试》 讲清 @BeforeEach 与 @ParameterizedTest,让重复的测试数据不再手写多份。

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

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