ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从main方法到JUnit5:IDEA下的单元测试最佳实践

从main方法到JUnit5:IDEA下的单元测试最佳实践 1. 为什么我会劝你把测试从 main 方法迁到 JUnit51.1 main 方法测试的痛点我最早把 JUnit5 引入日常开发其实是被“测试代码还拿 main 方法跑”这件事逼的。以前项目里有人写了一个非常关键的金额计算工具类测的时候新建一个普通类写 main 方法里面 new 几个对象System.out.println 打印结果盯着控制台人工比对。当时数据量少还能忍后来工具类逻辑越来越复杂输入输出组合指数级上升main 方法里全是一行行 if 判断跑完还得自己删。最要命的是如果某一次改动导致之前的用例失败根本不知道是哪一次改动引入的只能从头到尾再跑一遍。这个场景我相信很多从传统 Java Web 项目转过来的同学都经历过。main 方法承担单元测试职责有三个硬伤一是没有断言机制出了结果靠肉眼判断数据一多必看错二是没有生命周期管理每个测试方法之间的数据清理、初始化逻辑要么重复写要么干脆不写导致测试之间互相污染三是没有收集和报告机制跑完就是控制台几行输出CI 里根本没法自动执行。JUnit5 恰恰是把这三个硬伤全部补齐了。它提供了一套完整的测试生命周期管理BeforeEach 和 AfterEach 能在每个用例前后做资源准备和清理assertThrows、assertEquals 这类断言方法把“结果是否符合预期”变成了程序自动判断配合 Maven Surefire 或 Gradle 的 test 任务测试还可以被拉到流水线里统一执行。所以从 main 方法迁到 JUnit5并不是多此一举而是让你的测试代码从“一次性脚本”变成“可维护的资产”。1.2 JUnit5 与 IDEA 的天然配合IDEA 对 JUnit5 的支持在近几个版本已经非常完整了基本属于开箱即用。社区版和专业版都内置了 JUnit5 的运行器和调试器不需要额外安装插件。我第一次在 IntelliJ IDEA 里右击一个测试方法选择 Run 的时候其实有点意外——它比我想象中要顺滑得多测试结果面板会直接帮你把失败用例、堆栈信息、断言差异全部列出来绿色表示通过红色表示失败遇到参数化测试还会以参数组合为单位逐行展示一眼就能看出是哪一组输入挂了。现在很多团队从旧项目迁移IDEA 会自动识别 Maven 或 Gradle 里引入的 JUnit5 依赖并配置好对应的模板如果你是新建的 Spring Boot 项目默认就带上了 spring-boot-starter-test其中已经包含 junit-jupiter。你几乎不用做额外配置就能在 IDEA 里体验到 JUnit5 的完整能力。不过“开箱即用”不代表没有踩坑空间后面我会详细讲依赖版本和包名混用的问题。2. 环境准备IDEA 内置支持与依赖引入别在这步翻车2.1 IDEA 层面基本不用额外装插件关于 IDEA 版本我建议至少用 2020.1 以后的版本。倒不是更早版本不能用而是 JUnit5 的支持在 2020.1 之后才真正成熟比如参数化测试结果的展示、Nested 嵌套测试的折叠结构、测试模板的生成这些体验在旧版本上可能会打折。当前主流的 2022、2023、2024 版本都做得很好了你在设置里能看到 JUnit 相关的模板选项说明内置支持已经就绪。有些同学会纠结“要不要装 JUnitGenerator V2.0 之类的插件”。我的建议是如果只是用 JUnit5 写单元测试官方内置支持足够如果希望一键生成测试类IDEA 在类名上右键选择 Go To - Test然后 Create New Test就能生成测试骨架真的不需要额外插件参与。刻意装一堆插件反而会在 IDEA 版本升级时遇到兼容性问题。2.2 Maven 依赖版本选型是第一个坑Maven 项目里引入 JUnit5 的标准方式是用 junit-jupiter 这个聚合依赖。我见过不少同事只引入 junit-jupiter-api结果运行时报找不到引擎这是因为 API 只负责编译期注解和断言真正执行测试还需要引擎实现。最省事的方式是直接引入 junit-jupiter它已经帮你把 api、params、engine 三个模块聚合进来了。dependencies dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId version5.10.2/version scopetest/scope /dependency /dependencies版本这里一定要留个心眼。JUnit5 的版本号从 5.0 一路走到 5.10 以上不同版本对 JDK 的要求不同。比如 5.10 系列在 JDK8 到 JDK21 都可以用但如果你用的是 JDK17我建议直接用 5.10.2 或更新的版本避免旧版本在模块化、反射访问上的限制。另外Spring Boot 项目里一般是通过 spring-boot-starter-test 引入 JUnit5 的它的版本由 Spring Boot 统一管理这种情况下你最好不要手动指定 junit 版本否则可能跟 Spring Boot 的依赖管理冲突。2.3 Gradle 依赖稍有不同但逻辑一致Gradle 项目引入 JUnit5 有两种常见方式。如果你用 Gradle 自带的 java 插件最低版本需要 4.6 以上在 dependencies 里配置dependencies { testImplementation platform(org.junit:junit-bom:5.10.2) testImplementation org.junit.jupiter:junit-jupiter } test { useJUnitPlatform() }如果不用 BOM可以写成 testImplementation org.junit.jupiter:junit-jupiter:5.10.2但用 BOM 的好处是后续只需要改一个版本号而且 junit-jupiter 下面的子模块版本不会出现不一致。Gradle 里最容易被忽略的是 test 块里的 useJUnitPlatform()。这句话是告诉 Gradle 用 JUnit5 平台去执行测试漏掉的话测试类会被当成 JUnit4 来执行注解完全不生效跑出来的结果永远是“没有测试”。我在帮同事解决问题时遇到好几次 IDEA 里测试能跑但命令行 Gradle 测试就是 0 个用例十有八九就是这个原因。3. 从零到一手写一个测试类并跑通3.1 一个足够小的被测类先拿一个最简单的计算器类来说明。写单元测试不需要华丽的业务代码重点是先把整个流程走通后面所有高级特性都能基于这个例子展开。package com.example.demo; public class Calculator { public int add(int a, int b) { return a b; } public int divide(int a, int b) { if (b 0) { throw new IllegalArgumentException(除数不能为0); } return a / b; } }这个类有两个方法一个加法一个除法足够覆盖普通断言、异常断言、参数化测试这些典型场景。3.2 测试类与断言在 IDEA 中光标停在 Calculator 类名上按快捷键 Alt EnterWindows或 Option EntermacOS选择 Create TestIDEA 会自动在 src/test/java 目录下生成对应的测试类骨架。如果你没找到这个入口检查一下是否已经在模块中添加了 JUnit5 依赖。package com.example.demo; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; class CalculatorTest { private final Calculator calculator new Calculator(); Test void should_add_two_numbers() { int result calculator.add(2, 3); assertEquals(5, result); } Test void should_throw_exception_when_divided_by_zero() { IllegalArgumentException exception assertThrows( IllegalArgumentException.class, () - calculator.divide(10, 0) ); assertEquals(除数不能为0, exception.getMessage()); } }这里有两个细节值得多说一句。第一JUnit5 的测试方法不要求 public包级私有就可以这是 JUnit4 到 JUnit5 的一个明显变化。第二测试方法命名我推荐用 should_xxx 这种下划线风格虽然 JUnit5 已经支持 DisplayName 来写中文描述但对于底层单元测试下划线风格在报表里更直观和 DisplayName 搭配使用效果最好。3.3 生命周期方法的执行顺序当测试类有多个测试方法时JUnit5 默认每个测试方法都会创建一个新的测试实例这也是和 JUnit4 一个很大的区别。JUnit4 默认所有测试方法共用同一个实例JUnit5 默认则是每个方法独立实例。这个设计主要是为了避免测试方法之间共享状态的冲突。生命周期注解的执行顺序是固定的BeforeAll 在类加载后执行一次属于静态方法BeforeEach 在每个测试方法前执行Test 执行AfterEach 在每个测试方法后执行AfterAll 在类中所有测试完成后执行一次也是静态方法。class LifecycleDemoTest { BeforeAll static void initAll() { System.out.println( 所有测试前执行一次 ); } BeforeEach void init() { System.out.println( 每个测试前执行 ); } AfterEach void tearDown() { System.out.println( 每个测试后执行 ); } AfterAll static void tearDownAll() { System.out.println( 所有测试后执行一次 ); } Test void firstTest() { System.out.println(第一个测试); } Test void secondTest() { System.out.println(第二个测试); } }运行这个类可以看到控制台输出顺序是所有测试前执行一次然后每个测试前后分别执行。这个机制在做数据库操作时特别有用比如 BeforeEach 里开启事务、插入测试数据AfterEach 里回滚事务清理数据保证测试之间互不影响。3.4 在 IDEA 里运行测试的三种方式第一种是最直接的在测试类或测试方法和左侧的绿色三角图标上右键选择 Run。第二种是用 Ctrl Shift F10Windows或 Control Shift RmacOS运行光标所在的测试方法。第三种是直接在类内部右键选择 Run All Tests跑完整类。这里有个使用习惯值得培养单个方法调试时不要每次都跑整个类方法级别的运行在 IDEA 里很快因为 JUnit5 的运行时只加载并执行你选中的那个测试方法。尤其是当你测试类里有几十个用例、其中一个挂掉的时候单独跑这个失败用例加断点调试效率会高很多。4. 进阶场景参数化测试、异常测试、超时、嵌套与动态测试4.1 参数化测试同一段逻辑多组输入单元测试里最常见的重复劳动就是为同一个方法写多组输入输出用例。JUnit4 时代要用 RunWith(Parameterized.class) 实现参数化写起来非常笨重。JUnit5 直接在 org.junit.jupiter.params 包里提供了 ParameterizedTest。ParameterizedTest ValueSource(ints {0, 1, 2, 3, 4}) void should_return_positive_number(int input) { assertTrue(Math.abs(input) 0); }ValueSource 是最简单的参数来源适合单个参数。如果有多组参数可以用 CsvSourceParameterizedTest CsvSource({ 2, 3, 5, 10, 20, 30, -1, 1, 0 }) void should_add_with_multiple_inputs(int a, int b, int expected) { assertEquals(expected, calculator.add(a, b)); }在 IDEA 里运行时参数化测试的展示非常直观每个参数组合会作为独立子用例展示哪个组合失败一目了然。这在定位问题的时候帮了大忙不用自己手写 for 循环然后猜具体哪一组数据挂了。参数化测试还有一个更强大的注解 MethodSource可以指定一个静态方法作为数据源适合组装复杂对象。但要注意数据源方法必须是 static 的除非测试类标注了 TestInstance(Lifecycle.PER_CLASS)否则 JUnit5 会报错这个坑我踩过提示信息比较模糊看了源码才知道是静态限制。4.2 异常断言assertThrows 的精确用法很多初学者测异常时喜欢用 try-catch 包起来然后 fail()这种做法不是不行但代码会非常啰嗦。JUnit5 的 assertThrows 可以直接断言某段代码是否抛出了指定异常而且能拿到异常对象继续验证 message 或自定义属性。前面 CalculatorTest 里的示例已经展示过一部分这里我想强调两个细节。第一assertThrows 的第一个参数是异常类型第二参数是一个 Executable 函数式接口里面可以写多行代码不只是一个表达式。这样我们就能测那种“前几行正常最后一行抛出异常”的复杂逻辑。第二如果方法抛出的异常是预期异常的子类断言也会通过。比如你断言 RuntimeException.class方法实际抛了 IllegalArgumentException测试依然是绿的。想精确匹配就把异常类型写到最具体的类。4.3 超时控制防止测试把自己挂死项目里但凡涉及到网络请求、文件读写、并发等待的测试都要考虑超时。JUnit5 的 Timeout 注解可以精确控制测试执行时间单位默认是秒也支持自定义。Test Timeout(3) void should_finish_within_three_seconds() throws InterruptedException { Thread.sleep(1000); }这个注解在集成测试里价值很大。有些外部服务不稳定某个接口调用超时之后会阻塞整个测试线程如果不加超时测试任务可能能跑好几个小时。还有一点要注意Timeout 是可以标注在类级别的这样类里所有测试方法都会继承默认超时时间个别方法可以用方法级 Timeout 覆盖。这种场景在做慢查询排查时非常有用。4.4 Nested 嵌套测试梳理业务场景当测试类越来越庞大时把所有测试方法平铺在一个类里会让代码变得难以阅读。Nested 注解允许我们在测试类内部再建内部测试类用来按业务场景分组。class OrderServiceTest { Nested class CreateOrderTest { Test void should_create_order_when_stock_enough() { // ... } Test void should_reject_order_when_stock_empty() { // ... } } Nested class CancelOrderTest { Test void should_cancel_order_when_status_is_pending() { // ... } } }IDEA 对 Nested 的处理很友好测试结果面板会按树形结构展开先展开内层类再展开具体用例。对于那种一个服务接口动辄十几个分支的类嵌套测试能让用例结构跟着业务结构走比平铺方式直观得多。4.5 动态测试数据驱动到极致DynamicTest 是 JUnit5 区别于 JUnit4 的一个重要特性。普通 Test 方法是编译期就固定好的而 DynamicTest 可以在运行时动态生成测试用例适合数据驱动的场景。TestFactory ListDynamicTest dynamicTestsFromStream() { return Stream.of(apple, banana, cherry) .map(word - dynamicTest(测试长度: word, () - { assertTrue(word.length() 3); })) .collect(Collectors.toList()); }TestFactory 方法返回 DynamicContainer 或 DynamicTest 的集合、迭代器、流运行时 JUnit5 会把每个动态测试当成独立用例执行。这个功能看起来非常强大但我不建议全部代码都用它来写。动态测试的问题在于测试数量和内容在运行前不可见阅读代码的人需要花额外精力理解测试结构。它适合用在外部的数据文件驱动的批量校验场景普通业务方法还是老老实实用 Test 更让人放心。5. IDEA 里的调试、覆盖率与快捷键把工具榨干5.1 断点调试run 和 debug 的区别很多人写测试习惯直接 Run等测试失败后再逐个脑补原因。我的习惯是先 Debug 执行在关键代码行打上断点观察每一步的入参、出参和中间变量。IDEA 的 Debug 面板配合 JUnit5 没有额外成本测试跑挂的时候点击测试结果面板里的红色堆栈IDE 能直接跳到源码对应行这一点比 JUnit4 时代还要流畅。特别推荐一个技巧在测试方法上右键选择 Modify Run Configuration可以把某个测试方法配置成常用运行配置。这样你在改完被测代码后只需要按 ShiftF10 就能直接重跑上一次的测试不用重新找方法右键。调试的时候配合断点整个循环速度会快很多。5.2 覆盖率工具判断漏测不再靠感觉IDEA 里自带覆盖率工具右键测试类选择 Run with Coverage跑完后会在代码编辑区左侧用颜色标注哪些行被执行过哪些没有。绿色代表已覆盖红色代表未覆盖黄色代表部分覆盖。配合 JUnit5我通常会点开“覆盖率的唯一标记”视图看看关键分支是不是所有 if/else 路径都走到了。这里提醒一句覆盖率只是参考指标不是目标。不要为了把覆盖率从 80% 刷到 90% 而写一堆走形式的用例那只会让你的测试套件变慢。合理的做法是先覆盖核心业务逻辑的分支再补异常路径和边界值最后才考虑覆盖率数字。5.3 测试代码模板与快速生成IDEA 在设置里的 File and Code Templates 中可以自定义测试类生成模板我习惯把常用的包名导入和注释模板预设好。右键生成测试类时IDEA 会弹出对话框让你选择要测试的方法并自动生成 JUnit5 的方法签名。另一个很实用的快捷键是 CtrlShiftTmacOS 上是 CmdShiftT在待测类中按这个组合键可以在源码文件和测试文件之间快速跳转。如果你新建了一个测试文件也可以通过这个快捷键从测试文件跳回源码。很多同事第一次看到我用这个快捷键时都以为我装了插件其实是 IDEA 内置功能。6. 我踩过的坑从包名混用到 Surefire 版本冲突6.1 JUnit4 和 JUnit5 包名混用IDEA 却显示绿色这是我在实际项目中遇到最多的一个问题。团队迁移 JUnit5 时代码里一部分人写的是 org.junit.Test另一部分写的是 org.junit.jupiter.api.Test。IDEA 的测试运行器对两种注解都识别如果你同时引入了 JUnit4 的依赖IDEA 会用 JUnit4 引擎跑 org.junit.Test用 JUnit5 引擎跑 org.junit.jupiter.api.Test界面上一片绿看起来两个版本能共存。但问题出在断言上。JUnit4 的 org.junit.Assert 和 JUnit5 的 org.junit.jupiter.api.Assertions 不能混用。比如你在 Test 方法里 import 了 org.junit.jupiter.api.Test却用 org.junit.Assert.assertEquals那么如果断言失败抛出的可能是 AssertionError 而不是 AssertionFailedError在某些 IDE 视图和报表里展示的信息不够友好。更严重的是有些老旧的 JUnit4 Rule 和 Runner 在 JUnit5 里完全无效比如 ExpectedException 规则和 RunWith(SpringJUnit4ClassRunner.class)遇到这种情况测试虽然能跑但预期断言根本不生效。6.2 Maven Surefire 版本与 JUnit5 不匹配如果你用 Maven 构建项目并且发现命令行执行 mvn test 时显示 0 个测试而 IDEA 里测试跑得好好的大概率是 Maven Surefire 插件版本太老。JUnit5 需要通过 Surefire 的 JUnit Platform 提供器来发现测试老版本 Surefire 不认识 junit-jupiter-engine。最简单的解决办法是升级 Surefire 到 2.22.2 以上版本如果你在用 Spring Boot 管理依赖建议直接用 3.0.0 以上的版本避免和 JDK17 的编译参数冲突。在 pom.xml 里显式指定build plugins plugin groupIdorg.apache.maven.plugins/groupId artifactIdmaven-surefire-plugin/artifactId version3.2.5/version /plugin /plugins /build这个坑的排查链路我留着很深印象第一次遇到时我以为是模块路径问题改了测试类路径无效又以为是编码问题加了 UTF-8 配置仍无效最后看 maven 输出日志里的 surefire 版本才发现问题。所以提醒一句IDEA 里能跑和命令行里能跑真的是两回事。6.3 IDEA 控制台中文乱码处理测试代码里用 DisplayName 写中文描述或者断言文案里有中文IDEA 控制台经常出现乱码。这通常是文件编码设置没统一导致的。检查 Settings - Editor - File Encodings 里的 Global Encoding、Project Encoding 和 Properties Files 默认编码都设成 UTF-8。还不行的话在 Help - Edit Custom VM Options 里加上 -Dfile.encodingUTF-8重启 IDEA 基本就解决了。乱码看着只是显示问题但在报告归档时很影响阅读。如果你的测试结果要被集成到 CI 平台的 HTML 报告里中文乱码可能直接导致报告不可读所以我还是建议一开始就把编码统一。6.4 排查怀疑方向测试结果全跳过还有一种情况测试类前面的图标是灰色运行后结果全部显示 Skipped。排除 Disabled 注解和 EnabledIf 这类条件注解后最可能是 JUnit5 的测试发现机制出了问题。比如你在非 test 目录下放了测试类或者类的访问修饰符不是 public 也不是包私有而显式写成了 privateJUnit5 默认不会扫描 private 类。处理后还是跳过可以打开 IDEA 的 Build 输出日志看测试引擎扫描时的 warning 信息。JUnit5 的跳过程序一般会在日志里说明跳过原因比如“类不包含任何可执行测试方法”或“测试方法不是包级私有或 public”。这条舍得花两分钟看日志比盲猜要快得多。7. 最后说几点我的使用习惯现在我的日常节奏是写完一个业务方法先不急着跑整个项目直接在 IDEA 里创建 JUnit5 测试类把正常路径、异常路径、边界值用 ParameterizedTest 和 assertThrows 快速铺一遍然后 Debug 模式跑盯着覆盖率工具补齐漏掉的分支。这个过程已经快成了肌肉记忆比旧时代打开 Postman 手动点接口验证不知道快了多少。如果你还在用 main 方法或者 JUnit4 写测试从 JUnit5 迁移其实不需要一次到位。可以先在一个基础工具类上试点跑通流程再逐步把旧的测试用到的断言和生命周期注解替换过来。JUnit5 在 IDEA 里的体验绝对值得你花一个下午时间做这件事。
返回列表