3. JUnit5 断言 API:核心方法与组合断言
3.1 本章导读
断言(Assertion)是单元测试的“裁判”,它验证被测代码的实际输出是否与预期一致。JUnit5 在 org.junit.jupiter.api.Assertions 中提供了一套丰富、可组合的静态断言方法。本章从原理到实战,逐一拆解 assertEquals、assertTrue、assertNotNull、assertThrows 和 assertAll,并给出 Android 项目中的典型用法、调试技巧及常见陷阱。
3.2 断言 API 原理与执行模型
JUnit5 的每个断言本质上是一个条件检查 + 失败报告的封装。当断言失败时,框架会抛出 AssertionFailedError(或其子类),测试引擎捕获该异常后将该测试标记为 Failed。若未抛出任何异常,则测试通过。
现代断言设计遵循 Fluent API 思想,支持延迟消息(Lambda 表达式提供消息仅在失败时计算),以及分组断言(assertAll)确保即使部分断言失败也能执行完所有检查。
| 断言方法 | 作用域 | 失败时异常 | 是否支持延迟消息 |
|---|---|---|---|
assertEquals(expected, actual) | 值相等性(调用 equals) | AssertionFailedError | 是 |
assertTrue(condition) | 布尔条件为 true | AssertionFailedError | 是 |
assertNotNull(object) | 对象非 null | AssertionFailedError | 是 |
assertThrows(expectedType, executable) | 验证代码块抛出指定异常 | AssertionFailedError | 否(返回异常实例) |
assertAll(executables...) | 分组执行断言,收集所有失败 | MultipleFailuresError | 否(内部使用) |
3.3 断言执行流程
下图展示了单个测试方法内部断言执行的典型路径:
3.4 核心断言 API 详解
3.4.1 assertEquals — 值相等性验证
原理:调用 expected.equals(actual),若返回 false 则失败。对于基本类型使用自动装箱后的 equals,注意浮点数使用增量比较。
常用变体:
assertEquals(expected, actual)— 通用assertEquals(expected, actual, delta)— float/double 带精度assertEquals(expected, actual, message)— 自定义失败消息assertEquals(expected, actual, messageSupplier)— 延迟消息(Lambda)
View 对象不建议直接使用 assertEquals,因为 View 的 equals 通常未覆写。应改用 assertSame 或字段级别比较。
// 基本类型浮点数比较
float expected = 3.14159f;
float actual = MathUtil.calculatePi();
assertEquals(expected, actual, 0.0001f, "PI 计算精度应在允许范围内");
// 对象比较(需覆写 equals)
User user1 = new User("Alice", 25);
User user2 = userDao.findByName("Alice");
assertEquals(user1, user2, "从数据库查询的用户应与构造对象相等");
3.4.2 assertTrue / assertFalse — 布尔条件验证
原理:直接评估布尔表达式,assertTrue 要求结果为 true,assertFalse 要求为 false。通常用于验证状态标志、集合是否为空、条件是否成立。
List<String> items = repository.fetchItems();
assertTrue(items.isEmpty(), "获取的列表应为空");
assertFalse(items.contains("invalid"), "不应包含无效条目");
3.4.3 assertNotNull / assertNull — 非空验证
原理:检查对象是否为 null。assertNotNull 用于确保依赖注入、数据库查询结果、异步回调参数不会为空。
// 验证 ViewModel 初始化成功
MyViewModel viewModel = new ViewModelProvider(this).get(MyViewModel.class);
assertNotNull(viewModel, "ViewModel 不应为 null");
// 验证 LiveData 值
LiveData<String> nameLiveData = viewModel.getName();
assertNotNull(nameLiveData.getValue(), "LiveData 的当前值不应为 null");
3.4.4 assertThrows — 异常预期验证
原理:接收一个 Executable(函数式接口,无参数无返回值),在内部执行该代码块。若抛出指定类型异常则测试通过并返回该异常实例;若未抛出任何异常或抛出不符合预期的类型则失败。
应用场景:验证非法输入时方法正确抛出 IllegalArgumentException、NullPointerException 等。
// 验证字符串转换数字时抛出异常
Executable exec = () -> Integer.parseInt("abc");
NumberFormatException exception = assertThrows(
NumberFormatException.class,
exec,
"传入非数字字符串应抛出 NumberFormatException"
);
assertEquals("For input string: \"abc\"", exception.getMessage());
// 验证边界条件
assertThrows(IndexOutOfBoundsException.class,
() -> new ArrayList<>().get(0));
assertThrows 捕获异常实例,再对捕获后的对象做进一步断言。注意 Executable 内部不能抛出受检异常,否则需用 ThrowingExecutable 或显式 try-catch。
3.4.5 assertAll — 分组断言
原理:接收多个 Executable(变长参数或集合),按顺序执行所有断言。无论其中哪些失败,都会继续执行完剩余断言。所有失败信息收集在 MultipleFailuresError 中,最终一次性报告。适用于验证一个对象的多项属性。
// 验证用户对象所有字段
User user = userService.getUser(42);
assertAll("User 字段校验",
() -> assertEquals("Alice", user.getName()),
() -> assertEquals(25, user.getAge()),
() -> assertNotNull(user.getEmail()),
() -> assertTrue(user.isActive())
);
assertAll 会执行所有断言,若其中某个断言非常慢(例如访问数据库),应避免混入在分组中。通常只对纯内存计算或简单字段进行分组。
3.5 组合示例:Android ViewModel 单元测试
以下代码展示在一个 ViewModel 测试中如何综合使用上述断言 API:
@Test
@DisplayName("LoginViewModel 验证输入合法性")
void testLoginValidation() {
LoginViewModel viewModel = new LoginViewModel();
// 初始状态:所有错误消息为 null
assertAll("initial state",
() -> assertNull(viewModel.emailError.getValue()),
() -> assertNull(viewModel.passwordError.getValue()),
() -> assertFalse(viewModel.isLoginEnabled.getValue())
);
// 输入空邮箱,触发验证
viewModel.onEmailChanged("");
assertNotNull(viewModel.emailError.getValue(), "空邮箱应触发错误");
// 输入有效邮箱 + 空密码
viewModel.onEmailChanged("test@example.com");
viewModel.onPasswordChanged("");
assertAll("valid email, empty password",
() -> assertNull(viewModel.emailError.getValue(), "有效邮箱不应有错误"),
() -> assertNotNull(viewModel.passwordError.getValue(), "空密码应触发错误"),
() -> assertFalse(viewModel.isLoginEnabled.getValue())
);
// 输入有效密码
viewModel.onPasswordChanged("Pass123!");
assertTrue(viewModel.isLoginEnabled.getValue(), "合法输入应启用登录按钮");
// 验证登录失败时的异常预期(假设 login 方法抛出)
assertThrows(IllegalStateException.class,
() -> viewModel.login(),
"未初始化网络模块时应抛出异常"
);
}
3.6 常见错误与性能优化
| 类别 | 错误描述 | 解决方案 |
|---|---|---|
| 误用 assertEquals | 对未经覆写 equals 的对象直接比较,导致语义错误 | 使用 assertSame 比较引用,或覆写 equals / 使用字段级别断言 |
| 浮点精度 | 直接 assertEquals 两个 float/double,偶现失败 | 重载版本第三个参数指定 delta,如 assertEquals(0.1f, actual, 1e-6f) |
| assertThrows 误吞异常 | Executable 内抛出了子类异常但类型检查不严格 | 使用 assertThrowsExactly (JUnit5.8+) 精确匹配类型,或检查异常实例的 class |
| assertAll 滥用 | 将具有前后依赖的断言放入分组(如先判 null 再判字段) | 保持分组内断言独立;若需依赖,应先用常规断言确保前提成立 |
| 延迟消息未使用 | 直接拼接字符串作为消息,即使测试通过也执行字符串拼接 | 改用 assertEquals(expected, actual, () -> "详细消息: " + buildDetail()) |
| 超时与性能 | 在断言语境内调用耗时 I/O | 将耗时操作移至 @BeforeEach 或使用 assertTimeout 系列方法 |
3.7 调试与报告优化
- 启用详细失败信息:在
gradle中配置test { testLogging { events "failed", "passed", "skipped" } },可以查看每个断言失败时的具体期望值和实际值。 - 断点调试:在断言行设置断点,观察
expected和actual变量的实际内容。注意 JUnit5 的断言方法内部可能会 wrap 异常,若 IDE 无法定位到精确行,可在Assertions类源码中加断点。 - 自定义失败消息:始终提供有业务含义的 message,尤其在
assertAll中,方便快速定位哪个属性校验失败。
-ea 启用断言不会影响 JUnit5,但可以在被测代码中使用 assert 关键字。此外,开启 "Show Passed" 可同时观察成功断言的数量。
3.8 小结
assertEquals是高频方法,注意对象类型及浮点精度。assertTrue/assertFalse适用于条件判断,避免复杂表达式。assertNotNull用于 null 安全验证,配合依赖注入使用。assertThrows是异常测试首选,可附带异常实例的进一步断言。assertAll实现一次性多角度验证,提升测试诊断效率。- 始终考虑性能:使用延迟消息、避免断言语境内 I/O、合理规划分组。