ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot测试实战:JUnit 5与JUnit 4的差异、迁移与常见坑

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
测试方法@Test@Test
生命周期初始化@BeforeClass@BeforeAll
测试前执行@Before@BeforeEach
测试后执行@After@AfterEach
类结束清理@AfterClass@AfterAll
忽略测试@Ignore@Disabled
断言工具org.junit.Assertorg.junit.jupiter.api.Assertions
测试运行器@RunWith@ExtendWith

这不仅仅是换了个名字,背后的语义也发生了变化。比如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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>

这个starter里面会自动引入以下核心组件:

  • JUnit Jupiter(JUnit 5的核心API和引擎)
  • Mockito(用于mock对象)
  • AssertJ(流式断言)
  • Hamcrest(matcher断言库)
  • 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> <exclusions> <exclusion> <groupId>org.junit.vintage</groupId> <artifactId>junit-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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>junit</groupId> <artifactId>junit</artifactId> <version>4.13.2</version> <scope>test</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> <groupId>org.junit.vintage</groupId> <artifactId>junit-vintage-engine</artifactId> <scope>test</scope> </dependency>

Gradle项目则加上:

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, "张三", "zhangsan@example.com"); when(userService.getUserById(1L)).thenReturn(user); mockMvc.perform(get("/api/users/1")) .andExpect(status().isOk()) .andExpect(jsonPath("$.name").value("张三")) .andExpect(jsonPath("$.email").value("zhangsan@example.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("李四", "lisi@example.com")); Optional<User> found = userRepository.findByEmail("lisi@example.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适用于简单的单个参数场景。如果测试方法需要多个参数,比如测试登录功能需要传入用户名和密码,可以用@CsvSource:

@ParameterizedTest @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 Stream<Arguments> 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("zhangsan@example.com")).thenReturn(false); when(userRepository.save(any(User.class))).thenAnswer(invocation -> invocation.getArgument(0)); // when - 执行被测方法 User result = userService.register("张三", "zhangsan@example.com"); // then - 验证结果 assertNotNull(result); assertEquals("张三", result.getUsername()); verify(userRepository).save(any(User.class)); verify(mailService).sendWelcomeEmail(result); } @Test @DisplayName("注册失败:邮箱已存在时抛出异常") void shouldThrowExceptionWhenEmailExists() { when(userRepository.existsByEmail("used@example.com")).thenReturn(true); assertThrows(EmailAlreadyRegisteredException.class, () -> userService.register("李四", "used@example.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做切片测试,这里再多加一些实际操作细节。

假设我们有这样一个Controller:

@RestController @RequestMapping("/api/users") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @PostMapping public ResponseEntity<UserVO> 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 ResponseEntity<UserVO> 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("张三", "zhangsan@example.com")) .thenReturn(new User(1L, "张三", "zhangsan@example.com")); CreateUserRequest request = new CreateUserRequest("张三", "zhangsan@example.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("zhangsan@example.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, "张三", "zhangsan@example.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("张三", "zhangsan@example.com"); userRepository.save(user); Optional<User> found = userRepository.findByEmail("zhangsan@example.com"); assertThat(found).isPresent(); assertThat(found.get().getUsername()).isEqualTo("张三"); } @Test @DisplayName("统计用户数量") void shouldCountUsers() { userRepository.save(new User("李四", "lisi@example.com")); userRepository.save(new User("王五", "wangwu@example.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() { Optional<User> found = userRepository.findByEmail("sql@example.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这类条件注解生效。

问题三:测试报空指针,但代码看起来没问题。这类问题很多出在注入上。如果用的是@WebMvcTest,Controller依赖的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 Boot+JUnit常见问题速查表

定位到排查思路那一步后,发现问题还是一个一个翻文档找解决方案太费时间。我把最常用的一些问题和解决办法做成一张速查表,方便大家直接对号入座。

异常/问题表现根本原因解决方式
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.Assertions
@MockBean在JUnit 5里不生效缺少@ExtendWith(SpringExtension.class)@SpringBootTest已内含该扩展;如果手动用@ContextConfiguration需额外加扩展
集成测试里事务不回滚测试方法上没有加@Transactional或数据源配置异常确认测试类或方法上有@Transactional注解
MockMvc注入为null测试类上没有加@WebMvcTest或@AutoConfigureMockMvc切片测试加@WebMvcTest;@SpringBootTest场景加@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)是同时出现的,迁移时如果忘记删掉@RunWith,IDE运行时会提示无法识别运行器。

第二个是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,老项目渐进式迁移,不必追求一步到位。测试代码跟业务代码一样需要持续维护,把迁移节奏拉长一点,反而能保证质量更加稳当。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进