Hamcrest 与 AssertJ 深度对比实战——现代 Java 断言库的完整选型指南
Hamcrest 与 AssertJ 深度对比实战——现代 Java 断言库的完整选型指南
适读人群:只用过 JUnit 原生 assertEquals、想让测试断言更具表达力的工程师,以及纠结在 Hamcrest 和 AssertJ 之间的开发者 | 阅读时长:约13分钟 | 核心价值:彻底搞清楚两个断言库的差异,知道什么时候用哪个
那个让我从 Hamcrest 切换到 AssertJ 的经历
2019年我在一个项目里推广 Hamcrest,把测试里的 assertEquals 全换成了 assertThat:
// 原来
assertEquals(expected, actual);
// 替换为
assertThat(actual, is(equalTo(expected)));结果一个同事来问我:"is(equalTo(expected)),这个 is 和 equalTo 有什么区别?为什么不直接用 equalTo?"
我解释了半天,最后说"is 只是个语法糖,可以忽略它"。
同事说:"那写这么复杂干什么?"
那一刻我开始怀疑 Hamcrest 的价值。后来我试了 AssertJ,发现它的设计思路完全不同:流式 API,IDE 自动补全驱动,学习曲线更平缓,断言更直观。
现在我的明确立场:新项目选 AssertJ,Hamcrest 只在有历史原因的项目里保留。
Hamcrest:匹配器组合模式
Hamcrest 的核心理念是"可组合的匹配器"(Composable Matcher)。
// JUnit 4/5 原生导入 Hamcrest
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.*;
// 基础匹配器
assertThat(result, is(42));
assertThat(name, is("张三"));
assertThat(list, hasSize(3));
assertThat(str, containsString("hello"));
assertThat(number, greaterThan(10));
// 组合匹配器
assertThat(value, allOf(greaterThan(0), lessThan(100)));
assertThat(value, anyOf(is(1), is(2), is(3)));
assertThat(value, not(nullValue()));
// 集合匹配器
assertThat(list, hasItem("apple"));
assertThat(list, hasItems("apple", "banana"));
assertThat(list, containsInAnyOrder("banana", "apple", "cherry"));
// 对象属性
assertThat(user, hasProperty("name", equalTo("张三")));
assertThat(user, hasProperty("age", greaterThan(18)));Hamcrest 的优势:
- 匹配器可组合:
allOf()、anyOf()支持复杂组合 - 可扩展:自定义 Matcher 比较成熟
- Spring 集成深:MockMvc 的
andExpect就是基于 Hamcrest - XML/JSON 匹配:
XmlMatchers、JsonMatchers等扩展库
Hamcrest 的问题:
- IDE 自动补全差:
assertThat(x, is(equalTo(...)))需要你知道正确的 Matcher 名 - 错误信息不够清晰:断言失败时,信息有时不够直观
- 语法啰嗦:
is(equalTo(x))等冗余语法
AssertJ:流式断言 API
AssertJ 采用流式 API(Fluent API)设计,所有断言都从 assertThat(actual) 开始,IDE 自动补全引导你完成断言。
import static org.assertj.core.api.Assertions.*;
// 基础断言
assertThat(42).isEqualTo(42);
assertThat("Hello World").contains("World").startsWith("Hello").endsWith("World");
assertThat(100).isBetween(1, 200).isGreaterThan(50);
assertThat(value).isNotNull().isInstanceOf(String.class);
// 集合断言
assertThat(list)
.hasSize(3)
.contains("apple", "banana")
.doesNotContain("grape")
.containsExactly("apple", "banana", "cherry") // 精确顺序
.allMatch(item -> item.length() > 3); // 自定义条件
// 对象字段断言
assertThat(user)
.isNotNull()
.extracting(User::getName, User::getAge)
.containsExactly("张三", 25);
// 嵌套属性
assertThat(order)
.extracting("user.name", "amount", "status")
.containsExactly("张三", new BigDecimal("100"), OrderStatus.PENDING);
// 异常断言(流式)
assertThatThrownBy(() -> service.doSomething(null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("参数不能为空");AssertJ 的聚合断言:所有失败都报告
// 聚合断言:不会在第一个失败处停止,而是收集所有失败
assertSoftly(softly -> {
softly.assertThat(order.getId()).isNotNull();
softly.assertThat(order.getStatus()).isEqualTo(OrderStatus.PAID);
softly.assertThat(order.getAmount()).isGreaterThan(BigDecimal.ZERO);
softly.assertThat(order.getUserId()).isEqualTo(1L);
});
// 如果多个断言失败,会同时报告所有失败,而不是只报第一个等价于 JUnit 5 的 assertAll,但语法更流畅。
详细对比:相同场景,不同写法
场景一:验证对象列表
// Hamcrest
assertThat(orders, hasSize(3));
assertThat(orders, everyItem(hasProperty("status", equalTo(OrderStatus.PENDING))));
assertThat(orders, hasItem(hasProperty("amount", greaterThan(new BigDecimal("100")))));
// AssertJ
assertThat(orders)
.hasSize(3)
.allSatisfy(order -> assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING))
.anySatisfy(order -> assertThat(order.getAmount()).isGreaterThan(new BigDecimal("100")));场景二:验证集合包含特定字段值
// Hamcrest:需要组合 hasItem + hasProperty,比较啰嗦
assertThat(users, hasItem(allOf(
hasProperty("name", equalTo("张三")),
hasProperty("age", equalTo(25))
)));
// AssertJ:更自然
assertThat(users)
.extracting(User::getName, User::getAge)
.contains(tuple("张三", 25));场景三:验证 Map
// Hamcrest
assertThat(map, hasEntry("key1", "value1"));
assertThat(map, hasKey("key2"));
assertThat(map, hasValue("value3"));
// AssertJ
assertThat(map)
.containsEntry("key1", "value1")
.containsKey("key2")
.containsValue("value3")
.hasSize(3);场景四:自定义匹配/断言
// Hamcrest:自定义 Matcher,需要实现 TypeSafeMatcher
public class ValidOrderMatcher extends TypeSafeMatcher<Order> {
@Override
protected boolean matchesSafely(Order order) {
return order.getId() != null
&& order.getAmount().compareTo(BigDecimal.ZERO) > 0
&& order.getStatus() != null;
}
@Override
public void describeTo(Description description) {
description.appendText("a valid order with id, positive amount and status");
}
public static Matcher<Order> isValidOrder() {
return new ValidOrderMatcher();
}
}
// 使用
assertThat(order, isValidOrder());
// AssertJ:自定义 AbstractAssert,链式更自然
public class OrderAssert extends AbstractAssert<OrderAssert, Order> {
public static OrderAssert assertThat(Order order) {
return new OrderAssert(order);
}
public OrderAssert isValid() {
isNotNull();
if (actual.getId() == null) failWithMessage("订单ID不能为空");
if (actual.getAmount().compareTo(BigDecimal.ZERO) <= 0) failWithMessage("金额必须大于0");
return this;
}
public OrderAssert hasPaidStatus() {
isNotNull();
if (OrderStatus.PAID != actual.getStatus())
failWithMessage("期望状态是PAID,实际是 <%s>", actual.getStatus());
return this;
}
}
// 使用
OrderAssert.assertThat(order).isValid().hasPaidStatus();踩坑实录三则
踩坑一:Hamcrest 和 JUnit 5 的 assertThat 混淆
现象:JUnit 5 自带了一个 assertThat(在 org.junit.jupiter.api.Assertions),和 Hamcrest 的 assertThat(org.hamcrest.MatcherAssert)不是同一个。导入错了,Matcher 用不了。
解法:明确导入 org.hamcrest.MatcherAssert.assertThat,或者统一用 AssertJ,避免混淆。
踩坑二:AssertJ 的 extracting 遇到 null 字段抛 NullPointerException
现象:
assertThat(users).extracting("address.city").containsExactly("北京", "上海");
// 如果某个 user.address 是 null,这里抛 NullPointerException,而不是断言失败原因:extracting("address.city") 内部用反射访问属性,中间路径的 null 会导致 NPE。
解法:先确认不为 null,或者用 extracting 配合方法引用和 null 安全写法:
assertThat(users)
.extracting(u -> u.getAddress() != null ? u.getAddress().getCity() : null)
.containsExactly("北京", "上海");踩坑三:使用 AssertJ 的 usingRecursiveComparison 时,比较了不该比较的字段
现象:
assertThat(actualOrder)
.usingRecursiveComparison()
.isEqualTo(expectedOrder);
// 失败了,因为 createTime 字段不同(实际时间 vs 测试里构造的时间)解法:排除不需要比较的字段:
assertThat(actualOrder)
.usingRecursiveComparison()
.ignoringFields("id", "createTime", "updateTime")
.isEqualTo(expectedOrder);最终选型建议
选 AssertJ 的情况(推荐给大多数新项目):
- 需要清晰可读的断言代码
- 团队新人多,学习成本要低
- 主要做对象属性、集合等断言
选 Hamcrest 的情况:
- 项目已经大量使用 Hamcrest,统一风格
- 使用 MockMvc(
andExpect必须用 Hamcrest) - 需要复杂的匹配器组合
实际上,很多项目同时用两个:
- MockMvc 的
andExpect用 Hamcrest(因为 API 要求) - 其他所有断言用 AssertJ
这是合理的,不需要强求统一。
