1. 先把这一章要解决的事说清楚
JUnit实战系列走到第二章“核心功能”的下半段,前面已经聊过断言怎么写、基础注解怎么用、参数化测试怎么入门,这一章则把剩下几块关键能力串起来:测试生命周期钩子、条件执行、重复测试、超时控制、动态测试,以及测试用例的组织展示方式。说实话,这些东西单个拎出来都不难,难的是搞明白它们各自解决什么问题,以及在一个真实项目里该怎么组合。这也是这篇内容最大的价值所在:不是把JUnit的API念一遍,而是把一个写过几年测试的人会怎么用这些功能摊开给你看。
这篇适合两类读者。第一类是刚学完JUnit基础、准备把测试真正写进项目的人,你需要知道除了断言之外,JUnit还能怎么帮你管理准备、清理、跳过、限时这些测试周边的麻烦事。第二类是已经写了半年一年测试、但总觉得测试代码乱糟糟的人,你缺的不是语法,而是组织方式和踩坑经验。这篇不会有特别高深的理论,但每一条都是我会在真实项目里反复用到的做法。
1.1 为什么“核心功能”要分成两篇
不是故意拖节奏,而是JUnit 5的功能面确实宽。上一篇如果说是“怎么把断言写对”,这一篇就是“怎么把测试用例组织好、控制好、跑得更聪明”。前者的核心是校验结果,后者的核心是管理测试过程本身:什么时候建数据、什么时候清理、哪些环境跑哪些用例、跑多久算失败、如何让测试名字变得可读。这两类知识的使用场景完全不同,混在一起讲只会两头都讲不透。
从学习路径上看,我也建议你先把断言和参数化用熟,再来碰生命周期和条件控制。因为后者往往要配合前者一起理解,比如你知道了断言可以写在lambda里,才能明白动态测试是什么;你理解了参数化测试的边界数据设计,才知道空值注解为什么那么顺手。所以这一篇不要急着硬记注解,先把整体逻辑捋顺,再回到代码里逐个试。
1.2 这一章的实际学习思路
我的建议是别把这一章当语法清单背。你完全可以只看一遍,知道JUnit在生命周期、条件、超时、动态测试这些方向上有哪些“能力点”,然后回到真实业务测试里去对照。比如你下次写一个需要连数据库的测试,自然会想起@BeforeEach和@AfterEach怎么搭;当你遇到某个测试只在Windows上能过,自然会想起还有条件执行这回事。能力点先混个脸熟,需要时能想起来,比当场背下所有参数更有用。
接下来我按自己常用的顺序,把这几个功能逐个拆开讲,中间会穿插我在项目里实际踩过的坑和一直在用的写法。内容会比官方文档啰嗦,但每条都保证能落地。
2. 生命周期钩子:五个注解的默契
生命周期这个词听起来很玄,其实就是“测试方法前后能自动执行什么代码”。JUnit 5 提供了几个最常用的钩子:@BeforeAll、@AfterAll、@BeforeEach、@AfterEach,配合@TestInstance可以把整个测试类的初始化、清理、状态管理安排得明明白白。理解它们各自的职责,是写测试代码的骨架第一步。
2.1 四个基本注解各管哪一段
先看一张表,把这几个注解的区别说明白:
| 注解 | 执行时机 | 典型场景 |
|---|---|---|
| @BeforeAll | 当前测试类所有测试方法执行之前,只执行一次 | 启动数据库容器、创建连接、加载配置 |
| @AfterAll | 当前测试类所有测试方法执行之后,只执行一次 | 关闭连接、销毁容器、清理全局资源 |
| @BeforeEach | 每个测试方法执行之前都会执行一次 | 构造被测对象、准备最小数据集、开启事务 |
| @AfterEach | 每个测试方法执行之后都会执行一次 | 清理临时数据、回滚事务、释放本地资源 |
方法签名上要注意:默认的PER_METHOD生命周期下,@BeforeAll和@AfterAll必须是static方法。这一点在JUnit 4时代大家都很熟,但切到JUnit 5后不少人会踩坑——因为JUnit 5允许你用@TestInstance(Lifecycle.PER_CLASS)把整个测试类变成单实例模式,这时@BeforeAll就不需要static了。我会在后面单独展开。
执行顺序上还有一个容易被忽略的细节:@BeforeEach的执行顺序是从父类到子类,@AfterEach则相反,从子类到父类。这保证了当你有一个测试基类时,基类里的公共前置条件会先准备好,子类的特殊数据再补充上来;清理时则先清理子类的数据,再释放基类的资源,不会出现“父类资源已经关了,子类还在用”的问题。
2.2 我真正会用的生命周期组合:数据库测试
这里直接给一个我在实际项目中反复使用的组合。比如要测一个订单服务,测试类会连数据库:
@TestInstance(TestInstance.Lifecycle.PER_CLASS) class OrderServiceTest { private DataSource dataSource; @BeforeAll void initDataSource() { // 创建测试库连接池,整个过程只做一次 dataSource = createTestDataSource(); } @AfterAll void closeDataSource() { // 释放连接池资源 closeQuietly(dataSource); } @BeforeEach void openTransaction() { // 每个用例之前开启事务,保证数据隔离 txManager.begin(); } @AfterEach void rollbackTransaction() { // 每个用例结束直接回滚,既不污染数据库,也不用写一堆删除语句 txManager.rollback(); } @Test void shouldCreateOrderWhenPayloadValid() { // 测试代码只关心业务逻辑 } }这里最值得说的一点是事务回滚的用法。如果你还在每个测试方法里手动insert数据、测试完再delete,很快就会被数据残留搞疯。把@BeforeEach和@AfterEach搭配成“开事务+回滚事务”,是我在服务端测试里最推荐的方案,能有效避免测试数据互相污染。
我踩过的坑是:@BeforeAll里初始化了太多共享数据,结果某些测试方法会修改这些共享数据,导致后面的测试跑挂。解决方案是区分“全局只读资源”和“用例私有数据”:连接池、容器、配置属于前者,放进@BeforeAll;具体业务数据属于后者,放@BeforeEach里单独准备。这样既省了重复初始化的时间,又不会因为数据被改而互相影响。
2.3 测试实例生命周期PER_CLASS与PER_METHOD的取舍
默认情况下,JUnit 5为每个测试方法都创建一个新的测试类实例,这叫PER_METHOD。这样测试方法之间不会共享成员变量,好处是隔离性好,坏处是两个非static的@BeforeAll不能直接用,每次new实例也有轻微开销。当你把注解写成@TestInstance(TestInstance.Lifecycle.PER_CLASS)时,整个测试类只创建一次实例,所有测试方法共用同一个对象,@BeforeAll和@AfterAll可以不再是static。
我的建议是小测试类无所谓,能保持默认就保持默认;一旦遇到需要“每个测试方法都想用同一个mock对象或同一个连接”的场景,再考虑PER_CLASS。不过用了PER_CLASS就必须格外小心成员变量的污染,最好把可变状态控制在最小范围,否则一个测试改了成员变量,另一个测试就会跟着遭殃。我自己的惯例是PER_CLASS只用来放“创建成本高且逻辑上只读”的依赖,比如服务对象、Mapper、DataSource,业务数据一律不入成员变量。
还有@Nested嵌套测试的加入会让生命周期变得更有意思。一个简单的规则是:每一层嵌套类自己的@BeforeEach会先执行,然后才执行外层类的@BeforeEach;@AfterEach反过来,先内层后外层。有了这个机制,你可以在外层类准备好通用环境,在内层类针对某一种业务场景补充特定数据,测试结构会清晰很多。
3. 条件执行:只让该跑的测试跑起来
条件执行是我非常喜欢的一组能力,也是很多开发者容易用歪的地方。简单说,JUnit 5允许你给测试方法或测试类挂上“运行条件”,满足条件才跑,不满足就直接显示跳过,而不是失败。这对跨平台项目、依赖外部服务的测试、以及本地和CI环境差别很大的项目特别有用。
3.1 基于操作系统和JVM版本的条件控制
最常用的两个注解是@EnabledOnOs和@EnabledOnJre。比如某个测试依赖Windows下的文件路径分隔符,或者某个JVM版本才有的行为,可以这样写:
@Test @EnabledOnOs(OS.WINDOWS) void onlyRunsOnWindows() { // 检查Windows专属路径处理逻辑 } @Test @EnabledOnJre(JRE.JAVA_17) void onlyRunsOnJava17() { // 依赖Java 17新特性的行为 }对应还有@DisabledOnOs和@DisabledOnJre,语义正好相反。写上这些注解后,不符合条件的环境里测试桩会显示成“跳过”,不会让整个构建变红。选择用“启用型”还是“禁用型”,我个人的标准是:默认环境应该跑的测试用@EnabledOnXxx显式声明;默认环境不需要跑的测试用@DisabledOnXxx排除。比如大多数测试都基于Linux跑,那就只在Linux上显示启用即可,而不是每个非Linux环境都去写禁用。
3.2 用系统属性和环境变量做细粒度开关
有些条件的判断依据不是操作系统,而是运行配置。JUnit 5为此提供了基于系统属性和环境变量的条件注解,写法非常直白:
@Test @EnabledIfSystemProperty(named = "test.env", matches = "integration") void onlyRunsInIntegrationEnv() { // 只有显式传入 -Dtest.env=integration 时才执行 } @Test @EnabledIfEnvironmentVariable(named = "CI", matches = "true") void onlyRunsInCi() { // 只在持续集成环境里跑 }matches这里支持正则表达式,所以你也可以写更复杂的匹配规则。从JUnit 5.7开始,还支持@EnabledIf和@DisabledIf,配合一个返回布尔值的静态方法使用:
@EnabledIf("customCondition") @Test void conditionalByCustomLogic() { // 只有 customCondition() 返回 true 才执行 } static boolean customCondition() { return isSpecialEnvironment(); }这套东西把“跳过测试”这件事变得很灵活,但灵活也意味着容易被滥用。我在实际项目里见过最头疼的情况是:某个人为了让失败测试通过构建,直接在测试上加了@Disabled("暂不修"),结果这个测试永远不见天日。我的建议是,条件执行应该服务于“环境差异”,而不是掩盖“测试本身的失败”。暂时不修的测试要么明确留TODO并定期清理,要么用@Disabled并写明原因,绝不要用环境条件来掩盖。
3.3 我用条件执行的三种真实场景
第一种是跨平台兼容。项目里同时支持Windows、macOS、Linux,部分测试涉及文件系统、软链接、权限模型,平台差异很大。我会把平台相关测试分组,分别用@EnabledOnOs标注,确保每个平台上都只跑与本平台相关的用例,同时避免其他平台误报。
第二种是外部依赖只在CI环境存在。比如测试需要调用某个内部测试服务,本地开发时不方便启动,CI上有专门环境变量可以判断。这种情况用@EnabledIfEnvironmentVariable就特别舒服,本地自动跳过,CI自动跑起来,整个测试代码不需要为环境写if/else。
第三种是本地开发时的慢测试。全量接口测试执行时间可能很长,本地调试单测频率很高,我会给耗时的端到端测试加一个自定义条件:只有当系统属性包含-DrunSlowTests=true时才执行。平时本地只跑快速单测,提交到CI后跑全量。用条件而不是删测试,既保证了覆盖率,又不影响日常开发节奏。
3.4 条件执行最容易被人骂滥用的地方
条件执行最大的反模式就是“为了绿而绿”。比如某个测试在本地能过、CI上偶尔失败,不去查原因,直接加一行@DisabledOnEnvVariable(named = "CI"),这是把症状压下去了,但把问题留给了以后的同事。第二个反模式是在同一个测试类里堆满各种条件,导致别人根本分不清这个测试到底什幺时候会跑、什么时候会跳过,维护成本比写测试本身还高。
所以我在代码审查里会特别看条件注解的备注。如果条件后面不写任何说明,我会直接要求补充;如果写的是“为什么需要跳过”,那我会认真评估是不是合理。条件注解不是终点,它只是告诉JUnit“什么时候该跑”,真正的价值判断还是得由人来做。
4. 重复测试与参数化测试:同一段逻辑的两种放大方式
一个测试方法如果只跑一遍,有些边界情况可能根本碰不到。JUnit 5提供了两种“放大”一个测试的机制:一种是@RepeatedTest,把同一个测试重复执行多次;另一种是@ParameterizedTest,用不同的参数多次执行同一个测试模板。它们看起来都像“跑很多次”,但使用场景和背后的设计逻辑差别很大。
4.1 @RepeatedTest不是压测,而是稳定性检查
先看一个最简单的重复测试:
@RepeatedTest(3) void retryThreeTimes() { // 同一段逻辑重复执行3次 }默认显示名是repetition 1 of 3这样的格式,你也可以通过编程式测试名称自定义:
@RepeatedTest(value = 5, name = "{currentRepetition} / {totalRepetitions}") void customDisplayName(TestInfo testInfo) { // ... }这个功能最常见的误解是拿它当“性能测试”或者“压测”来用。其实@RepeatedTest更合适的场景是验证代码的稳定性:并发访问是否会出现偶发问题、线程安全类会不会在重复调用时暴露状态错乱、随机数相关逻辑有没有小概率异常。因为每次重复都是独立的测试实例调用,所以重复之间如果存在共享数据污染,很快就能暴露出来。但如果你想测的是“处理一万条数据需要多少秒”,那应该用专门的性能测试工具,而不是在单元测试里重复跑。
4.2 参数化测试的几种数据来源
参数化测试的核心是@ParameterizedTest,它必须配一个数据源注解。这里列一下最常用的几种:
| 数据源注解 | 适用场景 | 示例 |
|---|---|---|
| @ValueSource | 简单的常量序列 | 字符串、int、boolean |
| @CsvSource | 多字段参数,表格行数据 | "1,foo,2.5" |
| @MethodSource | 需要动态生成,或参数较复杂 | 返回Stream/List的方法 |
| @EnumSource | 枚举类型数据 | 传入所有枚举值 |
| @NullAndEmptySource | 边界值:null和空字符串/空集合 | 和@ValueSource配合 |
比如测试订单状态转换,用@CsvSource写起来非常舒服:
@ParameterizedTest @CsvSource({ "CREATED, PAID, true", "PAID, SHIPPED, true", "CANCELLED, PAID, false" }) void statusTransition(OrderStatus from, OrderStatus to, boolean expected) { Order order = new Order(); order.setStatus(from); assertEquals(expected, order.canTransitTo(to)); }再比如校验字符串输入时,空值空串常常是隐藏的坑:
@ParameterizedTest @NullAndEmptySource @ValueSource(strings = { " ", " " }) void blankStringShouldBeRejected(String input) { assertFalse(validator.isValid(input)); }这比手写五六个@Test方法干净得多。需要注意的是,使用@MethodSource时,数据源方法通常放在同一个类中,且是static的;在某些情况下也可以放外部工厂类里,但需要额外指定方法引用的全限定名。
4.3 选型建议:优先参数化,慎用重复
我给项目定的一条内部规则是:能参数化解决的问题,就不要用@RepeatedTest硬凑。参数化的价值在于“同一套逻辑,用不同的数据覆盖不同的分支”;重复测试的价值在于“同一个数据下,多次运行看稳定性”。两者目标不同,不能互相替代。
举例来说,判断一个字符串是否是合法手机号,这就是典型的参数化:合法号码、号码多一位少一位、包含字母、为空、全空格,每条都是新数据;而一个并发环境下多次往队列里加数据的测试,更适合@RepeatedTest。如果你在代码审查里看到有人用@RepeatedTest(100)去测一个纯粹的函数计算,那大概率是选错了工具。
还有一点值得提醒:参数化测试的代号要可读。参数化测试天然会把参数拼进测试报告,如果参数含义不明确,报告里只看到一行[1] args,那基本等于没用。我习惯用@ParameterizedTest的name属性定制展示,例如name = "[{index}] {0} -> {1}",这样CI日志里一眼就能看出是哪组数据失败。
5. 超时测试:给测试上一道保险
超时测试是我在线上业务项目里经常用、但在很多教程里看到讲得不够细的功能。JUnit 5用@Timeout注解来限制一个测试的执行时间,超过限制直接判失败。听起来很简单,但它背后的执行模型、跟业务超时的区别、以及常见的误用方式,都值得展开说。
5.1 @Timeout的执行模型和基本写法
@Timeout可以放在测试方法、测试工厂方法、测试模板或者整个测试类上。默认单位是秒,也可以指定TimeUnit:
@Test @Timeout(2) void mustFinishWithinTwoSeconds() { // 逻辑必须2秒内返回 } @Test @Timeout(value = 500, unit = TimeUnit.MILLISECONDS) void shouldBeVeryFast() { // 逻辑必须500毫秒内返回 }JUnit 5在处理@Timeout时,会把带超时方法的测试放到单独的执行线程中运行,超时后测试线程会被中断并标记为失败。所以它不是一个单纯的“在同一个线程里记录开始时间和结束时间”的朴素实现,而是真正尝试去中断长期运行的操作,这是我比较依赖它的原因之一。不过你也要知道,如果测试代码里发起了不可中断的操作,比如一个阻塞的IO调用不能及时响应中断,那么即使超时,线程也不一定能立刻退出,测试框架只能尽力处理。
在类级别加@Timeout,等于给类里所有测试方法设置默认超时,这很适合整套测试用例都不允许耗时的项目。
5.2 业务超时和应用测试超时是两回事
这里特别想强调一个区分:业务超时是指被测代码自己定义的“处理不能超过多少毫秒”的规则,通常写在业务代码内部,比如调用外部接口最多3秒;测试超时是JUnit对“这段测试代码必须在多少时间内完成”的限制。很多教程把这俩混在一起,导致新手在业务代码没有超时机制时,试图靠给测试加@Timeout来“验证”业务超时,这是不对的。
如果你要测试业务代码里的超时逻辑,正确做法是设一个很短的业务超时时间,用mock把外部调用挂起,然后断言业务代码按预期抛超时异常。@Timeout在这里只是防止测试自身出问题,而不是替代业务代码实现超时。反过来,如果你只是担心某个测试在CI上偶尔因为环境慢而超时,那也不是加@Timeout的理由,先想想是不是测试本身做得太重、依赖太多。
5.3 我踩过的超时相关坑
第一次写@Timeout时,我犯过一个低级错误:给一个需要等待异步任务2秒完成的测试加了1秒超时,结果每次必挂。这倒还好,毕竟失败是显性的。真正烦的是另一种情况:本地跑得飞快,CI上一旦负载高就偶发超时。这时候盲目调大超时时间不是好选择,我建议先缩小测试范围:排除外部网络请求、减少线程池竞争、避免测试之间共享资源。如果实在无法避免,就把超时设置得宽松一点,作为“防止死循环”的最后防线,而不是常规性能约束。
另一个容易踩的坑是@Timeout在线程池模型下有时表现得更“硬”。项目里如果已经有自定义线程池或者使用了并行测试,那@Timeout的执行上下文会更复杂。一个测试超时后线程虽然被标记失败,但那个线程上的任务可能还在继续运行,最终可能导致资源泄漏或者后续测试受干扰。所以超时时间不能拍脑袋定,要结合测试花在准备、执行、清理等各阶段的耗时来评估,留出合理余量。
6. 动态测试与测试组织展示
前几节聊的都是“把测试写好”,这一节聊的是“把测试组织得像人话”。JUnit 5在这块提供了两个很有价值的能力:@TestFactory动态生成测试用例,以及@DisplayName、@Nested这类让测试报告更可读的展示手段。很多开发者觉得这些只是“锦上添花”,但我在项目里发现,它们对测试的可维护性影响很大。
6.1 @TestFactory:让测试用例本身可以编程生成
动态测试允许你在运行时计算出一批测试用例。一个@TestFactory方法可以返回Stream、Collection、Iterable或Iterator,里面的每个元素都代表一个动态测试。最简单的例子:
@TestFactory Stream<DynamicTest> dynamicTestsForOrders() { List<Order> orders = loadTestOrders(); return orders.stream() .map(order -> DynamicTest.dynamicTest( "订单金额计算:" + order.getId(), () -> assertEquals(expectedAmount(order), order.calculateAmount()) )); }这段代码的威力在于:测试用例的数量和内容不是写死的,而是根据运行时数据计算出来的。比如你从配置文件里读了一批测试数据,或者从数据库里查出了一批历史订单,这些都能直接转成动态测试。每个动态测试都有自己的名字和结果,失败时能精准定位到是哪一份数据出了问题。但要注意,动态测试的测试方法本身不会触发@BeforeEach和@AfterEach,因为这些钩子绑定的是@TestFactory方法所在的测试实例生命周期。所以动态测试里的数据准备、清理逻辑,都要靠自己写在动态测试的lambda表达式中。这一点是动态测试和参数化测试一个比较本质的区别,选型时一定要想清楚。
6.2 @DisplayName与@Nested:把测试报告变成需求文档
我见过太多测试类叫OrderServiceTest,里面的方法叫test1、test2、checkStatus。测试跑完只有红绿,完全看不出业务含义。JUnit 5的@DisplayName就是为了解决这个问题:
@Test @DisplayName("创建订单时,如果库存不足,应该抛出库存不足异常") void shouldThrowWhenStockNotEnough() { // ... }中文显示名虽然有点长,但测试失败时,CI报告里直接能看到业务意图,不用再翻代码猜这个测试在干嘛。我在很多团队里立过一条规定:核心业务测试必须写@DisplayName,并且用“什么条件下、做什么事、期望什么结果”的格式来写。如果字符串太长,那就说明方法本身职责不单一,拆。
@Nested则可以把测试类按业务场景分层。比如一个支付服务测试类,外层放通用准备逻辑,内层分别定义“余额支付”“银行卡支付”“优惠券叠加”三个嵌套类。每个嵌套类里再写各自的测试方法。这样做的好处是,测试报告会变成一个树形结构,和需求文档的目录结构几乎一一对应。查找相关测试、定位失败点都变得很快。配合@DisplayName之后,这份测试报告甚至可以直接拿去做业务评审的支撑材料。
6.3 覆盖率和“测试叙事能力”的关系
很多团队把覆盖率当成唯一指标,要求行覆盖、分支覆盖达到多少百分比。但我的看法是,覆盖率只是一个辅助数字,真正重要的是测试是否把业务规则讲清楚。动态测试和后缀展示这些功能,本质上都在帮助测试代码获得“叙事能力”。同样的80%行覆盖率,可能是一个写满test1的类,也可能是一个用@DisplayName把异常分支都解释清楚的测试套件,维护成本天差地别。
实际项目里,我也会把动态测试和条件执行组合起来使用:外部数据源可用时动态生成大量数据驱动测试,数据源不可用时就跳过。这样既保证了CI上的执行质量,又让本地开发保持轻量。这种组合用起来后,我对“测试用例必须静态存在”这件事的执念很快就放下了。写测试的本质不是写一堆固定方法,而是把这个系统该有的行为边界定义清楚。
7. 配置文件与构建接入的实用建议
除了注解之外,JUnit 5还支持通过配置文件调整全局行为。把这些配置放在src/test/resources/junit-platform.properties里,就能覆盖很多注解做不到的“整体设置”。这一节我把项目里最常用的几项配置和自己接入构建工具的过程整理出来,顺便聊聊哪些调整值得做、哪些容易踩坑。
7.1 junit-platform.properties 里的高价值配置项
| 配置项 | 作用 | 我的推荐值 |
|---|---|---|
| junit.jupiter.execution.timeout.default | 给所有测试设置默认超时时间 | 视项目而定,通常不给全局默认 |
| junit.jupiter.execution.parallel.enabled | 是否启用并行执行 | 小项目不建议开 |
| junit.jupiter.execution.parallel.mode.default | 并行模式:same_thread/concurrent | 默认same_thread |
| junit.jupiter.displayname.generator.default | 全局显示名生成策略 | 可配自定义类,但不建议 |
| junit.jupiter.testinstance.lifecycle.default | 全局测试实例生命周期 | 默认per_method,保持默认 |
最容易被误解的是并行执行。JUnit 5从5.3开始支持并行,但并行测试不是“只要配置打开就自动安全”。如果测试之间共享数据库、文件、端口,并行跑起来马上就是成片失败。我碰过不只一次团队兴致勃勃打开并行,结果测试调了一天还没调完。所以我的建议是:先不要全局开并行,等测试互相隔离做得足够好之后,再用小规模并行验证,最后再逐步放开。
7.2 Maven和Gradle里的接入方式
JUnit 5的测试运行需要构建工具里启用JUnit Platform。Maven项目通常在pom.xml里配置surefire插件:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>3.2.5</version> </plugin>只要项目依赖里引入了junit-jupiter相关依赖,新版surefire会自动识别并运行JUnit 5测试。Gradle项目则是在build.gradle里加上:
test { useJUnitPlatform() }这一步看起来简单,但很多新项目跑不起来测试,根源就是构建工具只识别到了JUnit 4的注解,没有把JUnit Platform接进来。排查的时候,先确认junit-jupiter依赖在testImplementation/compileOnly里,再看构建脚本是否声明了useJUnitPlatform()。
另外一个跟构建有关的点:在CI上跑测试时,建议让测试报告直接输出到统一的目录,配合构建插件的测试报告,可以按测试类、测试方法快速定位失败用例。JUnit 5默认会生成XML报告,构建工具会读取它,不需要额外写报告逻辑,关键是不要随意改报告输出路径。
7.3 IDE里的执行与调试技巧
大多数主流IDE对JUnit 5的支持都很好。你在IDE里可以直接右键点击测试类或测试方法,选择运行,失败时能跳到具体的断言行。这里有几个我日常用得很顺手的小技巧。
第一个是给测试分组。如果项目里测试很多,编写测试的时候尽量用@Tag给它们打上标签。比如@Tag("slow")标记慢测试,@Tag("db")标记依赖数据库的测试,然后在构建工具或IDE里只跑某个标签的测试,会方便很多。
第二个是调试动态测试。动态测试因为生成过程比较复杂,IDE的调试体验没有普通@Test方法那么直接。我的做法是先在@TestFactory方法里打断点,看数据流是否正常;再在动态测试lambda表达式里打断点,看具体断言参数。如果动态测试数量很多,也可以先临时改成只生成其中一条数据,确认逻辑没问题后再恢复全量。
第三个是显示名和测试树。IDE的测试运行窗口通常会把@DisplayName显示出来,配合@Nested嵌套类,整个测试树会非常清晰。如果团队成员反馈“看不懂测试”,先别急着怪他们,看看是不是测试类里全是test1、test2这种名字,该用@DisplayName和@Nested把业务语义补上。
7.4 把功能变成习惯的几条个人原则
写了几年测试后,我逐渐沉淀出几条很朴素的习惯。第一,每个测试类的名字都说明它测的是哪个业务模块,每个测试方法名都说明它验证什么行为,测试代码从来不是“只要能过就行”。第二,公共准备和清理逻辑尽量用生命周期钩子收敛,避免每个测试方法里都重复贴一大段setup代码。第三,环境相关的测试一定要有明确的跳过条件,并且把条件写入代码注释,防止后来人完全看不懂为什么有的测试没跑。
这些习惯说起来都很简单,但真正把项目里的测试代码从“能跑”变成“好维护”,靠的就是这些小事。JUnit 5提供了一堆功能,如果只是当成语法点去背,用的时候还是无从下手;只有把它们放进真实项目里,遇到对应的场景再拿出来用,这些功能才会变成你自己的工具箱。
8. 常见问题与排查技巧实录
最后把我在实际项目里遇到过的、以及很多同事问过的问题汇总成一张排查表,方便你对照处理。这些问题单独看都不难,但组合在一起,几乎覆盖了JUnit 5核心功能使用中80%左右的常见卡点。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| @BeforeAll报错:Method must be static | 默认PER_METHOD下要求static | 改成static,或给测试类加@TestInstance(PER_CLASS) |
| @RepeatedTest跑了若干次后失败,想继续看后续 | 默认中断当前测试 | 从失败信息看已执行次数,再按需调整重复次数或断言逻辑 |
| @Timeout偶发失败,本地却正常 | CI环境慢、资源竞争、共享状态 | 先隔离测试,再评估是否确实需要永久超时限制 |
| 条件执行不生效,测试还是被跑了 | 注解写在错误的测试类/方法上,或者matches正则没匹配上 | 检查注解位置、系统属性名、正则表达式 |
| @Nested嵌套类执行顺序不符合预期 | 对@BeforeEach/AfterEach顺序理解偏差 | 记住:Before从小到大,After从大到小 |
| 参数化测试报告里参数看不懂 | 没有配置name属性 | 完善@ParameterizedTest的name,把参数含义展示出来 |
| 动态测试里发现@BeforeEach没执行 | 动态测试不走普通测试生命周期钩子 | 在动态测试的lambda里自行准备和清理数据 |
| 中文@DisplayName显示成乱码 | 构建或终端编码问题 | 检查项目文件编码、构建工具编码、IDE控制台编码 |
| 测试跑不起来,提示找不到测试 | 构建工具没启用JUnit Platform | 确认依赖和useJUnitPlatform配置 |
这些坑里,我对两个问题的印象最深。
第一个是@BeforeAll的static问题。当时有个同事把新写的测试类从JUnit 4迁移过来,在@BeforeAll上写了一大段初始化代码,结果一直报错,折腾了半天才发现是JUnit 5改规则了。我后来在团队里统一了两个习惯:一是新代码默认用PER_CLASS模式共享昂贵依赖,二是所有@BeforeAll方法哪怕不加static也写static,从源头减少这类困惑。
第二个是@Timeout和并行执行叠加时的表现。项目刚开并行测试时,我一直以为并行只会让速度变快,直到发现几个带@Timeout的测试开始神秘失败。查下来才明白,并行环境下线程调度更加不确定,超时时间设置的“余量”必须比串行时更大一些。那次之后,我立了一个规矩:并行测试和超时配置不要在项目里同时“拍脑袋”上线,必须小步验证。
排查问题时的另外一个心得是:JUnit的测试报告远比控制台输出重要。每次看到失败的测试,先不要急着改代码,先去看报告里失败的是哪个类、哪条用例、哪一步断言。报告里写的失败信息往往已经包含了实际值和期望值,对比一下就能猜出问题方向。这也再次验证了一点:测试代码写清楚了,排查效率自然高。与其事后各种debug,不如从一开始就把命名、分组、条件、超时这些“周边功能”用到位,让每个测试用例都能在最合适的环境里被稳定地执行。