Spring Boot项目JUnit 4与JUnit 5选型配置与迁移指南

发布时间:2026/10/12 13:14:06
Spring Boot项目JUnit 4与JUnit 5选型配置与迁移指南
1. 为什么Spring Boot项目测试总在“版本”上栽跟头不管你是刚把第一个接口写完的新手还是已经维护了几年老项目的资深开发者只要在Spring Boot里写过单元测试基本都遇到过这么几种情况依赖照着网上的教程加了一堆启动测试时报NoSuchMethodError或者ClassNotFoundException本来Test注解用得挺好升级一下Spring Boot版本所有测试突然全红了还有更隐蔽的——测试能跑通但MockMvc一直注入不进来或者Transactional根本不回滚查了半天才发现是JUnit版本和Spring测试支持之间没配对。这些问题归根结底指向同一个核心Spring Boot对JUnit 4和JUnit 5的支持方式完全不一样而大多数教程根本没说清楚这两条路该怎么选、怎么配。我在帮朋友排查项目问题时见过太多同类情况pom里同时出现junit:junit和org.junit.jupiter:junit-jupiter两个版本的注解混着用旧的RunWith和新的ExtendWith叠在一起项目能编译但测试行为完全不可控。这篇文章以“Spring Boot中使用JUnit 5/JUnit 4”为主线把两条路的依赖配置、注解差异、版本兼容、常见坑位全盘梳理一遍。适合这几类读者正在从JUnit 4迁到JUnit 5的老项目维护者新开Spring Boot项目不想在测试依赖上浪费时间的开发者以及像我一样排查半天发现是版本问题导致的测试异常想一次搞清楚底层规则的排查党。我先说结论方便你有个框架Spring Boot 2.2之后默认支持JUnit 5但工程里如果还在用老一套的spring-boot-starter-test它默认带进来的其实是JUnit 4兼容层Spring Boot 2.4之后依赖里默认的测试引擎变成了JUnit 5但junit-vintage-engine这个JUnit 4兼容引擎默认不再包含。这个“默认”的变化就是大部分混乱的源头。下面我按“选型 - 配置 - 能力 - 踩坑”的顺序来拆。2. JUnit 5和JUnit 4到底差在哪不是升级是重构很多人把JUnit 5当成JUnit 4的加量版觉得无非就是注解名字换了一下、多几个断言方法。这个理解会直接导致你在实际接入时踩坑。JUnit 5本质上是一次彻底的架构重构它把原来JUnit 4那个大而全的框架拆成了三个独立模块各管一摊JUnit Platform在JVM上启动测试框架的基础也是测试引擎TestEngine的运行入口。这一步是JUnit 5区别于JUnit 4最核心的架构变化它允许不同测试框架在同一个平台上运行。JUnit Jupiter我们写测试用的注解Test、BeforeEach、DisplayName、断言、测试引擎逻辑都在这里它是在Platform之上实现的具体编程模型。JUnit Vintage一个兼容引擎用来在JUnit Platform上跑JUnit 3/JUnit 4的老测试。这个拆分的价值不仅仅是“更干净”而是生态层面的。Spring Boot里的spring-test模块可以只依赖Platform抽象不需要绑定具体某个版本的JUnitMockito、AssertJ这些第三方库也能各自适配IDE和构建工具只需要理解Platform一套标准。这也是为什么Spring Boot能把测试支持演进得这么快——它不需要跟着某个具体框架版本走只要适配Platform接口就行。JUnit 4只有一个junit:junit包所有能力混在一起。好处是老项目只要引一个依赖就能跑坏处是需要扩展能力时JUnit 4只能通过RunWith和Rule这两个钩子去做很多高级场景比如条件执行、动态测试、参数扩展根本塞不进去。JUnit 5直接用一组清晰的SPI接口如TestExecutionListener、ParameterResolver、TestTemplateInvocationContextProvider替代了那个万能的RunWith从框架设计上说扩展点更可控、更类型安全。给你一个特别直观的类比JUnit 4像一台老式一体机打印、扫描、复印都在一个盒子里要加功能只能往USB口上插U盘JUnit 5像把打印机、扫描仪、复印机拆成了独立设备用统一接口连到一个办公平台上。Spring Boot对测试的编排能力就建立在这套统一接口上。理解了这一层你再看Spring Boot的依赖变化就不会迷糊了。Spring Boot 2.1及之前的spring-boot-starter-test里包含的是JUnit 4你用RunWith(SpringRunner.class)是对的Spring Boot 2.2到2.3的starter里引入了JUnit 5的依赖但同时也保留了JUnit 4的兼容引擎这一阶段你可以两个注解混用Spring Boot 2.4开始starter的默认测试能力迁到了JUnit 5如果你还需要跑老测试必须手动加junit-vintage-engine。这也是为什么“网上抄来的配置跑不通”的概率这么大——因为你看到的教程可能是两年前的而你用的Spring Boot版本已经是三年前的设计了。这不是文章写错了是版本演进把配置从“默认可用”改成了“需要显式声明”。3. Spring Boot项目里两条路线的接入实操开始配置之前先要明确你当前项目的Spring Boot版本这决定了“最小配置”长什么样。我建议的做法是先看pom.xml里的parent或者dependencyManagement中的Spring Boot版本号。如果是2.4及以上直接按JUnit 5的方案走如果是2.3及以下看现有代码是否已经在用RunWith、Before、After这些老API有的话先按JUnit 4方案理清楚再规划迁移。3.1 路线ASpring Boot JUnit 4老项目维护场景虽然JUnit 5已经是很成熟的选择但现实情况是很多老项目还跑在JUnit 4上有些是历史包袱太重改不动有些是团队还没计划迁移。如果你要在一个Spring Boot 2.4以上的新工程里强制用JUnit 4也不是不行但要手动排除JUnit 5相关依赖。以Spring Boot 2.7.x为例最直接的JUnit 4配置是这样dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope exclusions exclusion groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter/artifactId /exclusion /exclusions /dependency dependency groupIdjunit/groupId artifactIdjunit/artifactId scopetest/scope /dependency注意第二段没有写版本号。这是Spring Boot的dependencyManagement在起作用——Boot的BOM里已经锁定了JUnit 4的版本你不需要也不能手动指定否则可能在升级Spring Boot时造成版本不匹配。写测试的时候Spring Boot 2.x对JUnit 4的入口是RunWith(SpringRunner.class)RunWith(SpringRunner.class) SpringBootTest public class UserServiceTest { Autowired private UserService userService; Before public void setUp() { // 每个测试方法执行前初始化 } After public void tearDown() { // 每个测试方法执行后清理 } Test public void testGetUserById() { User user userService.getUserById(1001L); Assert.assertNotNull(user); Assert.assertEquals(张三, user.getName()); } }如果你是Spring Boot 3.x我不建议再折腾JUnit 4了。Boot 3是基于Jakarta EE 9构建的对JUnit 4的支持已经完全舍弃硬要配的话需要极老版本的Spring Test和一系列补丁依赖真要走这条路还不如直接规划迁移。3.2 路线BSpring Boot JUnit 5新项目推荐路径JUnit 5在Spring Boot 2.4以上的配置非常简单尤其当你用2.4以后的版本时spring-boot-starter-test已经把JUnit 5junit-jupiter作为默认引擎。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency就这一条。它的传递依赖里已经包含了org.junit.jupiter:junit-jupiterJUnit 5核心包括API、参数化测试和引擎org.mockito:mockito-junit-jupiterMockito对JUnit 5的集成org.assertj:assertj-core流式断言库org.springframework:spring-testSpring测试支持org.springframework.boot:spring-boot-testBoot层的测试支持如SpringBootTestcom.jayway.jsonpath:json-path等辅助库写测试时用JUnit 5的这套写法import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; SpringBootTest class UserServiceTest { Autowired private UserService userService; BeforeEach void setUp() { // JUnit 5的初始化方法不需要public方法包级私有即可 } Test void testGetUserById() { User user userService.getUserById(1001L); Assertions.assertNotNull(user); Assertions.assertEquals(张三, user.getName()); } }看到区别了吗RunWith(SpringRunner.class)在JUnit 5里不需要了——Spring通过ExtendWith(SpringExtension.class)来接入JUnit 5但SpringBootTest这个注解在Boot 1.5之后自己就带上了ExtendWith(SpringExtension.class)的元注解所以直接用就行。这是一个非常容易被忽略的细节很多从JUnit 4转过来的同学还在到处找ExtendWith的配置。3.3 版本对照速查表Spring Boot版本Starter默认测试引擎配JUnit 4的复杂度配JUnit 5的复杂度推荐选择2.1及以下JUnit 4直接可用需要手动加依赖并排除旧包JUnit 42.2 - 2.3JUnit 5 Vintage兼容直接可用直接可用项目新建用JUnit 52.4 - 2.6JUnit 5无Vintage需手动加junit:junit并排除jupiter直接可用JUnit 52.7 - 3.xJUnit 5无Vintage不推荐直接可用JUnit 5这个表是我实际配置大量项目后的总结核心判断逻辑很简单新项目无脑JUnit 5老项目如果代码在2.4之前诞生、且测试数量大短期保留JUnit 4没问题但新写的测试尽量切到JUnit 5风格为后续迁移逐步铺路。4. JUnit 5真正能用起来的高级能力JUnit 5绝不只是换了个包名。至少在Spring Boot项目里下面这些能力是JUnit 4很难优雅实现的而JUnit 5只是加了注解或参数就搞定。4.1DisplayName中文测试名可读性直接拉满JUnit 4里没这个注解你只能靠testXxx()这样的方法名硬撑看的多了眼睛干。JUnit 5直接用字符串描述测试意图DisplayName(用户服务 - 查询用户信息) class UserServiceTest { Test DisplayName(根据用户ID查询用户返回完整的用户信息) void testGetUserById() { // ... } }IDEA和Maven Surefire的报告里显示的都是这个DisplayName而不是方法名。测试报告的可读性提升对业务方汇报也友好很多。4.2 参数化测试JUnit 5的王牌功能JUnit 4里要做“同一套逻辑跑多组数据”的话要么写循环、要么定制RunWith(Parameterized.class)——后者非常繁琐需要专门的类构造器配合。JUnit 5把这个能力做成了参数化测试最简单的用法ParameterizedTest ValueSource(strings { 张三, 李四, 王五 }) void testFindUsersByName(String name) { ListUser users userService.findUsers(name); Assertions.assertTrue(users.stream().allMatch(u - u.getName().equals(name))); }还有更强大的CsvSource直接在注解里写多组数据省去大量手写数据集工厂的样板代码ParameterizedTest CsvSource({ admin, true, user, false, guest, true }) void testCheckPermission(String role, boolean expected) { boolean result permissionService.check(role); Assertions.assertEquals(expected, result); }Spring项目里配合MethodSource有个技巧很实用——可以用一个静态方法动态生成测试数据甚至从数据库或接口响应里取数据来喂给测试方法这让“根据线上真实数据回放做测试”成为了可能。4.3 嵌套测试给测试内部建结构化目录JUnit 5的Nested注解允许在测试类内部构造层级SpringBootTest DisplayName(订单模块测试) class OrderServiceTest { Nested DisplayName(创建订单相关) class CreateOrderTests { Test DisplayName(创建订单成功时返回订单号) void testCreateSuccess() { } } Nested DisplayName(取消订单相关) class CancelOrderTests { Test DisplayName(取消不存在订单时抛出异常) void testCancelNotFound() { } } }测试报告会按嵌套层级展示层次更清晰。尤其在业务模块测试方法非常多的时候这个能力能把“哪部分出问题了”这件事从一堆平铺的方法名里跳出来报告扫一眼就知道是哪个业务子模块挂了。4.4RepeatedTest与动态测试覆盖特殊场景RepeatedTest(5)可以让一个测试重复执行5次适合稳定性和并发相关的冒烟验证动态测试TestFactory更进一步是在运行时生成测试用例的方法常用于数据驱动场景。虽然这两个用到的频次不高但遇到随机性问题、并发竞态问题时能少写很多蹩脚的循环代码。4.5 条件测试按环境跳过或停用这个在实际项目里超级实用。比如某些测试只希望在CI环境跑某些涉及外部接口的测试本地能跑但CI没网络就不应该跑Test EnabledIfSystemProperty(named test.env, matches ci) void testOnlyInCI() { } Test DisabledIfEnvironmentVariable(named SKIP_INTEGRATION, matches true) void testIntegration() { }JUnit 5还提供了Timeout注解可以给测试用例设置超时时间避免数据库连接池耗尽导致测试挂几分钟才报错。这些都是JUnit 4里很难用配置化方式做到的。这些能力组合起来意味着什么你可以在同一个测试类里既有纯单元测试Mock掉所有外部依赖、又有条件跳过的集成测试、还有参数化跑的数据校验一套结构能覆盖绝大部分测试需求。5. Spring Boot测试的依赖版本管理为什么有的包不用写版本号很多新人在配置Spring Boot测试依赖时会有一个困惑为什么spring-boot-starter-test里的传递依赖不用写版本号为什么我自己引入junit-jupiter的时候不写版本号也能编译过原因在Spring Boot的统一依赖管理机制上。Spring Boot的父POMspring-boot-dependencies用dependencyManagement定义了一整套经过兼容性测试的依赖版本。你引入一个Spring Boot管理的依赖而不指定版本时Maven就会去这个清单里找匹配的版本你在IDE里看到的实际解析版本就是BOM里锁定的那个。这是一个特别值得强调的实践原则Spring Boot项目的测试依赖不要手动指定版本号。只有这个包不在Boot的BOM管理范围内比如你自己引入的第三方库才需要自己声明版本。看一个反面案例。同一个项目里有的人在pom里写了dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter-api/artifactId version5.7.2/version scopetest/scope /dependency而Spring Boot 2.7默认管理的JUnit Jupiter版本可能是5.8.x。一旦某个测试只有在新版本才有修复的bug就会因为手动锁定旧版本而出现诡异行为。更严重的是如果手动指定的版本和Spring Boot中的其他组件比如Mockito、Spring Test不兼容升级时会直接让你排查到崩溃。所以我的建议就一条项目依赖走Boot BOM不要去手动覆盖版本除非你完全清楚自己在干什么并且有明确的兼容性测试结果。这也是为什么我在前面给的配置示例里JUnit 4和JUnit 5的依赖都没有version——Boot都替你管了。补充一点如果你是用Gradle来做依赖管理Spring Boot插件同样提供dependency-management插件来统一版本。逻辑一致不再赘述。6. 从JUnit 4迁到JUnit 5的完整映射与动作清单如果你负责的老项目已经在用JUnit 4想开始迁到JUnit 5不用一次性把几百个测试全改了。我建议分步骤做每步都能独立编译运行第一步在pom里引入junit-vintage-engine确保老测试继续跑通这一步是为了让构建不红dependency groupIdorg.junit.vintage/groupId artifactIdjunit-vintage-engine/artifactId scopetest/scope /dependency第二步新写的测试全部用JUnit 5注解。Java包命名是天然的隔离——JUnit 4的注解都在org.junit包下JUnit 5的注解都在org.junit.jupiter.api包下。你可以混用但注意一个类里不要同时用两个版本的Test否则有些Runner会只执行其中一个。第三步按映射表逐类迁移JUnit 4JUnit 5说明BeforeBeforeEach每个方法前执行AfterAfterEach每个方法后执行BeforeClassBeforeAll类加载后执行一次JUnit 5要求方法staticAfterClassAfterAll所有方法结束后执行一次JUnit 5要求方法staticTest(expected XxxException.class)Assertions.assertThrows(XxxException.class, () - ...)断言异常方式变了Test(timeout 1000)Timeout(1)或Assertions.assertTimeout(...)超时配置方式变了IgnoreDisabled停用测试RunWith(SpringRunner.class)不需要SpringBootTest自带ExtendWith(SpringExtension.class)启动Spring容器的方式RunWith(MockitoJUnitRunner.class)ExtendWith(MockitoExtension.class)Mockito与JUnit 5的集成Assert.assertEqualsAssertions.assertEquals断言类变更推荐静态导入org.junit.Testorg.junit.jupiter.api.Test包名变更第四步SQL初始化相关方法从Before里迁移时注意BeforeAll只能用static方法。如果你在JUnit 4里用了BeforeClass里初始化数据库JUnit 5要写成BeforeAll static void initDatabase() { // 静态方法才能调用 }第五步删掉junit-vintage-engine强制你的测试类全部运行在JUnit 5上。这时如果有遗漏的老API构建会直接报错报什么就补什么比人为排查准确得多。这套流程我实操过几轮关键点是第二步和第三步不要合并做——不要“一边写新注解一边删老依赖”那样出错后根本分不清是迁移问题还是依赖问题。7. 实际项目里的踩坑实录与问题速查7.1 Mockito和JUnit 5的“兼容性陷阱”老项目里经常能看到这种写法RunWith(MockitoJUnitRunner.class) public class UserServiceTest { Mock private UserMapper userMapper; }切到JUnit 5后RunWith失效而且mockito-junit-jupiter这个桥接包如果没加进依赖Mock注解根本不会生效——你的mock对象是null调用时直接NPE。正确的JUnit 5写法是ExtendWith(MockitoExtension.class) class UserServiceTest { Mock private UserMapper userMapper; }同时确保pom里有mockito-junit-jupiterSpring Boot的starter在2.2之后默认带。这个包的作用就是把Mockito的生命周期挂接到JUnit 5的扩展模型上没有它Mock就是普通的注解什么都不做。7.2Transactional不回滚测试数据污染库在Spring Boot集成测试里代码长这样SpringBootTest Transactional void testCreateUser() { userService.createUser(...); }这个设计是想利用事务回滚让测试执行完数据自动还原。但它有个隐含前提——测试类所在的线程和事务管理线程必须一致而且测试引擎必须支持Spring的测试事务管理。如果你把测试切到JUnit 5但忘了测试类里有RunWith(SpringJUnit4ClassRunner.class)相关配置事务管理器可能根本没被挂载上去导致数据写进了库测试看着“通过”了DB里垃圾数据越来越多。排查方法是看日志里有没有Rolled back transaction for test这种提示没有的话检查SpringBootTest是否生效、测试类是否还在被某些Runner劫持。7.3 Surefire插件版本与JUnit 5测试“跑了个寂寞”有些项目有专门的maven-surefire-plugin配置。JUnit 5项目里如果你把Surefire强制设成旧版本比如2.12Maven可能压根不执行任何JUnit 5测试但构建还是显示成功——因为Surefire的2.x版本只能识别JUnit 4的测试JUnit 5的测试类在它眼里不存在。这比测试报错更坑你以为自己在写测试其实Maven根本没跑过它们。解决方案是确保Surefire版本在2.22.0以上这个版本开始支持JUnit Platform。Spring Boot 2.4以上默认内置的是3.x版本一般不用改。Gradle用户注意useJUnitPlatform()要在test任务里显式开启老项目经常忘了加同样会出现“测试没执行”的假象。7.4No tests found但是明明有测试类这个报错通常发生在IDE里直接运行测试类时。原因一般是测试类没有遵循“公共无参构造器 测试方法不是static”的约定或是因为构建工具没有正确发现测试。排查思路按顺序确认类名不是以Test结尾也没关系关键是方法上有Test注解确认类不是abstract的JUnit 5不能实例化抽象测试类如果类定义在package-private下JUnit 5完全支持JUnit 4要求public检查Maven的testIncludes配置是否限制了**/*Test.java之类的通配符。我给这类问题一个优先解先在IDE里对着某个测试方法右键执行如果能跑通说明测试代码没问题问题出在构建配置如果IDE也报No tests found看注解包名是否引错了。7.5 版本冲突ClassNotFoundException 与 NoSuchMethodError最后聊一个最“经典”的报错组合。类的NoClassDefFoundError、运行期的NoSuchMethodError基本都指向你的classpath里有多个不同版本的JUnit依赖。比如同时解析到了junit:junit:4.12和org.junit.jupiter:junit-jupiter-api:5.4.2而代码里用了两个时代的注解。用Maven排查命令看依赖树是最直接的mvn dependency:tree -DincludesjunitGradle用gradle dependencies --configuration testRuntimeClasspath看到同一groupId出现多个版本用exclusion排除不需要的。另一个高频坑是有的中间依赖会把JUnit 4带进运行时即使你自己只加了JUnit 5最后运行测试时加载了JUnit 4的类然后所有用org.junit.jupiter.api.Test注解的类全部无法识别。这种情况下构建配置里的传递依赖必须做一次彻底清洗。8. 项目里测试启动的最终检查清单每次新建Spring Boot项目或者接手新项目我都建议按这份清单过一遍测试环境能省去后面大量的“为什么跑不通”排查时间[ ] pom或build文件里只有一条测试starterspring-boot-starter-testscopetest[ ] 不要手动指定JUnit相关版本号让Boot的BOM统一管理[ ] JUnit 4时代的RunWith全部替换为JUnit 5的ExtendWith或不写SpringBootTest自带[ ]Before/After按对应关系换成BeforeEach/AfterEachBeforeClass/AfterClass换成BeforeAll/AfterAll且方法用static[ ]Assert.*换成Assertions.*并静态导入[ ] Mockito测试用ExtendWith(MockitoExtension.class)[ ] 如果项目里还需要跑JUnit 4测试显式引入junit-vintage-engine[ ] Maven项目的Surefire不低于2.22.0Gradle项目的test任务里加了useJUnitPlatform()[ ] 执行mvn test后确认日志里能看到Tests run: X, Failures: Y, Errors: Z并且X不为0——这能避免“测试根本没跑却在读绿色构建”的假象9. 最后说几个我的实际体会Spring Boot的测试栈里JUnit 5和JUnit 4的切换本质上不是“升级依赖”这么简单的事。它牵扯到Spring Test对测试框架的接入方式、Mockito的扩展机制、Maven/Gradle的构建识别逻辑每一层都有各自的版本适配条件。我碰过太多次“改了注解但没跑起来”的案例最后发现都卡在依赖传递或Surefire版本上。如果让我给一个最省心的实践方案新项目直接用Spring Boot 2.4以上版本 默认starter JUnit 5编写测试不要回头看JUnit 4老项目迁不动的时候在spring-boot-starter-test旁边加junit-vintage-engine让新旧测试共存过渡——这是投入产出比最高的方案。另外有两个小技巧在实际调测中非常有用。一是给Spring Boot的测试类加上SpringBootTest(webEnvironment SpringBootTest.WebEnvironment.RANDOM_PORT)来模拟真实HTTP调用配合TestRestTemplate或MockMvc做端到端验证二是当某个测试方法需要特定Bean但不想动用整个Spring上下文时可以混用MockBean和JUnit 5的ExtendWith局部替换依赖而不是整容器启停。测试写得约快、反馈越及时工程交付的质量也越可靠。