主题
界面
主题
JUnit 的断言能用,但有两个问题:参数顺序容易写反、失败信息不够具体。AssertJ 用 assertThat(actual).isEqualTo(expected) 的链式写法解决了这两点。
本节讲它的核心用法,以及最重要的一个判断:什么时候用 AssertJ、什么时候 JUnit 的断言就够了——引入第二个断言库不是没有代价的。
完成本节后,你应该能够:
assertThat 写数字、字符串、集合、Optional 的链式断言;assertThatThrownBy / assertThatExceptionOfType 断言异常;extracting + tuple 校验对象集合的多个字段;SoftAssertions 一次收集所有失败;equals 的语义吗?(《equals 与 hashCode》)看一个"断言参数写反"的经典事故:
// 想验证 payable 等于 9000,写反了参数
assertEquals(payable, 9_000L);这行代码是错的,但它不会报错——因为 assertEquals 在两边相等时通过,而"谁在前谁在后"只影响失败信息的可读性。当真实值不等于 9000 时,报告会说:
expected: <9000> but was: <9000> ← 看起来像 bug 的报告本身等等,实际情况更麻烦:如果 payable 是 8000,报告会显示 expected: <8000> but was: <9000>——把"实际值"标成了 expected,把"期望值"标成了 actual,排查时容易看反。
AssertJ 的写法绕开了这个问题:
assertThat(payable).isEqualTo(9_000L);
// 主语是"被测对象",谓语是"应该满足什么"——读起来就是一句期望再看失败信息的差距(第 7 节有实测输出):集合断言的失败信息能精确指出"多了哪个、少了哪个",这比"两个列表不相等"有用得多。
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>import static org.assertj.core.api.Assertions.assertThat;
assertThat(actual).isEqualTo(expected);
assertThat(text).startsWith("abc");
assertThat(list).hasSize(3);assertThat 是重载的:传数字得到数字断言对象,传集合得到集合断言对象——IDE 的自动补全会根据类型给出可用的方法,所以不用背 API。
assertThat(calculator.payableInCents(5_000L, 2))
.isEqualTo(9_000L)
.isGreaterThan(8_000L)
.isLessThanOrEqualTo(10_000L);等价于 JUnit 的三条断言,但更紧凑。注意:链式断言失败即停(第一个不满足就抛异常),不会继续执行后面的——所以链太长时要考虑用 SoftAssertions(见 5.6)。
assertThat(names)
.hasSize(3)
.contains("bob")
.doesNotContain("dave")
.allMatch(name -> name.length() >= 3)
.startsWith("alice")
.endsWith("charlie");对比 JUnit:这些校验用 assertEquals 要么写不出来(allMatch),要么要手写循环加 assertTrue——测试代码比被测代码还长。
失败信息的差距(实测):
# 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:测试失败时省下的时间,远超过学习成本。
// 方式一:一条链完成类型与消息校验
assertThatThrownBy(() -> calculator.payableInCents(0L, 1))
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("单价必须为正")
.hasMessageContaining("单价");
// 方式二:需要更多控制时用这个
assertThatExceptionOfType(IllegalArgumentException.class)
.isThrownBy(() -> calculator.payableInCents(100L, 0))
.withMessage("数量必须为正");与 assertThrows 的分工:
assertThrows | assertThatThrownBy | |
|---|---|---|
| 拿到异常对象 | ✓ 直接返回 | 需要 .actual() 或改用 catchThrowable |
| 链式校验 | 需要额外 assertAll | ✓ 一条链 |
| 需要自定义失败消息 | ✓ 支持 | 支持(as(...)) |
实践建议:需要继续操作异常对象(读自定义字段)用 assertThrows;只做类型与消息校验用 assertThatThrownBy。
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(); // 必须调用,否则失败不会报告它解决的问题:硬断言在第一个失败处停止,于是"改一次、跑一次、发现下一个错"循环多轮。软断言把同一批校验的所有失败一次性汇总。
两个注意点:
assertAll()——忘了它,测试永远通过(这是个静默陷阱);| 场景 | 建议 |
|---|---|
| 单个值相等 | JUnit assertEquals 就够了 |
| 集合/字符串的多条件校验 | AssertJ(省事且失败信息好) |
| 异常断言 | 需要异常对象用 assertThrows,否则 assertThatThrownBy |
| 校验多个并列属性 | SoftAssertions |
| 项目里已经有 AssertJ | 统一用它,别在一个测试里混两种风格 |
最后一行最重要:两种写法混用会让测试文件读起来割裂,代码审查时也难统一标准。新项目建议统一用 AssertJ;已有项目保持现状,随改随迁移。
assertThat(calculator.payableInCents(5_000L, 1)).isEqualTo(5_000L);assertThat(products)
.extracting(Product::name, Product::priceInCents)
.containsExactly(
tuple("键盘", 19_900L),
tuple("鼠标", 9_900L));这一条断言同时校验了数量、顺序、每个对象的两个字段——用 JUnit 写需要循环 + 多条断言。
assertThat(order)
.extracting(Order::id, Order::status, Order::totalInCents, Order::createdAt)
.containsExactly(tuple("O1", "PAID", 19_900L, instant));问题:失败信息会告诉你"这一整条不满足",但四个字段里究竟哪个错了要自己找。
修复:拆成几条断言(或对易错的字段单独断言):
assertThat(order.id()).isEqualTo("O1");
assertThat(order.status()).isEqualTo("PAID");
assertThat(order.totalInCents()).isEqualTo(19_900L);文件:labs/testing-unit/src/test/java/cn/kb/learn/testing/AssertjDemoTest.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();
}
}运行命令:
cd labs
./mvnw -pl testing-unit test -Dtest=AssertjDemoTest预期输出:
Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.216 s
-- in cn.kb.learn.testing.AssertjDemoTest为了看清失败信息的差距,把 collection_assertions 里的 "bob" 改成 "dave" 再跑一次:
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 写同样的校验,失败信息只有:
org.opentest4j.AssertionFailedError: expected: <[alice, dave, charlie]> but was: <[alice, bob, charlie]>"少了 dave、多了 bob"这句话,AssertJ 直接替你说了。
assertThat(names) 处按住 Ctrl 点击 assertThat,观察它重载了非常多个版本(assertThat(List) / assertThat(String) / assertThat(int) …)——这是它能提供类型专属断言的原因;.allMatch(...) 处断点,观察 Lambda 对每个元素执行一次——断言内部也是普通代码;SoftAssertions 的 assertAll() 注释掉,重跑——观察测试变成通过(即使有断言不满足)。这个坑要亲手踩一次;assertThatThrownBy(...) 处 F7 进入,观察它内部如何捕获异常并包装成断言对象。softly.assertAll() SoftAssertions softly = new SoftAssertions();
softly.assertThat(value).isEqualTo(9_000L);
// 忘了 assertAll():失败不会报告,测试永远通过这是静默失效,比断言写错更危险。防御办法:用 SoftAssertions 的 try-with-resources 形式(AssertJ 3.16+ 支持自动 assertAll):
try (SoftAssertions softly = new SoftAssertions()) {
softly.assertThat(a).isEqualTo(1);
softly.assertThat(b).isEqualTo(2);
}assertEquals(9_000L, payable);
assertThat(names).hasSize(3);问题:读代码的人要在两套语义间切换。修复:一个项目(至少一个测试类)统一一种风格。
isEqualTo 比较不该比较的东西 assertThat(order).isEqualTo(expectedOrder); // 依赖 order 的 equals 实现问题:如果 Order 没有重写 equals,这比较的是引用,永远不相等(除非同一个对象)。修复:用 extracting 比较真正关心的字段,或明确知道 equals 的语义(《封装》)。
顺带一个提醒:record 自动生成 equals,所以对 record 用 isEqualTo 是安全的 ✓
assertThat(list.add("x")).isTrue(); // 断言里改了状态修复:先执行操作,再断言结果——测试的三个阶段(准备/执行/断言)不要在断言里混起来。
assertThat 的参数当成期望值 assertThat(9_000L).isEqualTo(payable); // 反了不会失败,但失败信息会反过来。记住句式:assertThat(实际值)。
目标:把一组 JUnit 断言改写成 AssertJ,并对比失败信息。
步骤:
"bob" 改成 "dave",记录 AssertJ 的失败信息;再用 assertEquals 写同样的校验,记录失败信息——把两条信息粘到笔记里(完成后改回来);PasswordValidator(T1-001 的练习)或 Cart 写一组 AssertJ 断言,至少覆盖:字符串断言、集合断言、异常断言;extracting + tuple 校验一个 List<Order>(Order 有 id、状态、金额三个字段);SoftAssertions 版本,故意让两条不满足,观察报告里两条失败都出现。验收方式:能说出第 5 步与"硬断言链"在失败时的差别。
扩展任务:给一个"返回自定义异常"的方法(异常里带错误码字段)写断言,比较 assertThrows + 字段断言 与 assertThatThrownBy 两种写法,说明各自的适用场景。
基础题
assertThat(actual).isEqualTo(expected) 中参数顺序写反会导致严重的失败信息误导。(对 / 错)assertEquals 更有价值?代码阅读题
List<String> actual = List.of("a", "b", "c");
assertThat(actual)
.hasSize(3)
.contains("b")
.doesNotContain("z");
assertThat(actual)
.containsExactly("a", "c", "b");contains 与 containsExactly 的语义差别是什么?编程题
Optional<User> findById(String id)(User 是 record User(String id, String name, int age));assertThatThrownBy 断言 findById(null) 抛出的 IllegalArgumentException 及其消息;SoftAssertions 一次校验一个用户列表的大小、顺序与每个用户的名字。综合任务
Checkout:Receipt checkout(Cart cart, String couponCode),Receipt 是 record(含订单号、原始金额、折扣金额、应付金额);extracting 校验 List<Receipt> 的多个字段;提示 1(方向):第 4 题的关键是 contains 不关心顺序,而 containsExactly 关心。
提示 2(关键点):第 6 题的 Optional<User> 断言可以用 .contains(new User(...))(依赖 record 的 equals)或 .get().extracting(...)。
提示 3(接近答案):第 7 题的"失败信息校验"重点在:金额类断言如果只写 isEqualTo,失败时只有两个数字;加上 as("...") 描述或拆成多条断言,信息量会大得多。
assertThat(actual).isEqualTo(expected) 句式让"谁是实际值"一目了然。assertThat;assertAll()。assertEquals 只能得到"两个列表不相等",而 AssertJ 会明确指出"少了哪个、多了哪个、顺序对不对"。测试失败时的排查成本主要花在"定位差异"上,AssertJ 把这一步自动化了。此外 allMatch、extracting 这类断言在 JUnit 里根本写不出来(要手写循环)。第 4 题:
containsExactly 要求顺序也完全一致,实际是 ["a", "b", "c"],期望是 ["a", "c", "b"]。第 5 题:
| 方法 | 语义 |
|---|---|
contains(...) | 包含这些元素即可,不关心顺序,也不关心是否还有其他元素 |
containsExactly(...) | 恰好是这些元素,且顺序一致——多一个、少一个、顺序不同都失败 |
containsExactlyInAnyOrder(...) | 恰好是这些元素,但不要求顺序 |
实践建议:只有顺序是契约的一部分时才用 containsExactly(比如"按时间倒序返回列表")。否则用 containsExactlyInAnyOrder——否则实现里换了个排序方法,测试就会无意义地变红。
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("张三", "李四");
}
}
}两个值得注意的写法:
.isPresent().get().extracting(...)——get() 把 Optional 断言切换成"值断言",之后就能用记录字段提取。这是"既有值、又要校验内容"的紧凑写法;close() 也会触发 assertAll()——避免"忘了 assertAll 导致测试静默通过"的坑。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 报红时,一个不熟悉代码的人能否只看失败信息就知道"哪里不符合预期"。
assertThat 写数字、字符串、集合、Optional 的链式断言;assertThatThrownBy / assertThatExceptionOfType 断言异常;extracting + tuple 校验对象集合;SoftAssertions 必须 assertAll(),并知道 try-with-resources 的写法;contains / containsExactly / containsExactlyInAnyOrder 的区别;assertThrows 的写法对比《Maven 测试生命周期与测试目录约定》:把测试放到 Maven 的生命周期里跑起来。
暂无