JUnit5断言全解析:从assertEquals到assertAll的实战指南
2. 断言方法全景图JUnit5 Jupiter 的断言家族3. 核心断言方法详解从 assertEquals 到 assertAll3.1 assertEquals 与断言失败时的消息输出assertEquals应该是日常开发中用得最多的断言方法了它的作用就是验证两个值是否相等。这里有个很多人一开始会搞混的点JUnit5 的assertEquals底层调用的其实是Objects.equals()方法所以它对两个对象的比较是逻辑相等而不是引用相等。如果你想验证两个对象引用是否指向同一个实例那得用assertSame而不是assertEquals。Test void testEqualsVsSame() { String str1 new String(hello); String str2 new String(hello); // str1 和 str2 内容相同但不是同一个对象 assertEquals(str1, str2); // 通过内容相等 assertNotSame(str1, str2); // 通过确实不是同一个引用 String str3 str1; assertSame(str1, str3); // 通过同一个引用 }关于断言的失败消息JUnit5 的assertEquals有重载版本可以传入一个String message参数。我的建议是核心业务逻辑的断言一定要写消息。因为当测试失败时如果你只看到expected: true but was: false你真的很难快速定位到底哪一步出了问题。但如果你写了用户登录后 token 不应为空那失败信息一眼就能看懂是哪个业务环节出了问题。这里还有一个 JUnit5 特有的小优化消息参数是延迟计算的。也就是说JUnit5 使用() - String的Supplier形式来提供消息只有断言失败时才会真正去拼接字符串。这在性能敏感、需要大量断言的场景下比如循环里跑几千次断言能省下不少字符串拼接的开销。Test void testWithLazyMessage() { ListString list new ArrayList(); // 只有断言失败时才会执行 lambda 里的字符串拼接 assertTrue(list.isEmpty(), () - 列表应该为空但实际有 list.size() 个元素); }3.2 布尔断言assertTrue / assertFalse 的灵活运用布尔断言看着简单就两个方法但实际用起来也有不少讲究。assertTrue(condition)验证条件为真assertFalse(condition)验证条件为假。不过你千万别把业务里所有的状态校验都写成布尔断言那样会让测试的可读性变得很差。举个例子你想验证一个订单的状态是已支付。如果你写assertTrue(order.getStatus() OrderStatus.PAID)测试代码是能跑通但将来别人读这段测试代码时还得先搞清楚PAID是什么、getStatus()返回什么。更好的做法是用assertEquals直接比较状态值或者配合 Hamcrest 的assertThat来做更语义化的断言。JUnit5 在布尔断言上的一个靠前版本问题也值得注意在早期的 JUnit5 版本中assertTrue的第二个参数是String message但后来版本逐步加入了SupplierString的重载。如果你在用较老的版本写 lambda 形式的消息可能会编译不过。这块升级依赖时留意一下 changelog 就行。另外assertTrue还可以配合条件组合来用比如Test void testBooleanWithComplexCondition() { User user new User(张三, 25, true); // 多个条件组合用户必须是成年人且账号处于激活状态 assertTrue(user.getAge() 18 user.isActive(), 用户应为成年人且账号已激活); }这种写法适合简单的二元组合。条件一旦超过两个我个人就建议拆开写成多条断言或者用assertAll来分组了不然排查失败原因时会很痛苦——你只知道整体条件是假的但不知道具体是哪个子条件出了问题。3.3 对象与数组断言assertSame、assertArrayEquals 的陷阱与技巧先聊聊assertSame。这个方法的语义是验证两个引用指向同一个对象用的是比较。实际工作中最常见的误用场景是拿它来比较字符串或包装类型的值。比如assertSame(100, 100)可能通过因为 Integer 有缓存但assertSame(200, 200)就未必了——Integer 缓存的范围是 -128 到 127。这种比较结果完全取决于 JVM 的缓存机制属于典型的看似能跑、实则隐患。再说assertArrayEquals。这个方法是用来比较两个数组的但有几个细节需要留意对于基本类型数组int[]、char[]等它是逐个元素比较值的。对于对象数组String[]、Integer[]等它是调用每个元素的equals方法。对于多维数组JUnit5 的assertArrayEquals从 5.4 版本开始才真正支持深度比较。如果你用的是旧版本比较二维数组时会直接失败因为旧版只会比较外层数组的引用。我踩过一次很深的坑用assertArrayEquals比较两个byte[]时因为数据量特别大测试跑了几次都通过但后来换了 JDK 版本数组的存储方式有细微变化测试就开始偶发失败。最后排查发现问题出在对象数组里的元素没有正确重写equals方法导致比较走的是Object.equals的引用比较。对自定义对象数组做断言一定先确认对象的equals方法实现是否正确这几乎是数组断言的第一个隐性门槛。Test void testArrayEquals() { int[] expected {1, 2, 3, 4}; int[] actual someMethodReturnArray(); assertArrayEquals(expected, actual, 数组内容不一致); }3.4 异常断言assertThrows 的正确打开方式assertThrows是 JUnit5 里比较亮眼的新特性它把预期异常的写法简化了不少。JUnit4 时代大家用的是Test(expected XxxException.class)注解那个写法有个明显的毛病你没法验证异常发生的位置。假设方法里有 3 行代码都可能抛出同一个异常你用 JUnit4 的注解只能知道抛了异常但不知道具体是哪一行抛的。assertThrows就把控制粒度细化到了代码块级别Test void testThrows() { // 只验证这一行代码是否抛出指定异常 assertThrows(IllegalArgumentException.class, () - { userService.createUser(null); }); }assertThrows的返回值还有一个很实用的用法——拿到异常对象后你还可以进一步验证异常消息、异常原因等细节Test void testThrowsWithDetail() { IllegalArgumentException ex assertThrows(IllegalArgumentException.class, () - { orderService.calculateDiscount(-1); }); // 进一步验证异常消息 assertTrue(ex.getMessage().contains(折扣不能为负数)); // 验证异常原因链 assertNotNull(ex.getCause()); }一个比较实战的技巧assertThrows的可执行接口Executable允许抛出任何异常所以你在 lambda 里写多个语句时如果前面某个语句抛出了预期外的异常assertThrows会直接让测试失败。这其实是好事——它保证了你断言的确实是目标代码块里的行为而不是被其他语句干扰。另外一个常见问题是assertThrows和assertDoesNotThrow的搭配。assertDoesNotThrow是 5.8 版本加入的新方法专门用来验证某段代码不会抛异常。它的使用场景是你想确认某个操作在正常路径下不会出现异常但又不想写空的 try-catch。代码洁癖者应该会喜欢这个。3.5 超时断言assertTimeout 与 assertTimeoutPreemptively 的取舍assertTimeout用来验证某段代码是否在指定时间内完成。它有两种变体在多线程执行模型上有本质区别assertTimeout在测试线程中同步执行目标代码等代码跑完后比较耗时是否超时。它的优点是如果被测试代码操作了ThreadLocal、事务上下文等线程绑定资源不会有问题缺点也很明显——如果代码真的超时了你得等代码跑完才能得出超时的结论测试本身并不会被中断。assertTimeoutPreemptively在独立的线程中执行目标代码一旦超时会立刻抛出异常让测试失败。听起来更智能但它有隐患目标代码运行在另一个线程里如果它操作了ThreadLocal、数据库事务、Spring 事务上下文这类线程绑定资源可能会因为线程切换而出现莫名的失败。我的建议是默认用assertTimeout只有在明确知道目标代码不会有线程本地依赖、且确实需要即时中断时才用assertTimeoutPreemptively。其实大多数测试里我们会用Timeout注解来标注整个测试方法的超时时间很少真的需要assertTimeout系列。但在验证某个具体算法性能是否达标的时候assertTimeout的价值就出来了Test void testTimeout() { // 验证排序算法在 1 秒内完成 assertTimeout(Duration.ofSeconds(1), () - { int[] result quickSort(generateRandomArray(10000)); assertArrayEquals(expectedSorted, result); }); }这里有个细节assertTimeout里还可以继续写断言整个 lambda 里的代码都算在计时范围内。所以你可以把执行 验证放到同一个超时断言里相当于同时校验了性能和正确性。3.6 分组断言assertAll 的妙用assertAll是 JUnit5 引入的一个非常实用的特性它解决的是 JUnit4 时代一个测试方法里多个断言遇到第一个失败的断言就直接中断的痛点。先看一个典型场景。你写了一个测试里面连续验证了 10 个字段的值结果跑的时候第一个断言就失败了后面 9 个断言根本没机会执行。你只能修完第一个问题再跑一次结果第二个又失败了……这种挤牙膏式的排错方式效率太低。assertAll的做法是把你提供的所有断言都执行一遍然后把所有的失败都汇总到一起报告。这样你一次就能看到所有没通过的断言不用一次次重跑。Test void testUserFields() { User user userService.getUserById(1001); assertAll(用户信息字段校验, () - assertEquals(张三, user.getName(), 用户名不匹配), () - assertEquals(25, user.getAge(), 年龄不匹配), () - assertTrue(user.isActive(), 用户应处于激活状态), () - assertNotNull(user.getEmail(), 邮箱不应为空) ); }需要注意assertAll里的每个 Executable 都是可以独立抛异常的包括断言失败、AssertionFailedError以及任何其他异常。assertAll会收集所有失败然后抛出一个MultipleFailuresError里面包含所有失败详情。使用assertAll时我有一个习惯给每个子断言都写上自定义消息并给整个assertAll也设置一个分组标题。这样当测试失败时你能从错误报告里看到用户信息字段校验这个分组标题下面列出具体的子失败的详细消息。定位问题的速度比单条断言快得多。4. 前置条件校验 assumptions 系列方法4.1 assumeTrue / assumeFalse 与断言的本质区别假设assumption和断言assertion在 JUnit5 里是两套完全不同的机制这个点很多初学者会混淆。简单区分断言失败→ 测试失败fail整个测试执行被标记为失败。假设失败→ 测试被跳过aborted不会计入失败结果而是显示为跳过skipped。这个区别意味着什么假设机制非常适合用来表达当前环境不满足某个条件时这个测试就不该执行的场景。最典型的就是跨平台测试或依赖外部服务的测试。Test void testOnlyOnLinux() { // 如果不是 Linux 系统这个测试自动跳过不算失败 assumeTrue(System.getProperty(os.name).toLowerCase().contains(linux)); // 以下代码只在 Linux 环境下执行 File file new File(/proc/cpuinfo); assertTrue(file.exists()); }比如你有一段逻辑是正则匹配资源的路径在 Windows 和 Linux 上路径分隔符不同如果你不想写一堆条件分支就可以用假设机制跳过不合适的平台。4.2 assumingThat 条件执行assumingThat是另一个很实用的方法它的语义和assumeTrue有些差别assumingThat(boolean, Executable)是先判断条件是否成立条件成立才执行 Executable条件不成立则什么都不做但该测试本身不会被标记为跳过。Test void testAssumingThat() { // 这段逻辑只有在 JDK17 才执行其他环境忽略 assumingThat(Runtime.version().feature() 17, () - { // 这里可以安全地使用 JDK17 的 API assertEquals(16, hello.length()); }); // 注意这行断言无论如何都会执行 assertTrue(true); }对比一下assumeTrue不满足条件时直接中断整个测试方法后面的代码都不执行assumingThat只是忽略这一个代码块后面的代码继续执行。这个特性在同一个测试方法里同时覆盖多版本兼容逻辑时特别好用。我见过一个很有意思的使用场景某位开发者做的是 SDK 兼容性测试同一个测试方法里要同时验证老版本接口行为和新版本接口行为。他用assumingThat判断当前运行环境分别执行对应的断言块一份测试代码就把两套兼容逻辑都覆盖了不用拆成两个测试类。这个思路挺值得借鉴的。5. 断言失败时JUnit5 帮你做了什么5.1 失败信息格式深度解析assertEquals失败时的默认消息格式很有意思它其实是一套信息丰富的结构化输出。比如expected: true but was: false这是最基础的布尔断言。实际项目中更多看到的是expected: 100 but was: 99看起来平平无奇但当你拿它去排查复杂的对象比较时就不够用了。所以在写断言时给每条断言加消息参数这件事我会反复强调。这不仅是代码习惯更是给自己留后路——几个月后回来看失败报告一条清晰的自定义消息能帮你节省大量排查时间。JUnit5 在比较对象时还有一个可视化优化如果Assert.assertEquals比较的是两个内容很长的字符串并且两个字符串足够相似失败消息会显示差异位置和一段上下文内容。这个diff 输出在验证日志输出、响应报文体时特别有用。5.2 如何从失败堆栈中快速定位断言位置JUnit5 失败时的堆栈信息里关键线索是AssertionFailedError的stack trace。但我发现不少同事有个习惯看到失败信息直接去看最后几行堆栈。其实正确的做法是找第一个包含自己测试代码的堆栈帧——那才是真正断言失败的位置后面的都是框架内部的调用链。举一个实际例子用 IDEA 或 Eclipse 跑测试双击失败消息IDE 会自动跳转到对应的源码行。这个功能是从断言失败消息里的类名和方法名做映射跳转的。如果自定义了消息跳转依然有效只要消息格式是标准的expected ... but was ...。另外JUnit5 的失败信息里在expected和actual值特别长的时候会被截断。默认截断长度为 80 个字符左右。如果你在断言长字符串、长 JSON 时觉得失败信息看不全可以通过配置参数来调整截断长度。具体配置方式可以在junit-platform.properties里设置junit.jupiter.assertion.failure.message.enable-truncation等相关参数不同版本略有差异。这块我建议按需使用不是必须改的配置。5.3 失败重试与信息保留策略有些团队会在 CI 里配置失败自动重试但这里有个坑如果你不加约束地重试某些只该执行一次的断言会被重复执行带来副作用。比如测试里创建了订单、发送了消息重试时可能重复创建、重复发送。我个人在项目里通常的做法是CI 上对不稳定测试flaky test做最多一次重试并把重试标准限定为特定类型的失败比如超时、网络抖动导致的失败而不是所有断言失败都重试。同时配合 JUnit5 的TestInstance生命周期配置保证每次重试都在干净的环境里执行。6. 完整实战案例从普通断言到高级断言6.1 场景设定与测试代码手把手实现用一个贴近业务的场景把前面的方法串起来——订单支付服务。假设业务逻辑是创建订单时金额必须大于 0否则抛IllegalArgumentException。订单创建成功后初始状态为PENDING。订单包含收货地址、商品列表、优惠信息等多个字段需要批量校验。高并发下订单号的生成耗时不能超过 500 毫秒。某些测试依赖数据库中的特定环境数据环境不满足时跳过。对应的测试代码可以这样写class OrderServiceTest { private OrderService orderService; BeforeEach void setUp() { orderService new OrderService(new MockPaymentGateway()); } Test void testCreateOrderValidation() { // 金额非法时抛异常 IllegalArgumentException ex assertThrows( IllegalArgumentException.class, () - orderService.createOrder(-1, new Address(北京, 朝阳区)) ); assertTrue(ex.getMessage().contains(金额必须大于 0)); } Test void testCreateOrderSuccessAndFields() { Order order orderService.createOrder(199, new Address(北京, 朝阳区), List.of(new Item(手机, 1), new Item(耳机, 2))); assertAll(订单字段校验, () - assertNotNull(order.getOrderId(), 订单号不能为空), () - assertEquals(OrderStatus.PENDING, order.getStatus(), 初始状态应为待支付), () - assertEquals(2, order.getItems().size(), 商品数量不匹配), () - assertTrue(order.getTotalAmount() 0, 订单金额应大于0), () - assertNotNull(order.getShippingAddress(), 收货地址不能为空) ); } Test void testOrderIdGeneratePerformance() { // 订单号生成必须在 500ms 内完成 assertTimeout(Duration.ofMillis(500), () - { String orderId orderService.generateOrderId(); assertFalse(orderId.isEmpty()); }); } Test void testOnlyWithTestDatabase() { // 如果环境中没有配置测试数据库跳过测试 assumeTrue(Environment.isTestDatabaseAvailable(), 测试数据库不可用跳过该测试); Order savedOrder orderRepository.save(createSampleOrder()); assertNotNull(savedOrder.getId()); } }这样一个测试类就同时用到了assertThrows、assertAll、assertTimeout、assumeTrue等核心 API。从验证异常到批量字段校验到性能验证再到环境假设基本覆盖了日常开发里的主流场景。6.2 测试代码组织与可读性优化写测试断言时我总结出几个值得注意的习惯一条测试方法只验证一个行为维度。别把验证正常流程、验证异常路径、验证边界条件全塞进一个方法里拆成多个测试方法失败时定位更快报告也更清晰。用assertAll把逻辑相关的断言分组。比如一个方法返回了多个字段把对这些字段的校验放在同一个assertAll里失败时能看到这一组所有的问题。断言消息写业务描述不写代码逻辑描述。比如用户登录后token不应为空比token ! null好得多前者告诉你业务预期后者只是复述代码。写完断言后故意让它失败一次。这是个土办法但很有效。确认一下失败信息里是否能体现出问题如果信息不明了就补消息参数。否则等 CI 真挂了你可能要对着一堆expected: true but was: false发呆。7. 常见问题与排查技巧实录7.1 断言失败时如何快速定位是业务问题还是测试代码问题这是我在实际项目中遇到最多的排查场景。断言失败第一反应别急着改业务代码先确认测试代码本身是不是写错了。几个典型情况断言值类型不一致比如assertEquals(1, someLongValue)如果someLongValue是long类型JUnit5 的方法重载选择可能让你以为两个值相等但实际由于类型问题走错了重载。排查时先看expected和actual的类型。对象没有正确重写equals比较两个 VO值对象时如果类没有实现equals方法断言会退化成引用比较内容一样也会失败。这时给类补上equals和hashCode或者改用逐字段断言。浮点数精度问题直接比较两个double或float因为浮点表示问题可能得到expected: 0.3 but was: 0.30000000000000004这样的失败。这种情况要用assertEquals(double expected, double actual, double delta)的重载版本给一个误差范围比如assertEquals(0.3, result, 0.000001)。在排查这类问题时JUnit5 的失败消息里会显示expected和actual的类型信息仔细看一下能省不少时间。7.2 并行测试中断言与共享状态的冲突JUnit5 支持并行测试执行这在提升测试速度的同时也给断言带来了新的挑战。一个典型场景多个测试线程同时操作同一个共享对象比如同一个 Service 实例导致断言的结果不稳定。解决思路有几种让被测对象在每个测试方法里独立创建不使用BeforeAll创建的共享实例。用TestInstance(PER_CLASS)配合ResourceLock来控制共享资源的并发访问。对于只读的共享数据用ResourceLock标注访问顺序即可。JUnit5 的并行执行默认是关闭的需要在junit-platform.properties中开启junit.jupiter.execution.parallel.enabledtrue junit.jupiter.execution.parallel.mode.defaultconcurrent同时也别忽略线程安全对断言本身的影响——断言操作通常不在锁保护的范围内如果被测代码存在线程安全隐患断言失败只是表象根因要去业务代码里排查。7.3 测试环境差异导致的断言失败同样的测试代码本地跑得好好的CI 上却挂了一片。这种环境差异问题我见过不少。高发原因操作系统差异路径分隔符、换行符、文件编码不同。比较文件路径时用Paths.get()构造预期值而不是硬编码C:/xxx或/home/xxx。时区问题new Date()在不同时区环境下日期断言可能失败。测试里涉及时间的部分最好用LocalDateTime.now(ZoneId.of(Asia/Shanghai))这种显式指定时区的方式。JDK 差异某些 API 的行为在 JDK 8 和 JDK 17 上不一致比如StringBuilder的toString()在某些极端场景下的内存表现会有差别日常开发遇到的概率极低但它确实是 CI 和本地结果不一致的经典来源之一。字符集差异Windows 默认 GBKLinux 默认 UTF-8文件读写不做编码设置时字节流断言最容易挂。针对这些我当年的排查流程通常是先在本地以 CI 的环境变量配置跑一遍比如把JAVA_TOOL_OPTIONS、LANG都设置成和 CI 一致能复现的 80% 都是环境差异问题复现不了的话再往代码逻辑层面查。7.4 常见问题速查表问题现象可能原因解决方案expected: true but was: false被测方法逻辑错误或条件判断不对加上自定义消息拆分断言条件对象比较失败但内容看起来一样对象没重写equals重写equals/hashCode或改用逐字段断言浮点数值比较失败浮点精度误差使用assertEquals(expected, actual, delta)重载assertTimeoutPreemptively偶发失败跨线程导致线程本地数据丢失改用assertTimeout或确认代码没有ThreadLocal依赖测试在 CI 挂但本地通过环境差异JDK/OS/时区/编码对齐本地和 CI 环境变量先定位差异因素并行执行时断言结果不稳定共享状态被多个测试线程修改独立实例 ResourceLock控制并发assertThrows没捕获到异常异常在 lambda 外被抛出或异常类型不匹配确认可执行代码范围检查异常类型继承关系多次重跑结果不同测试数据依赖外部资源用assumeTrue或 Mock 掉外部依赖8. 高级技巧与工具联动8.1 结合 Hamcrest Matcher 扩展断言语义JUnit5 自带断言方法的表达能力在某些场景下还是不够。比如你想断言一个字符串以数字开头用原生断言只能写assertTrue(str.matches(^\\d.*))但可读性一般。这时可以用 Hamcrest 的assertThatimport static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; Test void testWithHamcrest() { assertThat(order-12345, startsWith(order-)); assertThat(order-12345, containsString(123)); assertThat(listOfItems, hasSize(5)); }Hamcrest 的assertThat在集合、字符串、数字区间的断言上表达力特别强allOf、anyOf、not这些组合子能让断言像写英文句子一样自然。JUnit5 本身不排斥 Hamcrest两者可以混用这点和 JUnit4 是一致的。我在实际项目中的搭配策略是简单的值比较用 JUnit5 原生断言复杂的语义用 Hamcrest。别一刀切全用一套工具选型应该服务于测试的可读性和可维护性。8.2 使用 AssertJ 做流式断言如果你觉得 Hamcrest 的静态方法写起来还不够顺手可以试试 AssertJ。它的 API 设计是流式的代码读起来特别像自然语言import static org.assertj.core.api.Assertions.assertThat; Test void testWithAssertJ() { OptionalString optional Optional.of(hello); assertThat(optional) .isPresent() .contains(hello); ListInteger numbers List.of(1, 2, 3, 4); assertThat(numbers) .hasSize(4) .allMatch(n - n 0) .anyMatch(n - n 3); }AssertJ 和 JUnit5 的配合非常融洽。不过需要注意的是AssertJ 有一套自己的失败消息格式和 JUnit5 原生断言不太一样。团队内做技术选型时要么统一用 AssertJ要么统一用 JUnit5 原生 少量 Hamcrest混用太多会带来维护负担——因为有些人熟悉 JUnit5 原生 API有些人熟悉 AssertJ读代码时会有认知转换成本。就我个人经验而言测试断言不必追求工具的全能稳定、可读、团队成员都容易上手远比用上了高级 API更重要。断言是测试代码里最常读的部分降低阅读门槛才是第一原则。8.3 条件测试执行EnabledOnOs / EnabledOnJreJUnit5 的EnabledOnOs和EnabledOnJre注解可以做到和assumeTrue类似的效果但语法更简洁、更声明式Test EnabledOnOs(OS.LINUX) void testOnlyOnLinux() { // 只有 Linux 才执行 } Test EnabledOnJre(JRE.JAVA_17) void testOnlyOnJava17() { // 只在 JDK17 上执行 } Test DisabledOnOs(OS.WINDOWS) void testNeverOnWindows() { // Windows 上跳过 }这类注解把环境条件的声明从测试代码里抽离出来测试方法内部更干净可读性也更好。适合那些从设计上就只打算在特定环境运行的测试比如只在 Linux 上验证符号链接行为、只在特定 JDK 版本上验证某个 API 的表现。注解和assumeTrue的使用边界我的建议是条件在编译期或类加载期就能确定的用注解条件需要运行时动态判断的用assumeTrue。比如当前系统是否装了某个字体当前网络是否能连通某个外部服务这类只有运行时才知道的条件注解就表达不了必须用假设。8.4 与 Mockito 组合使用时的断言策略在单元测试里JUnit5 断言和 Mockito 的verify是两个不同层面的验证工具。verify验证的是交互行为——某个方法是否被调用、调用参数是什么、调用次数是多少断言验证的是结果状态——被测方法返回的值是否正确、对象的状态是否符合预期。一个典型的组合用法Test void testOrderServiceWithMock() { PaymentGateway gateway mock(PaymentGateway.class); when(gateway.charge(anyDouble())).thenReturn(true); OrderService service new OrderService(gateway); Order order service.createOrder(100, new Address(上海, 浦东)); // 行为验证确认调用过支付接口 verify(gateway, times(1)).charge(100.0); // 状态断言订单确实创建了且状态正确 assertNotNull(order.getOrderId()); assertEquals(OrderStatus.PENDING, order.getStatus()); }这里容易犯的错误是用断言去验证行为或者用verify去验证状态。两者混用并不是不行但要清楚边界。比如验证用户下单后是否发送了通知邮件用verify(notificationService).send(...)更直接验证邮件内容是否正确生成用断言比较邮件内容对象更合适。8.5 参数化测试中的断言写法参数化测试里断言逻辑通常是同一套模板但输入数据不同。JUnit5 的ParameterizedTest配合CsvSource或MethodSource可以让断言的覆盖范围大幅扩展ParameterizedTest CsvSource({ 1, 1, 2, 2, 3, 5, 10, 20, 30, -1, 1, 0 }) void testAdd(int a, int b, int expectedSum) { assertEquals(expectedSum, calculator.add(a, b)); }参数化测试中要格外注意断言消息的动态性。如果你的参数来自外部文件断言失败时的默认消息只有expected和actual但你不知道是哪一组参数导致的失败。JUnit5 的参数化测试在失败报告里会带上参数索引但可读性依然有限。我的做法是参数化测试的断言统一带上参数相关的消息比如参数组 [ a , b ] 计算结果不正确这样 CI 报告里一眼就能定位是哪个数据源出的问题。9. 从 JUnit4 迁移到 JUnit5 时断言代码的变更要点9.1 常见迁移差异对照表很多老项目是从 JUnit4 迁移过来的断言部分的改动看着不大但坑都在细节里。先看一张对照表JUnit4JUnit5注意事项Test(expected Xxx.class)assertThrows(Xxx.class, () - ...)作用范围从整个方法缩小到指定代码块Test(timeout 500)assertTimeout或Timeout注意两者执行模型差异Assert.assertThat(actual, matcher)Assertions.assertThat或 Hamcrest 的assertThatJUnit5 中org.junit.Assert不存在需改导入Before/AfterBeforeEach/AfterEach生命周期注解变更BeforeClass/AfterClassBeforeAll/AfterAll默认是静态方法配合TestInstance可改为实例方法RunWith(SpringJUnit4ClassRunner.class)ExtendWith(SpringExtension.class)测试框架集成方式改变ExpectedException规则assertThrowsExpectedException规则在 JUnit5 中不再支持这里要特别提一下ExpectedException。JUnit4 时代它是个常用规则但到了 JUnit5规则Rule机制被扩展模型Extension取代ExpectedException没有直接的对应物官方推荐方案就是用assertThrows。如果你在迁移时还看到老的Rule public ExpectedException thrown ExpectedException.none();这种代码直接改成assertThrows就行。另外JUnit4 的Assert.assertThat依赖 HamcrestJUnit5 里这类静态方法在org.junit.jupiter.api.Assertions中不存在。要么改用 Hamcrest 自带的org.hamcrest.MatcherAssert.assertThat要么用 AssertJ 的assertThat看团队选型。9.2 迁移过程中的断言行为变化即使表面上语法一样JUnit4 到 JUnit5 的断言行为也有一些细微变化。比如assertEquals对于数组的行为JUnit4 的assertEquals对数组默认比较引用所以当时如果你拿数组做断言必须要用assertArrayEquals。到了 JUnit5assertEquals对数组也不会做深度比较仍然需要assertArrayEquals这点没有变但很多人会以为是 JUnit5 的 bug其实是语义本身如此。还有一个常被忽略的变化JUnit5 断言失败抛出的异常类型是org.opentest4j.AssertionFailedError而不是 JUnit4 的java.lang.AssertionError。如果你在其他框架里 catch 了AssertionError来做某些处理要注意类型变化否则可能 catch 不到。9.3 迁移后如何验证断言覆盖是否完整迁移assertThrows之后有些旧代码会因为行为变化而静默失效。比如原来Test(expected Exception.class)只管整个方法迁移成assertThrows后如果你把它包在一个非常小的代码块里异常可能被提前捕获或忽略反而测不出真实问题。我的迁移经验是迁移完必须重新跑一遍全量测试并逐个检查原先抛异常的场景是否真的被assertThrows捕获到。比较粗暴但有效的方法是临时改坏被测代码让异常不抛了看测试能不能正确失败。如果测试还是绿的说明断言根本没生效。这个故意改坏验证的思路我几乎在每次重写测试时都会用一遍。10. 实操总结与个人经验分享10.1 一套推荐的断言代码风格综合这些年写测试和评审别人测试代码的经验我整理了一套个人偏好的断言风格分享出来供参考用assertAll做分组同一个行为产生的多个结果字段用assertAll包起来一次跑完所有校验。分组名写业务场景不写代码层级。自定义消息写业务预期每条断言的消息描述应该发生什么而不是复述代码做了什么。异常断言放在方法最前面因为异常打断执行流先验证异常行为再验证正常返回值的断言。比较复杂的对象结构时优先assertEquals完整对象前提是对象已正确实现equals/hashCode否则退化为引用比较测试会莫名失败。数值断言注意精度浮点数统一用带delta的assertEquals重载。举例Test void testRefund() { // 1. 验证异常行为 assertThrows(IllegalStateException.class, () - refundService.refund(orderId, 0)); // 2. 正常退款场景 RefundResult result refundService.refund(orderId, 99); // 3. 分组断言业务字段 assertAll(退款结果校验, () - assertNotNull(result.getRefundId(), 退款单号应生成), () - assertEquals(RefundStatus.SUCCESS, result.getStatus(), 退款状态应为成功), () - assertEquals(99.0, result.getRefundAmount(), 0.001, 退款金额应等于申请金额) ); }10.2 测试代码的可维护性断言是给别人看的很多人写业务代码时很讲究可读性一到测试代码就开始放飞自我断言随手一写变量名随便起。这是非常错误的。测试代码可能比业务代码活得更久因为业务逻辑重构了测试还得继续跑。将来接手项目的人最先看的往往就是测试因为测试描述的是系统的预期行为。所以我会把测试代码当作文档来写断言就是文档里的结论句。一个测试方法的方法名加上assertAll的分组名加上每条断言的自定义消息基本就能完整描述这个行为的所有预期。这也是为什么我一直强调断言消息写业务描述——那不只是给 CI 日志看的更是给未来的维护者看的。10.3 给新手的三个实用建议第一从assertAll和assertThrows开始用。这两个是 JUnit5 相比 JUnit4 提升最明显的地方能直接改善测试体验。assertTimeout和assumeTrue后续根据场景再学不迟。第二不要一开始就追求 100% 完全覆盖。断言覆盖的核心是先保住关键业务链路比如核心算法的正确性、支付金额的准确性、异常边界的处理。先在这几个地方把断言写扎实比盲目堆量有用得多。第三保持断言代码的整洁。测试代码同样需要equals/hashCode的正确实现、同样需要避免魔法数字、同样需要命名规范。你写的断言是几个月后的自己要看的东西。10.4 后续可以怎么扩展如果你的项目已经全面使用 JUnit5可以继续往这几个方向延伸参数化测试的全面引入、Nested测试类来组织同模块的不同场景、JUnit5 扩展模型Extension来自定义断言行为。用Nested把同一个业务类的不同场景组织到内部类里会让测试类的结构更清晰配合DisplayName描述业务场景整个测试报告的可读性会有质的提升。这些工具单个拆开看都不复杂组合起来产生的价值才是 JUnit5 真正的优势所在。