Skip to content

AssertJ 与断言语义化 ​

T1-009建议学进阶约 45 分钟JDK 21测试专项 A:单元测试 · JUnit 5

1. 本节定位 ​

JUnit 的断言能用,但有两个问题:参数顺序容易写反、失败信息不够具体。AssertJ 用 assertThat(actual).isEqualTo(expected) 的链式写法解决了这两点。

本节讲它的核心用法,以及最重要的一个判断:什么时候用 AssertJ、什么时候 JUnit 的断言就够了——引入第二个断言库不是没有代价的。

2. 学习目标 ​

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

  • 说出 JUnit 断言的三个不足,以及 AssertJ 如何解决;
  • 用 assertThat 写数字、字符串、集合、Optional 的链式断言;
  • 用 assertThatThrownBy / assertThatExceptionOfType 断言异常;
  • 用 extracting + tuple 校验对象集合的多个字段;
  • 用 SoftAssertions 一次收集所有失败;
  • 判断一个断言该用哪个库,避免"同一个测试里混着两种写法"。

3. 前置知识检查 ​

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

看一个"断言参数写反"的经典事故:

java
// 想验证 payable 等于 9000,写反了参数
assertEquals(payable, 9_000L);

这行代码是错的,但它不会报错——因为 assertEquals 在两边相等时通过,而"谁在前谁在后"只影响失败信息的可读性。当真实值不等于 9000 时,报告会说:

text
expected: <9000> but was: <9000>     ← 看起来像 bug 的报告本身

等等,实际情况更麻烦:如果 payable 是 8000,报告会显示 expected: <8000> but was: <9000>——把"实际值"标成了 expected,把"期望值"标成了 actual,排查时容易看反。

AssertJ 的写法绕开了这个问题:

java
assertThat(payable).isEqualTo(9_000L);
// 主语是"被测对象",谓语是"应该满足什么"——读起来就是一句期望

再看失败信息的差距(第 7 节有实测输出):集合断言的失败信息能精确指出"多了哪个、少了哪个",这比"两个列表不相等"有用得多。

5. 核心概念 ​

5.1 引入依赖 ​

xml
<dependency>
  <groupId>org.assertj</groupId>
  <artifactId>assertj-core</artifactId>
  <version>3.26.3</version>
  <scope>test</scope>
</dependency>

5.2 一个入口:assertThat ​

java
import static org.assertj.core.api.Assertions.assertThat;

assertThat(actual).isEqualTo(expected);
assertThat(text).startsWith("abc");
assertThat(list).hasSize(3);

assertThat 是重载的:传数字得到数字断言对象,传集合得到集合断言对象——IDE 的自动补全会根据类型给出可用的方法,所以不用背 API。

5.3 链式断言:一次表达多个约束 ​

java
assertThat(calculator.payableInCents(5_000L, 2))
        .isEqualTo(9_000L)
        .isGreaterThan(8_000L)
        .isLessThanOrEqualTo(10_000L);

等价于 JUnit 的三条断言,但更紧凑。注意:链式断言失败即停(第一个不满足就抛异常),不会继续执行后面的——所以链太长时要考虑用 SoftAssertions(见 5.6)。

5.4 集合断言:AssertJ 最省事的地方 ​

java
assertThat(names)
        .hasSize(3)
        .contains("bob")
        .doesNotContain("dave")
        .allMatch(name -> name.length() >= 3)
        .startsWith("alice")
        .endsWith("charlie");

对比 JUnit:这些校验用 assertEquals 要么写不出来(allMatch),要么要手写循环加 assertTrue——测试代码比被测代码还长。

失败信息的差距(实测):

text
# AssertJ 的集合断言失败
org.opentest4j.AssertionFailedError:

Expecting actual:
  ["alice", "bob", "charlie"]
to contain exactly (and in same order):
  ["alice", "dave", "charlie"]
but some elements were not found:
  ["dave"]
and others were not expected:
  ["bob"]

它直接告诉你"少了 dave、多了 bob"。而等价的 JUnit 写法只会有 expected: <[alice, dave, charlie]> but was: <[alice, bob, charlie]>——还得你自己逐项对比。

这一个理由就足够在生产项目里引入 AssertJ:测试失败时省下的时间,远超过学习成本。

5.5 异常断言 ​

java
// 方式一:一条链完成类型与消息校验
assertThatThrownBy(() -> calculator.payableInCents(0L, 1))
        .isInstanceOf(IllegalArgumentException.class)
        .hasMessage("单价必须为正")
        .hasMessageContaining("单价");

// 方式二:需要更多控制时用这个
assertThatExceptionOfType(IllegalArgumentException.class)
        .isThrownBy(() -> calculator.payableInCents(100L, 0))
        .withMessage("数量必须为正");

与 assertThrows 的分工:

assertThrowsassertThatThrownBy
拿到异常对象✓ 直接返回需要 .actual() 或改用 catchThrowable
链式校验需要额外 assertAll✓ 一条链
需要自定义失败消息✓ 支持支持(as(...))

实践建议:需要继续操作异常对象(读自定义字段)用 assertThrows;只做类型与消息校验用 assertThatThrownBy。

5.6 SoftAssertions:一次看到所有失败 ​

java
SoftAssertions softly = new SoftAssertions();
softly.assertThat(calculator.payableInCents(5_000L, 1)).isEqualTo(5_000L);
softly.assertThat(calculator.payableInCents(5_000L, 2)).isEqualTo(9_000L);
softly.assertThat(calculator.payableInCents(100L, 1)).isEqualTo(100L);
softly.assertAll();                 // 必须调用,否则失败不会报告

它解决的问题:硬断言在第一个失败处停止,于是"改一次、跑一次、发现下一个错"循环多轮。软断言把同一批校验的所有失败一次性汇总。

两个注意点:

  1. 必须调用 assertAll()——忘了它,测试永远通过(这是个静默陷阱);
  2. 别滥用:一个测试里塞 20 条软断言,失败时你面对的是一屏输出。软断言适合"同一对象的多个并列属性",比如"校验一个订单的金额、状态、明细数量"。

5.7 什么时候用哪个 ​

场景建议
单个值相等JUnit assertEquals 就够了
集合/字符串的多条件校验AssertJ(省事且失败信息好)
异常断言需要异常对象用 assertThrows,否则 assertThatThrownBy
校验多个并列属性SoftAssertions
项目里已经有 AssertJ统一用它,别在一个测试里混两种风格

最后一行最重要:两种写法混用会让测试文件读起来割裂,代码审查时也难统一标准。新项目建议统一用 AssertJ;已有项目保持现状,随改随迁移。

6. 递进示例 ​

6.1 最小示例 ​

java
assertThat(calculator.payableInCents(5_000L, 1)).isEqualTo(5_000L);

6.2 业务示例:校验对象集合的多个字段 ​

java
assertThat(products)
        .extracting(Product::name, Product::priceInCents)
        .containsExactly(
                tuple("键盘", 19_900L),
                tuple("鼠标", 9_900L));

这一条断言同时校验了数量、顺序、每个对象的两个字段——用 JUnit 写需要循环 + 多条断言。

6.3 反例:链太长,失败时看不清 ​

java
assertThat(order)
        .extracting(Order::id, Order::status, Order::totalInCents, Order::createdAt)
        .containsExactly(tuple("O1", "PAID", 19_900L, instant));

问题:失败信息会告诉你"这一整条不满足",但四个字段里究竟哪个错了要自己找。

修复:拆成几条断言(或对易错的字段单独断言):

java
assertThat(order.id()).isEqualTo("O1");
assertThat(order.status()).isEqualTo("PAID");
assertThat(order.totalInCents()).isEqualTo(19_900L);

7. 完整可运行代码 ​

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

java
package cn.kb.learn.testing;

import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.tuple;

@DisplayName("AssertJ 语义化断言")
class AssertjDemoTest {
    private final OrderCalculator calculator = new OrderCalculator();

    @Test
    @DisplayName("数字断言可以链式叠加")
    void numeric_assertions() {
        assertThat(calculator.payableInCents(5_000L, 2))
                .isEqualTo(9_000L)
                .isGreaterThan(8_000L)
                .isLessThanOrEqualTo(10_000L);
    }

    @Test
    @DisplayName("异常断言:一条链完成类型与消息校验")
    void exception_assertions() {
        assertThatThrownBy(() -> calculator.payableInCents(0L, 1))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("单价必须为正")
                .hasMessageContaining("单价");

        assertThatExceptionOfType(IllegalArgumentException.class)
                .isThrownBy(() -> calculator.payableInCents(100L, 0))
                .withMessage("数量必须为正");
    }

    @Test
    @DisplayName("集合断言:一次校验大小、内容与顺序")
    void collection_assertions() {
        List<String> names = List.of("alice", "bob", "charlie");
        assertThat(names)
                .hasSize(3)
                .contains("bob")
                .doesNotContain("dave")
                .allMatch(name -> name.length() >= 3)
                .startsWith("alice")
                .endsWith("charlie");
    }

    @Test
    @DisplayName("从对象集合中提取字段做元组比对")
    void tuple_assertions() {
        record Product(String name, long priceInCents) {
        }

        List<Product> products = List.of(
                new Product("键盘", 19_900L),
                new Product("鼠标", 9_900L));

        assertThat(products)
                .extracting(Product::name, Product::priceInCents)
                .containsExactly(
                        tuple("键盘", 19_900L),
                        tuple("鼠标", 9_900L));
    }

    @Test
    @DisplayName("Optional 断言")
    void optional_assertions() {
        assertThat(Optional.of("value")).isPresent().contains("value");
        assertThat(Optional.empty()).isEmpty();
    }

    @Test
    @DisplayName("SoftAssertions 收集全部失败,而不是首个失败就停止")
    void soft_assertions_collect_all() {
        SoftAssertions softly = new SoftAssertions();
        softly.assertThat(calculator.payableInCents(5_000L, 1)).isEqualTo(5_000L);
        softly.assertThat(calculator.payableInCents(5_000L, 2)).isEqualTo(9_000L);
        softly.assertThat(calculator.payableInCents(100L, 1)).isEqualTo(100L);
        softly.assertAll();
    }
}

运行命令:

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

预期输出:

text
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.216 s
  -- in cn.kb.learn.testing.AssertjDemoTest

为了看清失败信息的差距,把 collection_assertions 里的 "bob" 改成 "dave" 再跑一次:

text
org.opentest4j.AssertionFailedError:

Expecting actual:
  ["alice", "bob", "charlie"]
to contain exactly (and in same order):
  ["alice", "dave", "charlie"]
but some elements were not found:
  ["dave"]
and others were not expected:
  ["bob"]

换成 JUnit 的 assertEquals 写同样的校验,失败信息只有:

text
org.opentest4j.AssertionFailedError: expected: <[alice, dave, charlie]> but was: <[alice, bob, charlie]>

"少了 dave、多了 bob"这句话,AssertJ 直接替你说了。

8. Debug 观察点 ​

  1. 在 assertThat(names) 处按住 Ctrl 点击 assertThat,观察它重载了非常多个版本(assertThat(List) / assertThat(String) / assertThat(int) …)——这是它能提供类型专属断言的原因;
  2. 在链式断言的 .allMatch(...) 处断点,观察 Lambda 对每个元素执行一次——断言内部也是普通代码;
  3. 把 SoftAssertions 的 assertAll() 注释掉,重跑——观察测试变成通过(即使有断言不满足)。这个坑要亲手踩一次;
  4. 在 assertThatThrownBy(...) 处 F7 进入,观察它内部如何捕获异常并包装成断言对象。

9. 常见错误 ​

错误 1:忘记调用 softly.assertAll() ​

java
SoftAssertions softly = new SoftAssertions();
softly.assertThat(value).isEqualTo(9_000L);
// 忘了 assertAll():失败不会报告,测试永远通过

这是静默失效,比断言写错更危险。防御办法:用 SoftAssertions 的 try-with-resources 形式(AssertJ 3.16+ 支持自动 assertAll):

java
try (SoftAssertions softly = new SoftAssertions()) {
    softly.assertThat(a).isEqualTo(1);
    softly.assertThat(b).isEqualTo(2);
}

错误 2:在同一个测试里混用两种断言 ​

java
assertEquals(9_000L, payable);
assertThat(names).hasSize(3);

问题:读代码的人要在两套语义间切换。修复:一个项目(至少一个测试类)统一一种风格。

错误 3:用 isEqualTo 比较不该比较的东西 ​

java
assertThat(order).isEqualTo(expectedOrder);     // 依赖 order 的 equals 实现

问题:如果 Order 没有重写 equals,这比较的是引用,永远不相等(除非同一个对象)。修复:用 extracting 比较真正关心的字段,或明确知道 equals 的语义(《封装》)。

顺带一个提醒:record 自动生成 equals,所以对 record 用 isEqualTo 是安全的 ✓

错误 4:链式断言里混入有副作用的操作 ​

java
assertThat(list.add("x")).isTrue();      // 断言里改了状态

修复:先执行操作,再断言结果——测试的三个阶段(准备/执行/断言)不要在断言里混起来。

错误 5:把 assertThat 的参数当成期望值 ​

java
assertThat(9_000L).isEqualTo(payable);   // 反了

不会失败,但失败信息会反过来。记住句式:assertThat(实际值)。

10. 动手实验 ​

目标:把一组 JUnit 断言改写成 AssertJ,并对比失败信息。

步骤:

  1. 运行第 7 节的命令,核对 6 个测试通过;
  2. 按第 7 节的说明,把 "bob" 改成 "dave",记录 AssertJ 的失败信息;再用 assertEquals 写同样的校验,记录失败信息——把两条信息粘到笔记里(完成后改回来);
  3. 给自己写的 PasswordValidator(T1-001 的练习)或 Cart 写一组 AssertJ 断言,至少覆盖:字符串断言、集合断言、异常断言;
  4. 用 extracting + tuple 校验一个 List<Order>(Order 有 id、状态、金额三个字段);
  5. 把第 4 步的断言拆成 SoftAssertions 版本,故意让两条不满足,观察报告里两条失败都出现。

验收方式:能说出第 5 步与"硬断言链"在失败时的差别。

扩展任务:给一个"返回自定义异常"的方法(异常里带错误码字段)写断言,比较 assertThrows + 字段断言 与 assertThatThrownBy 两种写法,说明各自的适用场景。

11. 练习题 ​

基础题

  1. 判断:assertThat(actual).isEqualTo(expected) 中参数顺序写反会导致严重的失败信息误导。(对 / 错)
  2. 填空:AssertJ 的入口方法名是 ______;软断言最后必须调用 ______。
  3. 简答:为什么说 AssertJ 的集合断言比 JUnit 的 assertEquals 更有价值?

代码阅读题

java
List<String> actual = List.of("a", "b", "c");

assertThat(actual)
        .hasSize(3)
        .contains("b")
        .doesNotContain("z");

assertThat(actual)
        .containsExactly("a", "c", "b");
  1. 两段断言分别会成功还是失败?
  2. contains 与 containsExactly 的语义差别是什么?

编程题

  1. 为一个"用户查询服务"写 AssertJ 断言:
    • 被测方法 Optional<User> findById(String id)(User 是 record User(String id, String name, int age));
    • 覆盖:存在的用户(断言三个字段)、不存在的用户(断言返回空 Optional);
    • 用 assertThatThrownBy 断言 findById(null) 抛出的 IllegalArgumentException 及其消息;
    • 用 SoftAssertions 一次校验一个用户列表的大小、顺序与每个用户的名字。

综合任务

  1. 为一个"订单结算"写一组"失败信息友好"的测试:
    • 被测类 Checkout:Receipt checkout(Cart cart, String couponCode),Receipt 是 record(含订单号、原始金额、折扣金额、应付金额);
    • 用 AssertJ 写至少 5 条断言,覆盖:金额计算、折扣生效、折扣不生效、无券场景;
    • 用 extracting 校验 List<Receipt> 的多个字段;
    • 要求:故意让每一条断言都失败一次(改期望值),记录失败信息,然后判断"哪一条的失败信息最能帮你定位问题",并说明理由;
    • 说明:如果你要把这套测试交给一个不熟悉业务的同事,哪些断言的信息量最大?
分级提示

提示 1(方向):第 4 题的关键是 contains 不关心顺序,而 containsExactly 关心。

提示 2(关键点):第 6 题的 Optional<User> 断言可以用 .contains(new User(...))(依赖 record 的 equals)或 .get().extracting(...)。

提示 3(接近答案):第 7 题的"失败信息校验"重点在:金额类断言如果只写 isEqualTo,失败时只有两个数字;加上 as("...") 描述或拆成多条断言,信息量会大得多。

12. 参考答案 ​

基础题
  1. 错。参数顺序写反不会让测试失败(相等判断是对称的),它只会让失败信息里的期望值与实际值互换,从而误导排查方向。AssertJ 的 assertThat(actual).isEqualTo(expected) 句式让"谁是实际值"一目了然。
  2. assertThat;assertAll()。
  3. 因为集合校验用 assertEquals 只能得到"两个列表不相等",而 AssertJ 会明确指出"少了哪个、多了哪个、顺序对不对"。测试失败时的排查成本主要花在"定位差异"上,AssertJ 把这一步自动化了。此外 allMatch、extracting 这类断言在 JUnit 里根本写不出来(要手写循环)。
代码阅读题(第 4、5 题)

第 4 题:

  • 第一段成功:大小为 3、包含 "b"、不包含 "z"——三个条件都满足;
  • 第二段失败:containsExactly 要求顺序也完全一致,实际是 ["a", "b", "c"],期望是 ["a", "c", "b"]。

第 5 题:

方法语义
contains(...)包含这些元素即可,不关心顺序,也不关心是否还有其他元素
containsExactly(...)恰好是这些元素,且顺序一致——多一个、少一个、顺序不同都失败
containsExactlyInAnyOrder(...)恰好是这些元素,但不要求顺序

实践建议:只有顺序是契约的一部分时才用 containsExactly(比如"按时间倒序返回列表")。否则用 containsExactlyInAnyOrder——否则实现里换了个排序方法,测试就会无意义地变红。

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

import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.List;
import java.util.Map;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.tuple;

@DisplayName("用户查询服务的 AssertJ 断言")
class UserQueryAssertjTest {
    record User(String id, String name, int age) {
    }

    private static final Map<String, User> DATABASE = Map.of(
            "u1", new User("u1", "张三", 30),
            "u2", new User("u2", "李四", 25));

    static Optional<User> findById(String id) {
        if (id == null) {
            throw new IllegalArgumentException("id 不能为空");
        }
        return Optional.ofNullable(DATABASE.get(id));
    }

    @Test
    @DisplayName("存在的用户:断言三个字段")
    void finds_existing_user() {
        assertThat(findById("u1"))
                .isPresent()
                .get()
                .extracting(User::id, User::name, User::age)
                .containsExactly("u1", "张三", 30);
    }

    @Test
    @DisplayName("不存在的用户:返回空 Optional")
    void returns_empty_for_missing_user() {
        assertThat(findById("u404")).isEmpty();
    }

    @Test
    @DisplayName("id 为 null 时抛出 IllegalArgumentException")
    void rejects_null_id() {
        assertThatThrownBy(() -> findById(null))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("id 不能为空");
    }

    @Test
    @DisplayName("软断言一次校验列表的大小、顺序与每个用户的名字")
    void soft_assertions_on_user_list() {
        List<User> users = List.of(DATABASE.get("u1"), DATABASE.get("u2"));

        try (SoftAssertions softly = new SoftAssertions()) {
            softly.assertThat(users).hasSize(2);
            softly.assertThat(users)
                    .extracting(User::id)
                    .containsExactly("u1", "u2");
            softly.assertThat(users)
                    .extracting(User::name)
                    .containsExactly("张三", "李四");
        }
    }
}

两个值得注意的写法:

  1. .isPresent().get().extracting(...)——get() 把 Optional 断言切换成"值断言",之后就能用记录字段提取。这是"既有值、又要校验内容"的紧凑写法;
  2. 软断言用了 try-with-resources:即使中途抛异常,close() 也会触发 assertAll()——避免"忘了 assertAll 导致测试静默通过"的坑。
综合任务(第 7 题)
java
package cn.kb.learn.testing;

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

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;

@DisplayName("订单结算:面向排查的断言设计")
class CheckoutAssertjTest {
    record Cart(long unitPriceInCents, int quantity) {
    }

    record Receipt(String orderId, long originalInCents, long discountInCents, long payableInCents) {
    }

    static Receipt checkout(Cart cart, String couponCode) {
        long original = cart.unitPriceInCents() * cart.quantity();
        long discount = 0L;
        if ("SAVE10".equals(couponCode) && original >= 10_000L) {
            discount = 1_000L;
        }
        return new Receipt("ORDER-1", original, discount, Math.max(original - discount, 0L));
    }

    @Test
    @DisplayName("无券:应付等于原始金额")
    void no_coupon() {
        Receipt receipt = checkout(new Cart(5_000L, 2), null);

        assertThat(receipt.originalInCents()).as("原始金额").isEqualTo(10_000L);
        assertThat(receipt.discountInCents()).as("折扣金额").isZero();
        assertThat(receipt.payableInCents()).as("应付金额").isEqualTo(10_000L);
    }

    @Test
    @DisplayName("有券且达阈值:抵扣 1000 分")
    void coupon_applied() {
        Receipt receipt = checkout(new Cart(5_000L, 2), "SAVE10");

        assertThat(receipt.discountInCents()).as("折扣金额").isEqualTo(1_000L);
        assertThat(receipt.payableInCents()).as("应付金额").isEqualTo(9_000L);
    }

    @Test
    @DisplayName("有券但未达阈值:不抵扣")
    void coupon_below_threshold() {
        Receipt receipt = checkout(new Cart(4_000L, 2), "SAVE10");

        assertThat(receipt.discountInCents()).as("未达阈值时折扣应为 0").isZero();
    }

    @Test
    @DisplayName("多笔订单:一次校验订单号与应付金额")
    void multiple_receipts() {
        List<Receipt> receipts = List.of(
                checkout(new Cart(5_000L, 2), "SAVE10"),
                checkout(new Cart(1_000L, 1), null));

        assertThat(receipts)
                .extracting(Receipt::payableInCents, Receipt::discountInCents)
                .containsExactly(
                        tuple(9_000L, 1_000L),
                        tuple(1_000L, 0L));
    }

    @Test
    @DisplayName("金额永不为负")
    void payable_never_negative() {
        Receipt receipt = checkout(new Cart(500L, 1), "SAVE10");
        assertThat(receipt.payableInCents()).as("应付金额不应为负").isGreaterThanOrEqualTo(0L);
    }
}

哪一条断言信息量最大:

断言信息量原因
extracting(...).containsExactly(tuple(...))最大失败时直接指出是第几笔订单、哪个字段不对
isEqualTo(9_000L)中只有两个数字,需要自行推断"这算不算对"
isZero()中语义明确("应该没有折扣")

给不熟悉业务的同事看时,信息量最大的是两类:① .as("折扣金额") 这种把业务语义写进断言的描述;② extracting(...).containsExactly(...) 这种直接暴露期望数据结构的断言。

由此得出一条实践建议:

断言失败信息里应该包含"这块断言在验证什么业务规则"。方法名和 @DisplayName 能说清"场景",但具体到某一行断言,.as("...") 是唯一能把语义带进报告的手段。

这也是"测试即文档"最实际的落点:当 CI 报红时,一个不熟悉代码的人能否只看失败信息就知道"哪里不符合预期"。

13. 掌握标准 ​

  • [ ] 能用 assertThat 写数字、字符串、集合、Optional 的链式断言;
  • [ ] 能用 assertThatThrownBy / assertThatExceptionOfType 断言异常;
  • [ ] 会用 extracting + tuple 校验对象集合;
  • [ ] 知道 SoftAssertions 必须 assertAll(),并知道 try-with-resources 的写法;
  • [ ] 能说出 contains / containsExactly / containsExactlyInAnyOrder 的区别;
  • [ ] 知道"一个项目统一一种断言风格"比"选哪种"更重要。

14. 扩展资源 ​

15. 下一步学习 ​

《Maven 测试生命周期与测试目录约定》:把测试放到 Maven 的生命周期里跑起来。

后续知识

暂无

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

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