Spring Boot测试实战:JUnit 5与JUnit 4的差异、迁移与常见坑
作为一名整天跟Spring Boot打交道的开发者我几乎每个项目都要面对测试代码的编写。从早期的JUnit 4到后来的JUnit 5测试框架的演进给我们的日常工作带来了不少变化也带来了不少困惑。说实话我自己在切换过程中也踩了不少坑比如注解失效、依赖冲突、测试类不执行这类问题搜遍全网才找到解决方案。这篇文章我就把Spring Boot项目中使用JUnit 5和JUnit 4的完整经验整理出来包括依赖怎么配、注解怎么用、两种版本有什么区别、混合使用时要注意什么以及我实际开发中遇到的各种坑和排查方法。不管你是刚开始接触Spring Boot测试的新手还是正在从JUnit 4迁移到JUnit 5的老手这篇文章都能让你少走一些弯路。1. 测试框架选型思路1.1 为什么测试在Spring Boot项目中如此重要很多开发者写Spring Boot项目时习惯先把业务代码写完然后手动启动应用用Postman之类的工具去请求接口验证。这种方式在项目初期没问题但随着项目变大、接口变多、逻辑变复杂手动测试的效率会越来越低。而且手动测试最大的问题是覆盖不全——你不会每次改动都去把所有接口手动测一遍这就导致很多bug在改完代码后悄悄溜进生产环境。自动化测试解决的就是这个问题。通过JUnit之类的测试框架我们可以把测试用例写进代码里每次修改完业务逻辑就运行一遍全部测试几秒钟内就能知道哪些地方被改坏了。Spring Boot对测试的支持做得相当完善它提供了spring-boot-starter-test这个模块里面整合了JUnit、Mockito、AssertJ等测试工具开箱即用。我见过很多团队因为测试写得好重构起来胆子大很多发布上线也安心不少。在选型上JUnit 5是当前的主流选择也是Spring Boot 2.2版本默认集成的测试框架。JUnit 4虽然是老前辈但在很多历史遗留项目里仍然大量存在。理解这两者的区别对我们日常开发维护来说非常关键。1.2 JUnit 4 vs JUnit 5核心差异对比很多刚开始接触JUnit 5的同学会有一个疑问JUnit 5不就是JUnit 4的升级版吗注解换个名字而已实际上远不止这么简单。JUnit 5是一个由三个独立子项目构成的体系JUnit Platform、JUnit Jupiter、JUnit Vintage。其中Platform是运行测试的基础平台Jupiter是JUnit 5的核心编程模型也就是我们写测试类时用的那些注解和断言Vintage则负责运行JUnit 4及更早版本的测试用例。这种架构设计的意图很明显让JUnit 5能够兼容运行老版本的测试代码方便逐步迁移。从代码层面看最直观的变化是注解包名和功能功能JUnit 4JUnit 5测试方法TestTest生命周期初始化BeforeClassBeforeAll测试前执行BeforeBeforeEach测试后执行AfterAfterEach类结束清理AfterClassAfterAll忽略测试IgnoreDisabled断言工具org.junit.Assertorg.junit.jupiter.api.Assertions测试运行器RunWithExtendWith这不仅仅是换了个名字背后的语义也发生了变化。比如JUnit 4中的BeforeClass要求方法必须声明为static而JUnit 5中的BeforeAll默认也要求static但如果测试类的生命周期设置为PER_CLASS就不需要static了。这个设计让JUnit 5在测试实例管理上更加灵活可以支持TestInstance(PER_CLASS)模式让非静态方法也能在类级别执行初始化逻辑。另外一个重要区别是RunWith和ExtendWith。JUnit 4通过RunWith(SpringRunner.class)来集成Spring的测试支持而JUnit 5则改用ExtendWith(SpringExtension.class)。虽然Spring Boot的SpringBootTest注解已经帮我们封装好了这些扩展但了解底层机制对于排查问题非常有帮助。从生态兼容性上JUnit 5通过Vintage引擎支持运行JUnit 4写的测试类。这意味着在一个Spring Boot项目中我们可以同时存在JUnit 4和JUnit 5风格的测试代码老的测试不用重写新的测试用新语法写。但这里也有一些需要注意的地方我后面会详细讲。2. 环境搭建与依赖配置实战2.1 Spring Boot项目引入JUnit 5的正确姿势如果你使用的是Spring Boot 2.2或更高版本那么spring-boot-starter-test默认已经包含了JUnit 5的依赖不需要额外引入。以Maven项目为例只需要加这一条依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency这个starter里面会自动引入以下核心组件JUnit JupiterJUnit 5的核心API和引擎Mockito用于mock对象AssertJ流式断言Hamcrestmatcher断言库Spring Test模块提供SpringBootTest等注解支持如果你用的是Gradle对应配置是dependencies { testImplementation org.springframework.boot:spring-boot-starter-test }gradle会自动帮你处理依赖版本问题。这里有个版本细节值得注意Spring Boot 2.2之前的版本默认集成的是JUnit 4所以如果你的项目用的老版本Spring Boot又想用JUnit 5需要手动排除JUnit 4的依赖并引入JUnit 5。具体做法是dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope exclusions exclusion groupIdorg.junit.vintage/groupId artifactIdjunit-vintage-engine/artifactId /exclusion /exclusions /dependency等等这个排除的是Vintage引擎不是JUnit 4。如果你确实不需要运行JUnit 4风格的测试可以考虑排除Vintage引擎这样能减少依赖冲突也能避免混用时的一些问题。但如果项目里还有老的JUnit 4测试类千万别排掉Vintage否则那些测试类会直接报无法找到测试引擎之类的错误。2.2 兼容JUnit 4的依赖配置方案对于历史遗留项目最稳妥的迁移策略不是一步到位重写所有测试而是让JUnit 4和JUnit 5共存逐步迁移。这时候需要在依赖配置上做文章。首先确保spring-boot-starter-test引入了JUnit 5同时单独引入JUnit 4的依赖。Maven配置如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency dependency groupIdjunit/groupId artifactIdjunit/artifactId version4.13.2/version scopetest/scope /dependency注意Spring Boot的依赖管理BOM里面对junit:junit的版本做了管理所以你其实可以省略version标签让Spring Boot来决定版本。不过如果项目有特殊要求也可以显式指定。这里有一个容易踩坑的点如果项目中同时存在JUnit 4和JUnit 5的依赖而且没有加入JUnit Vintage运行引擎JUnit 4的测试类运行时会抛出异常。所以在兼容模式下junit-vintage-engine不能被排除。这个依赖通常也被包含在spring-boot-starter-test里面但在某些定制化的配置里可能不存在需要手动补充dependency groupIdorg.junit.vintage/groupId artifactIdjunit-vintage-engine/artifactId scopetest/scope /dependencyGradle项目则加上testImplementation junit:junit:4.13.2 testRuntimeOnly org.junit.vintage:junit-vintage-engine配置完之后可以用一个简单的方法验证两种JUnit版本是否都能正常工作分别写一个JUnit 4风格的测试类和一个JUnit 5风格的测试类运行mvn test看是否都能通过。如果都能通过说明兼容环境搭建成功。3. 核心测试编写与注解解析3.1 JUnit 5常用注解与生命周期JUnit 5的注解体系比JUnit 4清晰很多先来看一张常用注解对照表然后我再逐个讲解使用场景。import org.junit.jupiter.api.*; DisplayName(用户服务测试) class UserServiceTest { BeforeAll static void initAll() { // 类加载前执行只执行一次 } BeforeEach void init() { // 每个测试方法执行前运行 } AfterEach void tearDown() { // 每个测试方法执行后运行 } AfterAll static void tearDownAll() { // 类加载完成后执行只执行一次 } Test DisplayName(测试用户查询功能) void testFindUser() { // 测试逻辑 } Test Disabled(功能尚未实现) void testNotImplemented() { // 被禁用的测试 } }DisplayName是JUnit 5新增的注解可以给测试类或测试方法设置一个可读性更高的中文或描述性名称。这个在测试报告里特别好用尤其是当测试方法名是英文的时候加上中文描述让后来维护的人一眼就看明白这测试在验证什么行为。Nested注解是JUnit 5的另一个亮点。它允许我们在测试类内部定义嵌套的测试类用来组织复杂场景的测试SpringBootTest class OrderServiceTest { Nested DisplayName(订单创建场景) class CreateOrderTest { Test void shouldCreateOrderWithValidParams() { // 验证成功场景 } Test void shouldThrowExceptionWhenParamsInvalid() { // 验证失败场景 } } Nested DisplayName(订单查询场景) class QueryOrderTest { // 查询相关的测试 } }嵌套类的好处是能将同一业务模块的成功、失败、边界等不同场景分组展示测试报告也更清晰结构上跟业务逻辑的对应关系更强。对于BeforeAll方法前面提到过它的static限制。实际上JUnit 5默认每个测试方法都会创建一个新的测试类实例相当于PER_METHOD生命周期所以BeforeAll和AfterAll标注的方法必须是static方法。如果不想用static可以用TestInstance(Lifecycle.PER_CLASS)注解让所有测试方法共用一个测试类实例。这个特性在需要共享状态时很有用但要注意线程安全。3.2 Spring Boot测试切片与集成测试SpringBootTest是Spring Boot测试中最核心的注解。加入这个注解后测试类会启动完整的Spring应用上下文把你的Controller、Service、Repository全部加载进来进行真正的集成测试。但完整加载上下文有一个问题速度慢。如果只是测试Controller层的逻辑把整个应用都启动起来确实没必要。Spring Boot提供的测试切片Test Slice注解解决了这个问题。常用的有几个WebMvcTest只加载Web层Controller、ControllerAdvice、Filter等不会加载Service和Repository。需要配合MockBean来mock掉依赖的Service。DataJpaTest只加载JPA相关的组件包括Entity、Repository、DataSource等。默认使用内嵌数据库进行测试。MybatisTest类似DataJpaTest针对MyBatis框架。JsonTest只加载JSON序列化相关的组件。RestClientTest针对RestTemplate或WebClient的测试切片。以WebMvcTest为例写一个Controller层的测试WebMvcTest(UserController.class) class UserControllerTest { Autowired MockMvc mockMvc; MockBean UserService userService; Test DisplayName(GET /api/users/1 返回用户信息) void shouldReturnUserInfo() throws Exception { User user new User(1L, 张三, zhangsanexample.com); when(userService.getUserById(1L)).thenReturn(user); mockMvc.perform(get(/api/users/1)) .andExpect(status().isOk()) .andExpect(jsonPath($.name).value(张三)) .andExpect(jsonPath($.email).value(zhangsanexample.com)); } }这个测试只启动Web层上下文通过MockBean把UserService替换成一个Mock对象专注于验证Controller的URL映射、参数绑定、返回JSON格式是否正确。实测下来这种测试的启动速度比完整SpringBootTest快好几倍很多场景我们根本不需要把整个Spring上下文拉起来。DataJpaTest则是另一个常用切片它会替代你配置的数据源默认使用内嵌数据库比如H2。在测试Repository时非常好用DataJpaTest class UserRepositoryTest { Autowired UserRepository userRepository; Test DisplayName(根据邮箱查找用户) void shouldFindUserByEmail() { User saved userRepository.save(new User(李四, lisiexample.com)); OptionalUser found userRepository.findByEmail(lisiexample.com); assertThat(found).isPresent(); assertThat(found.get().getName()).isEqualTo(李四); } }这里要注意DataJpaTest默认会在每个测试方法结束后回滚事务所以测试数据不会污染真实数据库。但也正因如此如果你需要验证某些不经过事务提交就无法生效的行为就需要用Commit注解或调整事务传播方式。3.3 参数化测试的进阶用法参数化测试是JUnit 5对比JUnit 4一个非常明显的优势。JUnit 4里要实现参数化得借助RunWith(Parameterized.class)加一个很长样板代码的构造方法。JUnit 5直接用ParameterizedTest配合各种ValueSource、MethodSource、CsvSource注解代码简洁许多。来看一个实际例子。假设我们有一个校验手机号的功能需要测试多个输入值ParameterizedTest ValueSource(strings { 13812345678, 15912345678, 18812345678 }) DisplayName(合法手机号应通过校验) void shouldPassValidPhoneNumbers(String phone) { assertTrue(phoneValidator.isValid(phone)); } ParameterizedTest ValueSource(strings { 12345, 1381234567, abcdefghijk, }) DisplayName(非法手机号应被拒绝) void shouldRejectInvalidPhoneNumbers(String phone) { assertFalse(phoneValidator.isValid(phone)); }ValueSource适用于简单的单个参数场景。如果测试方法需要多个参数比如测试登录功能需要传入用户名和密码可以用CsvSourceParameterizedTest CsvSource({ admin, password123, true, admin, wrongpass, false, guest, guest123, true }) void shouldValidateLoginCredentials(String username, String password, boolean expected) { assertEquals(expected, authService.login(username, password)); }对于更复杂的参数比如需要调用某个方法生成测试数据可以用MethodSource。被引用的方法必须返回Stream、Iterable、Iterator或数组ParameterizedTest MethodSource(userDataProvider) void shouldSaveUser(User user, boolean expectedResult) { // 测试逻辑 } static StreamArguments userDataProvider() { return Stream.of( Arguments.of(new User(张三, 25), true), Arguments.of(new User(null, 25), false), Arguments.of(new User(张三, -1), false) ); }参数化测试能大幅度减少测试代码的重复。我刚接触JUnit 5时最大的感受就是原来要写五六个几乎一模一样的测试方法现在一个参数化方法加上几个参数就能搞定维护起来也方便很多。4. 实际操作过程与测试用例实现4.1 编写一个完整的Service层单元测试下面我带大家实际操作一下从零开始写一个Service层的单元测试。这个测试不依赖数据库只使用Mockito来mock掉Repository依赖重点是验证Service层的业务逻辑正确性。假设我们有一个用户注册的Service方法实现逻辑是用户名不能为空、邮箱格式要正确、如果邮箱已经注册过就抛异常、注册成功后发送一封欢迎邮件。Service public class UserService { private final UserRepository userRepository; private final MailService mailService; public UserService(UserRepository userRepository, MailService mailService) { this.userRepository userRepository; this.mailService mailService; } public User register(String username, String email) { if (username null || username.isBlank()) { throw new IllegalArgumentException(用户名不能为空); } if (!isValidEmail(email)) { throw new IllegalArgumentException(邮箱格式不正确); } if (userRepository.existsByEmail(email)) { throw new EmailAlreadyRegisteredException(该邮箱已注册); } User user new User(username, email); User saved userRepository.save(user); mailService.sendWelcomeEmail(saved); return saved; } }对应的测试类ExtendWith(MockitoExtension.class) class UserServiceTest { Mock private UserRepository userRepository; Mock private MailService mailService; InjectMocks private UserService userService; Test DisplayName(注册成功保存用户并发送欢迎邮件) void shouldRegisterUserSuccessfully() { // given - 准备mock行为 when(userRepository.existsByEmail(zhangsanexample.com)).thenReturn(false); when(userRepository.save(any(User.class))).thenAnswer(invocation - invocation.getArgument(0)); // when - 执行被测方法 User result userService.register(张三, zhangsanexample.com); // then - 验证结果 assertNotNull(result); assertEquals(张三, result.getUsername()); verify(userRepository).save(any(User.class)); verify(mailService).sendWelcomeEmail(result); } Test DisplayName(注册失败邮箱已存在时抛出异常) void shouldThrowExceptionWhenEmailExists() { when(userRepository.existsByEmail(usedexample.com)).thenReturn(true); assertThrows(EmailAlreadyRegisteredException.class, () - userService.register(李四, usedexample.com)); verify(userRepository, never()).save(any(User.class)); verify(mailService, never()).sendWelcomeEmail(any(User.class)); } Test DisplayName(注册失败邮箱格式不正确) void shouldThrowExceptionWhenEmailInvalid() { assertThrows(IllegalArgumentException.class, () - userService.register(王五, not-an-email)); verify(userRepository, never()).save(any(User.class)); } }在这个测试中ExtendWith(MockitoExtension.class)是JUnit 5对应的Mockito集成注解取代了JUnit 4中的RunWith(MockitoJUnitRunner.class)。Mock创建mock对象InjectMocks自动把mock对象注入到被测类中。thenAnswer的用法可能有些读者不太熟悉它是在调用save方法时返回传入参数作为结果相当于模拟一个保存成功并返回带ID的实体的行为。如果直接用thenReturn(user)会返回一个固定的对象对于验证返回结果来说没问题但实际中save方法往往会对传入对象做修改比如填充ID所以thenAnswer更加灵活。这个测试类完全是单元测试不加载Spring上下文所以执行速度非常快。整个测试跑下来毫秒级完成非常适合在开发阶段频繁运行。4.2 编写Controller层集成测试接下来看Controller层的测试。前面我提到了可以用WebMvcTest做切片测试这里再多加一些实际操作细节。假设我们有这样一个ControllerRestController RequestMapping(/api/users) public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService userService; } PostMapping public ResponseEntityUserVO createUser(RequestBody Valid CreateUserRequest request) { User user userService.register(request.getUsername(), request.getEmail()); return ResponseEntity.status(HttpStatus.CREATED).body(UserVO.from(user)); } GetMapping(/{id}) public ResponseEntityUserVO getUser(PathVariable Long id) { User user userService.getUserById(id); return ResponseEntity.ok(UserVO.from(user)); } }用WebMvcTest写测试WebMvcTest(UserController.class) class UserControllerTest { Autowired private MockMvc mockMvc; MockBean private UserService userService; Autowired private ObjectMapper objectMapper; Test DisplayName(POST /api/users 创建用户成功返回201) void shouldCreateUser() throws Exception { when(userService.register(张三, zhangsanexample.com)) .thenReturn(new User(1L, 张三, zhangsanexample.com)); CreateUserRequest request new CreateUserRequest(张三, zhangsanexample.com); mockMvc.perform(post(/api/users) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isCreated()) .andExpect(jsonPath($.id).value(1L)) .andExpect(jsonPath($.username).value(张三)) .andExpect(jsonPath($.email).value(zhangsanexample.com)); } Test DisplayName(POST /api/users 参数校验失败返回400) void shouldReturn400WhenParamsInvalid() throws Exception { CreateUserRequest request new CreateUserRequest(, invalid-email); mockMvc.perform(post(/api/users) .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); } Test DisplayName(GET /api/users/1 查询用户成功返回200) void shouldGetUser() throws Exception { when(userService.getUserById(1L)) .thenReturn(new User(1L, 张三, zhangsanexample.com)); mockMvc.perform(get(/api/users/1)) .andExpect(status().isOk()) .andExpect(jsonPath($.username).value(张三)); } }这里ObjectMapper是Spring Boot自动配置好的可以直接注入用来把请求对象序列化为JSON字符串。jsonPath是AssertJ提供的JSON路径断言方法可以非常方便地验证返回JSON中的字段值。使用WebMvcTest时需要注意一点如果Controller中依赖了其他组件而这些组件不在测试切片加载的范围内Spring Boot会尝试加载它们找不到就报错。通常的做法就是用MockBean把不必要的依赖替换掉。但MockBean也是Spring Boot测试中一个需要慎用的注解因为它会往Spring上下文中注入mock对象如果同一个测试类中有多个测试方法用到同一个Bean的不同行为记得在测试方法开头使用reset或者使用BeforeEach重新设置mock行为以免测试方法之间相互干扰。4.3 数据库相关测试的配置与方法Service和Controller的测试搞定之后剩下一块大头就是数据层测试。DataJpaTest是最常使用的切片注解但真正用起来有不少细节需要注意。先看一个基本示例DataJpaTest class UserRepositoryTest { Autowired private UserRepository userRepository; Test DisplayName(根据邮箱查找用户) void shouldFindUserByEmail() { User user new User(张三, zhangsanexample.com); userRepository.save(user); OptionalUser found userRepository.findByEmail(zhangsanexample.com); assertThat(found).isPresent(); assertThat(found.get().getUsername()).isEqualTo(张三); } Test DisplayName(统计用户数量) void shouldCountUsers() { userRepository.save(new User(李四, lisiexample.com)); userRepository.save(new User(王五, wangwuexample.com)); long count userRepository.count(); assertThat(count).isEqualTo(2); } }这个测试默认使用内嵌数据库比如H2测试结束自动回滚事务不会留下脏数据。但是有几个坑需要注意第一个坑是DataJpaTest默认只会扫描Entity和Repository如果你在测试中还需要其他的组件比如某些配置类、监听器测试上下文就不会包含它们导致神秘的空指针或bean找不到错误。解决办法是用Import注解手动引入需要的配置类。第二个坑是生产环境用的MySQL或者其他数据库而测试用的是H2两者的SQL语法有时会有差异。比如MySQL支持但H2不支持的字段类型或者某些特有的SQL函数。这种情况下测试可能通过但部署到生产环境后功能却出问题反过来也可能H2能过但MySQL报错。解决思路有三种一是保持测试和生产数据库一致用Testcontainers启动一个真实的MySQL容器来测试二是规范SQL写法避免使用数据库特有的语法三是针对差异点编写单独的数据库方言兼容测试。Testcontainers是较新的趋势配置上稍复杂一些但能最大程度还原真实环境推荐有条件的团队使用。第三个坑是关联实体和数据初始化的顺序。DataJpaTest默认会扫描classpath下的schema.sql和data.sql如果存在自动执行建表和初始化数据。但SQL文件的命名和位置如果没有放在src/test/resources下Spring Boot是找不到的。另外多个测试方法之间共享的数据初始化状态要特别小心某些测试可能依赖于data.sql插入的数据而另一些测试又可能希望数据库是干净的这种场景建议使用Sql注解来控制每个测试方法的数据准备。Sql注解的用法Test Sql(scripts /test-sql/insert-user.sql, executionPhase Sql.ExecutionPhase.BEFORE_TEST_METHOD) void shouldQueryDataWithSqlScript() { OptionalUser found userRepository.findByEmail(sqlexample.com); assertThat(found).isPresent(); }这种方式比在每个测试方法里手动创建数据更清晰尤其是测试数据量比较大的时候。而且脚本文件可以用版本控制方便团队协作和Review数据准备逻辑集中在一个文件里避免散落在各个测试方法中。5. 常见问题与排查技巧实录5.1 测试类不执行或报错问题我在带团队和帮朋友排查问题的时候遇到最多的几个测试相关的问题整理出来分享给大家。问题一测试类能编译但运行失败报错No tests found with test runner JUnit 5。这个错误通常是因为项目中同时存在多个测试引擎但IDEA或构建工具无法确定该用哪个引擎来执行测试类。解决办法是检查依赖中是否存在冲突比如同时引入了junit:junit和org.junit.jupiter:junit-jupiter-engine。如果确认是Spring Boot项目确保使用了spring-boot-starter-test并且没有错误地排除掉某个引擎依赖。在IDEA中可以尝试清理缓存并重启或者手动设置Run Configuration中的测试运行器。问题二测试方法设置了Test注解但运行时不执行也没有报错。这种情况通常是测试方法被Disabled注解标记了或者方法名不符合测试发现规则。在JUnit 5中Test方法没有特殊命名要求不像JUnit 4中有些版本会要求方法名以test开头所以重点检查是不是有Disabled、EnabledOnOs这类条件注解生效。问题三测试报空指针但代码看起来没问题。这类问题很多出在注入上。如果用的是WebMvcTestController依赖的Service没有加MockBean那么Spring会尝试加载真实的Service一旦Service中还有Repository依赖加载就会失败或注入null。排查时可以看测试日志中有没有UnsatisfiedDependencyException异常。另外类上的注解写错也很常见比如把WebMvcTest写成SpringBootTest导致整个上下文加载慢而且容易出现依赖冲突。5.2 JUnit 4与JUnit 5混合使用的注意事项项目里同时存在JUnit 4和JUnit 5测试类时有一些坑是我踩过之后才总结出来的。混合使用时最让我印象深刻的问题是RunWith注解的冲突。一个测试类如果用了JUnit 4的RunWith(SpringRunner.class)而项目里默认的测试框架是JUnit 5那么这个测试类会用JUnit 4的引擎来执行。这看起来没问题但如果这个类里混用了JUnit 4和JUnit 5的注解比如Test用的是JUnit 5的而运行器是JUnit 4的那么这个Test方法会直接报错或静默跳过。所以我的经验是一个测试类内部不要混用两个版本的注解要么整体是JUnit 4风格要么整体是JUnit 5风格。其次断言库的混用也可能出问题。JUnit 4的Assert.assertEquals和JUnit 5的Assertions.assertEquals在大多数情况下行为一致但异常断言的方式不同。JUnit 4用的是Test(expected XxxException.class)JUnit 5用的是assertThrows方法。如果你在JUnit 5测试里写了Test(expected ...)编译会报错因为这个注解在JUnit 5中根本不存在IDE会画红波浪线。解决办法是统一改成assertThrows。最后是Maven Surefire插件的配置。Surefire插件是Maven执行测试的核心插件默认情况下它通过扫描测试类名来发现测试类。在JUnit 5时代需要确保Surefire插件的版本足够新至少2.22.0并且加入了junit-platform-surefire-provider依赖。很多老项目升级JUnit 5后测试不执行原因就是Surefire插件版本太老根本不认识JUnit 5的引擎。5.3 测试性能优化与技巧测试写多了之后运行时间会变得很长。一个大型Spring Boot项目如果有几百个测试类每次跑完都需要好几分钟这在开发阶段非常影响效率。我在实际项目中总结了一些优化技巧。第一尽量使用测试切片代替完整的SpringBootTest。WebMvcTest、DataJpaTest只加载部分上下文启动时间大幅缩短。我之前接手过一个项目原本所有测试都用SpringBootTest每次全量跑测试要五六分钟后来把纯Controller和纯Repository的测试改成切片测试时间直接缩短到两分钟以内。第二善用Transactional让测试数据自动回滚。在集成测试中每个测试方法执行前事务开启执行后回滚这样测试方法是隔离的数据互不影响也无需手动清理。但要注意加了Transactional的测试会失去对真实事务提交行为的验证能力某些和事务边界相关的测试需要单独处理。第三合理使用BeforeAll和AfterAll做重量级初始化。比如在一个测试类中有一个很耗时的操作比如加载一个大的配置文件、启动一个内嵌MQ如果每个测试方法都重新执行一次整个测试类会非常慢。把这类操作挪到BeforeAll里确保只执行一次。第四使用MockMvc时可以考虑静态导入相关方法import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;代码会更简洁阅读体验也好很多。我见过不少同学写测试时MockMvcRequestBuilders.get()、MockMvcResultMatchers.status()这样全拼代码非常长其实静态导入是行业惯例写法。第五给测试类加上合适的DisplayName或者注释描述清楚这个测试的业务场景。这点和性能无关但对于维护体验来说很重要。新接手项目的同事看到一条挂掉的测试如果测试方法名和DisplayName能直接说明这个测试是验证场景X的定位问题的速度会快非常多。5.4 Spring BootJUnit常见问题速查表定位到排查思路那一步后发现问题还是一个一个翻文档找解决方案太费时间。我把最常用的一些问题和解决办法做成一张速查表方便大家直接对号入座。异常/问题表现根本原因解决方式No tests found for given includesSurefire版本太老或缺少junit-platform-surefire-provider升级Surefire到2.22.0确保有JUnit Platform provider测试过程中报ClassNotFound: junit.framework.AssertJUnit 4和JUnit 5的API调用冲突统一断言库不要混用org.junit.Assert和org.junit.jupiter.api.AssertionsMockBean在JUnit 5里不生效缺少ExtendWith(SpringExtension.class)SpringBootTest已内含该扩展如果手动用ContextConfiguration需额外加扩展集成测试里事务不回滚测试方法上没有加Transactional或数据源配置异常确认测试类或方法上有Transactional注解MockMvc注入为null测试类上没有加WebMvcTest或AutoConfigureMockMvc切片测试加WebMvcTestSpringBootTest场景加AutoConfigureMockMvc内嵌数据库初始化失败schema.sql位置不对或SQL语法与数据库不兼容检查文件路径、SQL兼容性测试执行顺序不确定导致依赖测试方法之间共享了状态使用TestMethodOrder配置执行顺序或确保每个方法数据独立断言失败但返回信息不清晰断言方式不够详细使用assertThat(actual).describedAs(描述).isEqualTo(expected)这张表基本覆盖了我日常被问到的大部分问题。对于排查问题我强烈建议打开测试的完整日志输出很多时候报错深层的cause才是真正的元凶表面提示只是冰山一角。尤其在混合使用JUnit 4和JUnit 5的项目里很多诡异的问题最终都指向依赖冲突或引擎选择错误。6. 测试框架迁移经验总结最后想聊一聊什么时候该用JUnit 5什么时候保留JUnit 4以及我在多个项目中做迁移时的一些心得体会。如果你的项目是全新的Spring Boot项目直接默认使用JUnit 5就行没有必要为了兼容而加上JUnit 4相关的东西。Spring Boot 2.2已经完整支持JUnit 5习惯之后写测试的效率高很多。如果你的项目还是Spring Boot 2.1或更老版本升级Spring Boot版本时建议顺便把测试框架迁移到JUnit 5。迁移流程我的建议是第一步升级Spring Boot版本到2.2同时升级Surefire插件。第二步保留JUnit 4依赖和Vintage引擎让老测试继续运行。第三步逐个把老的JUnit 4测试类迁移到JUnit 5语法重点是把RunWith换成ExtendWith把Before/After换成BeforeEach/AfterEach把BeforeClass/AfterClass换成BeforeAll/AfterAll。最后一步所有测试类迁移完成后移除JUnit 4和Vintage引擎依赖彻底清理干净。迁移过程中我踩过的坑有两个印象比较深刻。第一个是RunWith(SpringRunner.class)这个注解在JUnit 5里面完全不需要了嵌套内容直接删除即可。但很多老代码的SpringBootTest和RunWith(SpringRunner.class)是同时出现的迁移时如果忘记删掉RunWithIDE运行时会提示无法识别运行器。第二个是Mockito的API变化。JUnit 4时代常用when(...).thenReturn(...)JUnit 5配合Mockito的新版本测试时可以继续用这个API但部分老代码使用的MockitoJUnitRunner需要替换为MockitoExtension。如果项目中还用了PowerMock一个扩展Mockito能力的库在JUnit 5下需要额外引入PowerMock的JUnit 5集成模块配置会稍微复杂一些。我在实际项目中的一个习惯是配置完成后立即全部运行一次测试确保迁移后没有任何隐藏性问题。这个动作看起来很简单但真的有很多开发者改完预期跑一遍就提交了根本不知道有些测试方法在迁移过程中从JUnit 4的Test(expected ...)变成了JUnit 5中无法识别的方法静默跳过了。再补充一点关于代码覆盖率的数据。在Spring Boot项目中我用JUnit 5替换JUnit 4后配合Jacoco统计覆盖率时JUnit 5的测试能够被正常识别并统计。但需要注意Jacoco版本和JUnit 5的兼容性太老的Jacoco可能无法正确统计JUnit 5平台上的覆盖率。建议使用0.8.5及以上的版本。最后对于团队协作场景建议把测试框架的规范写进项目的CONTRIBUTING文档里明确新测试统一使用JUnit 5旧测试逐步迁移。这样新成员加入时不会困惑用哪个版本写测试也不会出现一个项目里两套写法互相混用的情况。如果项目中有大量JUnit 4测试类但暂时没有精力全部迁移也完全可以接受JUnit 4和JUnit 5共存的状态。只要在依赖中保留了Vintage引擎两种版本的测试可以同时运行不会互相干扰。我见过不少成熟项目在很长一段时间内都是这种新旧共存的状态并滚动推进迁移进度。只要规则明确、依赖配置正确这个模式完全可行。我的建议是新项目无脑用JUnit 5老项目渐进式迁移不必追求一步到位。测试代码跟业务代码一样需要持续维护把迁移节奏拉长一点反而能保证质量更加稳当。

相关新闻

构建基于 SSE 协议通信的 MCP Server 和 Client:把 endpoint 改到 TaoToken

构建基于 SSE 协议通信的 MCP Server 和 Client:把 endpoint 改到 TaoToken

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 23:56:53 阅读更多 →
Claude Code + PowerShell 命令大全 (2026 最新版):把 settings 改到 TaoToken

Claude Code + PowerShell 命令大全 (2026 最新版):把 settings 改到 TaoToken

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 23:56:53 阅读更多 →
开源项目从零到八万星:Archify架构图工具的技术实现与商业化思考

开源项目从零到八万星:Archify架构图工具的技术实现与商业化思考

1. 一个开源项目从零到八万星的真实路径Archify 这个项目,我从第一行代码写到最后一次合并 PR,前后跨度差不多两年半。八万 Star 这个数字听起来挺唬人,但如果你问我最真实的感受是什么,我会说:开源项目的价值从来不是…

2026/10/12 23:55:52 阅读更多 →

最新新闻

Label Studio:两条 Docker 命令完成第一次数据标注并导出模型训练格式

Label Studio:两条 Docker 命令完成第一次数据标注并导出模型训练格式

Label Studio:两条 Docker 命令完成第一次数据标注并导出模型训练格式 【免费下载链接】label-studio Label Studio is a multi-type data labeling and annotation tool with standardized output format 项目地址: https://gitcode.com/GitHub_Trending/la/labe…

2026/10/13 2:15:26 阅读更多 →
Gemini cli 源码分析之工具篇:ReadFile 工具从入口到落盘的完整链路拆解

Gemini cli 源码分析之工具篇:ReadFile 工具从入口到落盘的完整链路拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 2:15:26 阅读更多 →
5 分钟跑通 ArtCraft:一条脚本启动 AI 图像生成桌面应用

5 分钟跑通 ArtCraft:一条脚本启动 AI 图像生成桌面应用

5 分钟跑通 ArtCraft:一条脚本启动 AI 图像生成桌面应用 【免费下载链接】artcraft ArtCraft is an intentional crafting engine for artists, designers, and filmmakers 项目地址: https://gitcode.com/GitHub_Trending/ar/artcraft ArtCraft 是一个面向艺…

2026/10/13 2:15:26 阅读更多 →
AI Agent 架构设计:上下文窗口压缩(OpenClaw、Claude Code、Hermes Agent 对比)——用 TaoToken 统一 Key 跑通三家实测

AI Agent 架构设计:上下文窗口压缩(OpenClaw、Claude Code、Hermes Agent 对比)——用 TaoToken 统一 Key 跑通三家实测

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 2:15:26 阅读更多 →
Claude Code 最全使用指南:CLAUDE.md、rules、skills、memory 一次讲清,TaoToken 统一 Key 接入

Claude Code 最全使用指南:CLAUDE.md、rules、skills、memory 一次讲清,TaoToken 统一 Key 接入

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/13 2:15:26 阅读更多 →
从 “My excellent classmates“ 看辅导型 GPT 自定义指令的完整设计范式

从 “My excellent classmates“ 看辅导型 GPT 自定义指令的完整设计范式

提示工程AI 安全治理 【免费下载链接】TheBigPromptLibrary A collection of prompts, system prompts and LLM instructions 项目地址: https://gitcode.com/gh_mirrors/th/TheBigPromptLibrary 点击查看 免费下载 导读 本文以 TheBigPromptLibrary 仓库中收录的…

2026/10/13 2:14:26 阅读更多 →

日新闻

物联网系统集成避坑指南:从选型到验收的关键要点

物联网系统集成避坑指南:从选型到验收的关键要点

做了这么多年物联网系统集成的项目,我最大的感受是,大部分坑不是出在技术前沿,而是出在一些看起来很平常的地方。设备选了便宜的、协议没问清楚、现场没摸透、验收标准写得含糊——这些细枝末节,最后都会变成延期、超支和扯皮的大…

2026/10/13 0:00:57 阅读更多 →
ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

ESP32舵机控制全攻略:从PWM原理到高级玩法与常见故障排查

如果你玩ESP32有一阵子了,很难不碰到舵机控制这道坎。做智能小车要转方向,做机械臂要抬关节,做云台要偏航俯仰,背后都是同一个东西在干活:舵机。而我见过太多人卡在“代码能编译、舵机就是不听话”这个阶段&#xff0c…

2026/10/13 0:00:57 阅读更多 →
CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

CAN总线原理与STM32实战:抗干扰、仲裁、错误处理全解析

1. 为什么CAN总线至今仍是工业现场的“硬通货”?——从汽车底盘到智能温室的真实需求你有没有拆过一辆老款燃油车的中控台?或者调试过某高校实验室里那台跑着Modbus协议却频频丢帧的PLC控制柜?又或者,在某个农业物联网项目里&…

2026/10/13 0:00:57 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/12 0:16:30 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/12 0:16:38 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/13 1:58:08 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 21:05:09 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 14:48:49 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/12 18:11:17 阅读更多 →