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)布尔条件为 trueAssertionFailedError是
assertNotNull(object)对象非 nullAssertionFailedError是
assertThrows(expectedType, executable)验证代码块抛出指定异常AssertionFailedError否(返回异常实例)
assertAll(executables...)分组执行断言,收集所有失败MultipleFailuresError否(内部使用)

3.3 断言执行流程

下图展示了单个测试方法内部断言执行的典型路径:

JUnit5 断言执行流程 断言执行流程(单测试方法) 测试方法 @Test 执行 调用断言 API(如 assertEquals) 条件满足? 否 抛出 AssertionFailedError 测试标记为 Failed 是 方法继续执行 测试通过(绿色) 若 assertAll 分组内有多个断言, 所有断言均执行后才统一报告失败。

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)
推荐实践:在 Android 单元测试中,对 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"), "不应包含无效条目");
常见错误:不要在 assertTrue 内部调用复杂耗时的表达式。若表达式返回布尔值但附带副作用(如网络请求),断言失败时会导致测试执行不符合预期。应优先将表达式结果赋值给局部变量,再断言。

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 中,方便快速定位哪个属性校验失败。
Android Studio 集成提示:使用 Run → Edit Configurations 为测试配置额外的 VM options:-ea 启用断言不会影响 JUnit5,但可以在被测代码中使用 assert 关键字。此外,开启 "Show Passed" 可同时观察成功断言的数量。

3.8 小结

  • assertEquals 是高频方法,注意对象类型及浮点精度。
  • assertTrue/assertFalse 适用于条件判断,避免复杂表达式。
  • assertNotNull 用于 null 安全验证,配合依赖注入使用。
  • assertThrows 是异常测试首选,可附带异常实例的进一步断言。
  • assertAll 实现一次性多角度验证,提升测试诊断效率。
  • 始终考虑性能:使用延迟消息、避免断言语境内 I/O、合理规划分组。