SpringBoot单元测试实战:JUnit5+Mockito+MockMvc构建安全代码
1. 项目概述:为什么单元测试是SpringBoot项目的“安全带”?
在Java后端开发,尤其是SpringBoot项目里,我们常常把业务逻辑写得飞快,Controller、Service、Repository层层调用,一气呵成。功能上线后,一旦某个底层方法因为一个边界条件没处理好而抛异常,或者某次需求变更无意中改动了核心逻辑,引发的可能就是一连串的线上故障。这时候,一套健壮的单元测试,就像是程序员为代码系上的“安全带”——它不能保证你不撞车(出Bug),但能在你“撞车”时,最大程度地保护核心业务逻辑不崩溃,把问题拦截在开发阶段。
我见过太多项目,初期为了赶进度完全不做测试,后期迭代时开发者战战兢兢,改一行代码都怕引发“海啸”。而一个配备了JUnit5、MockMvc和Mockito的SpringBoot测试套件,能让你拥有“安全重构”的底气。JUnit5提供了现代化的测试框架和丰富的断言;MockMvc让你能像真实HTTP请求一样测试Controller层,却无需启动整个Web服务器;Mockito则能帮你轻松模拟(Mock)那些复杂的依赖,比如数据库操作、第三方服务调用,让测试焦点始终集中在当前单元的代码逻辑上。
这篇文章,我就以一个典型的SpringBoot Web项目为例,手把手带你搭建一套从Controller到Service的完整单元测试体系。无论你是刚接触测试的新手,还是想从JUnit4升级到JUnit5的老手,都能找到可直接“抄作业”的配置和代码。我们会从最基础的POM依赖配置讲起,一步步深入到如何编写针对RESTful API的测试、如何模拟外部依赖,并分享我在实际项目中积累的、文档里不会写的那些“踩坑”经验和调试技巧。
2. 环境准备与核心依赖解析
开始写测试之前,一个正确的项目依赖配置是基石。SpringBoot对测试的支持非常友好,但依赖的版本和范围选择不当,可能会让你在后续遇到各种奇怪的兼容性问题。
2.1 Maven依赖配置详解
在你的 pom.xml 文件中,除了基本的 spring-boot-starter-web 依赖,测试相关的依赖通常放在 <dependencies> 里,并且其 <scope> 被设置为 test 。这意味着它们只在编译和运行测试时生效,不会打包进最终的生产环境Jar包。
<dependencies>
<!-- SpringBoot Web 核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 你的其他业务依赖,如MyBatis, JDBC等 -->
<!-- 测试专用依赖,scope均为test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<!-- 排除JUnit 4,强制使用JUnit 5 -->
<exclusions>
<exclusion>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
关键点解析:
-
spring-boot-starter-test:这是SpringBoot测试的“全家桶”。它默认会引入JUnit(老版本可能是JUnit 4)、AssertJ、Hamcrest、Mockito、JSONassert、JsonPath等一堆测试相关的库。在SpringBoot 2.2及以上版本,它默认引入的是JUnit 5。 -
<exclusions>标签 :这是一个非常重要的保险措施。有时,你的项目可能间接依赖了JUnit 4(比如通过其他第三方库)。这个排除项确保在测试范围内彻底移除JUnit 4,避免两个版本的JUnit在类路径上冲突,导致一些注解(如@Test)引用错误,出现“No runnable methods”这种让人摸不着头脑的错误。 -
<scope>test</scope>:务必确保。这保证了测试代码和依赖不会污染你的生产包。
注意 :如果你使用的是SpringBoot 2.1.x或更早的版本,
spring-boot-starter-test可能默认捆绑的是JUnit 4。此时,你需要显式地添加JUnit 5的依赖,并排除JUnit 4。但鉴于现在是2024年,强烈建议使用SpringBoot 2.7+或3.x版本,它们对JUnit 5的支持是原生且完整的。
2.2 理解JUnit 5的架构变化
JUnit 5和JUnit 4有架构上的根本不同,理解这一点能帮你避开很多坑。JUnit 5由三个主要子模块组成:
- JUnit Jupiter :这是编写新测试和扩展的核心编程模型和运行时。我们用的
@Test,@BeforeEach,@DisplayName等注解都来自这个模块(包名是org.junit.jupiter.api)。 - JUnit Vintage :这是一个兼容层,用于在JUnit 5平台上运行基于JUnit 3或JUnit 4编写的旧测试。如果你的项目是老项目迁移,可能需要引入它。
- JUnit Platform :作为在JUnit上启动测试框架的基础,它定义了IDE和构建工具(如Maven、Gradle)发现和执行测试的稳定API。
实操心得 :在IntelliJ IDEA中,请确保你的测试运行配置使用的是“JUnit 5”而不是“JUnit”。你可以在 Run -> Edit Configurations... 里检查。使用错误的运行器会导致JUnit 5的新特性(如 @DisplayName )不生效。
3. 编写你的第一个SpringBoot单元测试:Service层测试
我们从最简单的Service层开始。假设我们有一个用户服务 UserService ,它依赖一个用户数据访问对象 UserRepository (这里用Spring Data JPA的接口为例)。我们的目标是测试 UserService 的业务逻辑,而不需要真实的数据库。
3.1 创建Service与Repository
首先,定义我们的业务组件:
// UserService.java
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
public User getUserById(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new RuntimeException("User not found with id: " + id));
}
public User createUser(User user) {
// 简单的业务逻辑,比如用户名判重
if (userRepository.existsByUsername(user.getUsername())) {
throw new RuntimeException("Username already exists");
}
return userRepository.save(user);
}
}
// UserRepository.java (Spring Data JPA Interface)
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByUsername(String username);
}
// User.java (Entity)
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
// getters and setters
}
3.2 使用Mockito模拟依赖并测试Service
对于 UserService 的单元测试,我们不应该启动整个Spring容器,也不应该连接真实的数据库。我们应该使用Mockito来模拟 UserRepository 的行为。
创建测试类 UserServiceTest.java 在 src/test/java 的对应包下。
// UserServiceTest.java
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class) // 1. 启用Mockito扩展
class UserServiceTest {
@Mock // 2. 创建一个Mock对象
private UserRepository userRepository;
@InjectMocks // 3. 将Mock注入到被测试对象中
private UserService userService;
@Test
@DisplayName("根据存在的ID查询用户应返回用户对象")
void getUserById_WhenUserExists_ShouldReturnUser() {
// 4. 准备测试数据
Long userId = 1L;
User expectedUser = new User();
expectedUser.setId(userId);
expectedUser.setUsername("testUser");
// 5. 定义Mock对象的行为(打桩 - Stubbing)
when(userRepository.findById(userId)).thenReturn(Optional.of(expectedUser));
// 6. 执行被测试方法
User actualUser = userService.getUserById(userId);
// 7. 验证结果和行为
assertNotNull(actualUser);
assertEquals(userId, actualUser.getId());
assertEquals("testUser", actualUser.getUsername());
// 验证userRepository.findById被调用了一次,且参数是userId
verify(userRepository, times(1)).findById(userId);
}
@Test
@DisplayName("根据不存在的ID查询用户应抛出异常")
void getUserById_WhenUserNotExists_ShouldThrowException() {
Long userId = 999L;
when(userRepository.findById(userId)).thenReturn(Optional.empty());
// 使用JUnit 5的assertThrows来断言异常
RuntimeException exception = assertThrows(RuntimeException.class, () -> {
userService.getUserById(userId);
});
assertEquals("User not found with id: " + userId, exception.getMessage());
verify(userRepository, times(1)).findById(userId);
}
@Test
@DisplayName("创建新用户成功应保存并返回用户")
void createUser_WithNewUsername_ShouldSaveAndReturnUser() {
User newUser = new User();
newUser.setUsername("newUser");
newUser.setEmail("new@example.com");
User savedUser = new User();
savedUser.setId(1L);
savedUser.setUsername("newUser");
savedUser.setEmail("new@example.com");
// 模拟existsByUsername返回false,表示用户名不存在
when(userRepository.existsByUsername("newUser")).thenReturn(false);
// 模拟save方法,当传入任何User对象时,返回我们预设的savedUser
when(userRepository.save(any(User.class))).thenReturn(savedUser);
User result = userService.createUser(newUser);
assertNotNull(result.getId());
assertEquals(1L, result.getId());
// 验证调用顺序和次数
verify(userRepository).existsByUsername("newUser");
verify(userRepository).save(newUser); // 这里也可以使用any(User.class)
}
@Test
@DisplayName("创建重复用户名的用户应抛出异常")
void createUser_WithDuplicateUsername_ShouldThrowException() {
User existingUser = new User();
existingUser.setUsername("duplicateUser");
when(userRepository.existsByUsername("duplicateUser")).thenReturn(true);
RuntimeException exception = assertThrows(RuntimeException.class, () -> {
userService.createUser(existingUser);
});
assertEquals("Username already exists", exception.getMessage());
// 验证save方法没有被调用
verify(userRepository, never()).save(any(User.class));
}
}
代码逐行解析与避坑指南:
-
@ExtendWith(MockitoExtension.class):这是JUnit 5的扩展机制。它替代了JUnit 4的@RunWith(MockitoJUnitRunner.class)。这个注解是必须的,它初始化了Mockito的注解处理,让@Mock和@InjectMocks生效。 -
@Mock:创建一个UserRepository的模拟对象。这个对象的所有方法默认都是“空的”或返回默认值(如返回null、0、false或空集合)。你需要通过when(...).thenReturn(...)来定义它的具体行为,这个过程叫“打桩”(Stubbing)。 -
@InjectMocks:创建一个UserService的真实实例,并将上面标记了@Mock的userRepository注入到这个实例中。这样,userService内部使用的就是我们的模拟仓库,而不是需要Spring容器管理的真实Bean。 - 准备测试数据 :在测试方法内部准备输入数据和期望结果。这是一个好习惯,让测试逻辑清晰。
-
when(...).thenReturn(...):这是Mockito打桩的核心语法。它定义了当模拟对象的某个方法以特定参数被调用时,应该返回什么值。any(Class.class)是一个参数匹配器,表示“任何此类型的参数”。 - 执行被测试方法 :调用我们真正要测试的
userService的方法。 - 验证(Verification) :
- 断言(Assertions) :使用JUnit 5的
assertEquals,assertNotNull,assertThrows等来验证方法返回的结果是否符合预期。 我强烈推荐使用AssertJ,它提供了更流畅的断言API (spring-boot-starter-test已默认引入),例如assertThat(actualUser).isNotNull().hasFieldOrPropertyWithValue("id", 1L),可读性更强。 - 行为验证 :使用
verify(mockObject, times(n)).methodName(arguments)来验证模拟对象的某个方法是否被调用了预期的次数。times(1)是默认值,可以省略。never()表示从未被调用。这对于验证“当用户名重复时,save方法不应该被调用”这样的业务逻辑至关重要。
- 断言(Assertions) :使用JUnit 5的
实操心得:关于
any()的使用any()是一个宽松的匹配器。在上面的verify(userRepository).save(newUser)中,我们使用了具体的对象newUser进行验证。但在某些情况下,被测试方法内部可能会修改对象(比如设置ID),导致传入save方法的对象和最初的newUser引用不同,从而验证失败。此时,使用verify(userRepository).save(any(User.class))是更安全的选择,它只验证是否传入了一个User类型的参数。你需要根据测试的侧重点来选择使用具体值还是匹配器。
4. 集成测试利器:使用MockMvc测试Controller层
Service层测试关注业务逻辑,而Controller层测试则关注HTTP接口的契约:请求路径、方法、参数、请求体、响应状态码、响应体格式等。 MockMvc 允许我们模拟HTTP请求,并对响应进行断言,而无需启动一个真实的Web服务器(如Tomcat),这使得测试速度极快。
4.1 构建MockMvc测试环境
假设我们有一个 UserController :
// UserController.java
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
User user = userService.getUserById(id);
return ResponseEntity.ok(user);
}
@PostMapping
public ResponseEntity<User> createUser(@RequestBody @Valid User user) {
User createdUser = userService.createUser(user);
return ResponseEntity.status(HttpStatus.CREATED).body(createdUser);
}
}
为其编写测试 UserControllerTest.java 。这里我们介绍两种常用的搭建 MockMvc 环境的方式。
方式一:使用 @WebMvcTest 切片测试(推荐用于纯Controller测试)
@WebMvcTest 是SpringBoot提供的一个测试切片注解。它只会加载与Web层相关的Bean(如 @Controller , @RestController , @ControllerAdvice , @JsonComponent , WebMvcConfigurer 等),而不会加载完整的应用上下文(如 @Service , @Repository )。这使测试更轻量、更聚焦。
// UserControllerTest.java
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import com.fasterxml.jackson.databind.ObjectMapper;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(UserController.class) // 1. 指定要测试的Controller
class UserControllerTest {
@Autowired
private MockMvc mockMvc; // 2. 自动注入MockMvc实例
@Autowired
private ObjectMapper objectMapper; // 3. JSON序列化/反序列化工具
@MockBean // 4. 模拟Controller所依赖的Service
private UserService userService;
@Test
void getUserById_ShouldReturnUser_WhenUserExists() throws Exception {
Long userId = 1L;
User mockUser = new User();
mockUser.setId(userId);
mockUser.setUsername("mockUser");
when(userService.getUserById(userId)).thenReturn(mockUser);
// 5. 发起GET请求并验证
mockMvc.perform(get("/api/users/{id}", userId) // 构建GET请求
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk()) // 断言HTTP状态码为200
.andExpect(jsonPath("$.id").value(userId)) // 使用JsonPath断言响应体JSON
.andExpect(jsonPath("$.username").value("mockUser"));
}
@Test
void createUser_ShouldReturnCreatedStatusAndUser() throws Exception {
User newUser = new User();
newUser.setUsername("newUser");
newUser.setEmail("new@email.com");
User savedUser = new User();
savedUser.setId(100L);
savedUser.setUsername("newUser");
savedUser.setEmail("new@email.com");
when(userService.createUser(any(User.class))).thenReturn(savedUser);
// 6. 发起POST请求,并传递JSON请求体
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON) // 设置请求头Content-Type
.content(objectMapper.writeValueAsString(newUser)) // 将对象转为JSON字符串
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isCreated()) // 断言HTTP状态码为201
.andExpect(jsonPath("$.id").value(100L))
.andExpect(jsonPath("$.username").value("newUser"));
}
@Test
void getUserById_ShouldReturn404_WhenUserNotExists() throws Exception {
Long nonExistId = 999L;
when(userService.getUserById(nonExistId))
.thenThrow(new RuntimeException("User not found"));
// 7. 断言Controller抛出的异常被正确处理(通常由@ControllerAdvice处理,这里返回500或404)
// 假设Controller没有全局异常处理,会返回500。这里我们断言500状态码。
mockMvc.perform(get("/api/users/{id}", nonExistId))
.andExpect(status().isInternalServerError());
// 更佳实践是使用全局异常处理,将特定业务异常转为规范的错误响应体,然后在这里断言状态码和错误信息JSON。
}
}
关键点解析:
-
@WebMvcTest(UserController.class):这个注解是关键。它告诉Spring Boot Test只加载UserController及其相关的Web配置,大大加快了测试启动速度。 -
MockMvc:核心测试工具,用于模拟HTTP请求和执行断言。 -
ObjectMapper:Spring Boot会自动配置一个,用于将Java对象和JSON字符串互相转换。 -
@MockBean:这是Spring Boot Test提供的注解,用于在Spring的应用上下文(这里是Web切片上下文)中添加一个Mockito模拟的Bean。它会替换掉上下文中任何同类型的真实Bean。这里我们用模拟的UserService替换了真实的Service,从而隔离了Controller层。 -
mockMvc.perform():构建一个请求。get(),post(),put(),delete()是静态导入的便捷方法。accept()设置请求头Accept。 -
.andExpect():对响应进行断言。status().isOk()断言状态码200。jsonPath()是一个强大的工具,它使用类似XPath的语法来定位和验证JSON响应体中的值。$表示根节点,$.id表示根节点下的id字段。 - 异常处理测试 :测试Controller在依赖服务抛出异常时的行为,是确保API健壮性的重要环节。你需要根据项目实际的异常处理机制(如使用
@ControllerAdvice和@ExceptionHandler)来断言相应的HTTP状态码和错误响应体。
方式二:在集成测试中使用 @SpringBootTest 与 @AutoConfigureMockMvc
如果你的测试需要更完整的Spring上下文(例如,要测试Security配置、过滤器链、或包含多个组件的集成场景),可以使用 @SpringBootTest 。它会加载完整的应用程序上下文。
// UserControllerIntegrationTest.java
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@SpringBootTest // 加载完整Spring上下文
@AutoConfigureMockMvc // 自动配置MockMvc
class UserControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
// ... 测试方法与 @WebMvcTest 版本类似
}
两种方式如何选择?
-
@WebMvcTest: 速度更快,关注点更集中 。当你只想测试Controller本身的逻辑、请求映射、参数绑定、响应处理时,这是首选。它不会加载数据库、Service等无关的Bean。 -
@SpringBootTest: 更重,但更完整 。当你需要测试整个Web层在真实Spring环境下的集成行为,比如与Security、AOP、自定义Filter的交互时使用。 注意 :如果这里不MockUserService,Spring会注入真实的Service,进而可能触发真实的数据库操作,这就变成了集成测试而非单元测试。通常我们会用@MockBean来隔离。
实操心得:处理JSON序列化问题 在测试中,经常需要将对象转为JSON字符串作为请求体。如果对象中有复杂的关联(如双向关联的JPA实体),直接使用
objectMapper.writeValueAsString()可能会导致循环引用而栈溢出。有几种解决方案:
- 在测试专用的DTO(数据传输对象)中定义需要序列化的字段,而不是直接使用Entity。
- 使用
@JsonIgnore注解在Entity的字段上忽略不必要的关联。- 配置ObjectMapper忽略循环引用:
objectMapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false);但这可能掩盖问题。 最佳实践是,Controller的入参和出参都应该使用独立的DTO,而不是直接暴露Entity ,这本身也是API设计的好习惯。
5. 高级Mockito技巧与测试最佳实践
掌握了基础用法后,我们来看看Mockito的一些高级特性,以及如何让测试代码更健壮、更可维护。
5.1 参数匹配器(Argument Matchers)的灵活运用
我们之前已经用了 any() 。Mockito提供了丰富的参数匹配器:
any(),any(Class<T>):匹配任何(或任何特定类型的)非空参数。对于基本类型,使用anyInt(),anyString(),anyLong()等。eq(value):匹配一个具体的值。当你想混合使用匹配器和具体值时,必须用eq()包裹具体值。isNull(),notNull():匹配空或非空参数。argThat(ArgumentMatcher):自定义匹配逻辑,功能最强大。
@Test
void testWithArgumentMatchers() {
// 模拟一个方法,当第一个参数是任意字符串,第二个参数大于10时,返回特定值
when(someService.complexMethod(anyString(), argThat(arg -> arg > 10)))
.thenReturn("success");
// 模拟验证,方法被调用时,第二个参数是“特定值”
verify(someService).anotherMethod(eq("fixedValue"), anyList());
}
注意事项 :一旦在打桩或验证中使用了参数匹配器,那么 该次调用的所有参数都必须使用匹配器 。你不能混用具体值和匹配器(除了 eq() )。
5.2 验证交互行为(Verification)
除了验证方法被调用的次数,还可以验证调用顺序、超时等。
verify(mock, times(n)).method():验证调用n次。verify(mock, atLeast(n)).method():至少n次。verify(mock, atMost(n)).method():至多n次。verify(mock, never()).method():从未调用。verify(mock, timeout(100)).method():验证在100毫秒内被调用(用于异步方法)。InOrder验证调用顺序:InOrder inOrder = inOrder(mockA, mockB); inOrder.verify(mockA).method1(); inOrder.verify(mockB).method2(); // 确保method1在method2之前被调用
5.3 测试 void 方法或抛出异常的方法
对于返回 void 的方法,我们通常关心它的副作用(比如是否调用了其他方法)或是否抛出了异常。
@Test
void testVoidMethod() {
// 测试一个无返回值的方法被调用
doNothing().when(someService).voidMethod(anyString()); // 默认就是doNothing,可省略
// 或者,模拟它抛出异常
doThrow(new RuntimeException("DB error")).when(someService).voidMethod("badInput");
assertThrows(RuntimeException.class, () -> someService.voidMethod("badInput"));
verify(someService).voidMethod("badInput");
}
使用 doThrow().when().method() 的语法来模拟 void 方法抛出异常。
5.4 测试最佳实践与心得
- 测试命名 :使用
方法名_测试场景_期望结果的格式(如getUserById_WhenUserNotExists_ShouldThrowException),这能让测试意图一目了然。JUnit 5的@DisplayName注解可以让你用更自然的语言描述测试。 - 单一职责 :一个测试方法只测试一个场景或一个分支逻辑。不要在一个测试方法里验证太多东西。
- Given-When-Then模式 :在测试方法内部用注释清晰地分隔“准备数据(Given)”、“执行操作(When)”、“验证结果(Then)”三个阶段。这能让测试结构非常清晰。
- 不要过度Mock :只Mock那些与外部系统交互的、不稳定的、或速度慢的依赖(如数据库、网络服务、文件系统)。对于简单的工具类或值对象,可以考虑使用真实对象。过度Mock会让测试变得复杂且脆弱。
- 关注行为,而非实现 :单元测试应该验证代码的“行为”(输出、状态变化、对外调用)是否符合预期,而不是验证其内部实现细节(比如某个私有方法是否被调用)。过度验证内部实现会导致测试与代码耦合过紧,一旦重构代码,大量测试就会失败。
- 使用
@BeforeEach进行公共设置 :如果多个测试方法需要相同的初始化步骤(比如创建一些公共的测试数据),可以将它们放在用@BeforeEach注解的方法中。@BeforeEach void setUp() { // 在每个测试方法执行前,重置Mock的状态(如果需要) Mockito.reset(userRepository); // 谨慎使用,通常不需要 // 或者初始化一些公共的测试数据 commonTestUser = new User(); commonTestUser.setId(1L); } - 合理使用
@Spy:@Spy是部分模拟。它会创建一个真实对象的包装,你可以选择性地模拟它的某些方法,而其他方法则保持真实行为。 慎用 ,因为它通常意味着你的类职责过多,可能需要重构。 - 测试覆盖率是辅助,不是目标 :追求高测试覆盖率是好的,但不要本末倒置。覆盖率高不代表测试质量高。要确保测试覆盖了核心业务逻辑、边界条件和异常流程。
6. 常见问题排查与调试技巧实录
即使按照最佳实践来,写测试时还是会遇到各种奇怪的问题。这里记录几个我踩过的坑和解决方法。
6.1 “No runnable methods” 或 “No tests found”
- 症状 :在IDE中运行测试类时,提示没有可运行的方法。
- 原因1 :JUnit版本冲突。项目里同时存在JUnit 4和JUnit 5的依赖,导致
@Test注解引用了错误的包(org.junit.Testvsorg.junit.jupiter.api.Test)。 - 解决 :确保
pom.xml中排除了JUnit 4(如前文所述),并检查所有测试类导入的是org.junit.jupiter.api.Test。 - 原因2 :测试类或方法不是
public。在JUnit 5中,测试类和方法可以是包私有的(即不加public),但如果你或你的IDE配置要求public,也可能导致此问题。确保类和方法有正确的访问修饰符(通常用默认的包私有或public都可以)。 - 原因3 :在IntelliJ IDEA中,运行配置错误。确保运行配置使用的是“JUnit 5”而不是“JUnit”。
6.2 Mockito “Wanted but not invoked” 或 “Unnecessary stubbing”
- 症状 :测试失败,提示期望的模拟方法调用没有发生,或者提示存在不必要的打桩。
- 原因与解决 :
- 参数不匹配 :
verify时使用的参数与实际调用的参数不一致。仔细检查参数匹配器是否使用正确,或者实际调用时传入的值是否与预期相同。使用调试模式或在verify前打印日志来确认实际调用的参数。 - 方法没有被执行到 :可能因为前置条件不满足,代码走了另一个分支。检查测试数据是否触发了正确的分支逻辑。
- 不必要的打桩 :Mockito默认是严格的,如果你为一个方法打了桩(
when(...).thenReturn(...)),但测试过程中这个方法从未被调用,Mockito会抛出UnnecessaryStubbingException。这通常意味着你的测试数据或场景设计有误,或者这个打桩确实是多余的,可以移除。你也可以用@MockitoSettings(strictness = Strictness.LENIENT)来放宽限制,但不推荐,这可能会掩盖问题。
- 参数不匹配 :
6.3 Spring上下文加载失败或Bean注入问题
- 症状 :使用
@SpringBootTest或@WebMvcTest时,测试启动失败,报BeanCreationException或NoSuchBeanDefinitionException。 - 原因1 :
@WebMvcTest只加载Web层相关的Bean。如果你的Controller依赖了一个没有在Web层扫描路径下的Bean(比如一个在别的配置类里定义的@Component),并且你没有用@MockBean模拟它,就会找不到。 - 解决 :对于所有非Web层的依赖,使用
@MockBean进行模拟。 - 原因2 :配置文件问题。测试时Spring会尝试加载
application.properties或application.yml。如果里面配置了测试环境没有的资源(如特定的数据库URL),会导致启动失败。 - 解决 :在
src/test/resources目录下创建专门的application-test.properties配置文件,覆盖生产环境的配置。例如,使用内存数据库H2来代替MySQL。在测试类上使用@ActiveProfiles("test")激活测试配置。
6.4 数据库相关测试:是单元测试还是集成测试?
这是一个常见的困惑点。如果你在测试 UserService 时,没有Mock UserRepository ,而是让Spring注入了一个真实的、连接了数据库的Repository,那么这 不是单元测试,而是集成测试 。集成测试速度慢,依赖外部环境,但能验证数据库交互是否正确。
如何选择?
- 单元测试 :使用Mockito模拟Repository,只测试Service的业务逻辑。 快、稳定、不依赖数据库 。
- 集成测试 :使用
@DataJpaTest(Spring Boot提供的另一个测试切片,用于JPA测试)来测试Repository层与真实数据库(通常是内存数据库H2)的交互。或者使用@SpringBootTest进行端到端的集成测试。
对于Repository层的测试,建议这样做:
// UserRepositoryTest.java - 这是一个集成测试
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest // 只加载JPA相关的配置和Bean,使用嵌入式数据库(默认H2)
class UserRepositoryTest {
@Autowired
private TestEntityManager entityManager; // 用于便捷地操作测试数据
@Autowired
private UserRepository userRepository;
@Test
void whenFindByUsername_thenReturnUser() {
// given
User user = new User();
user.setUsername("john");
entityManager.persist(user);
entityManager.flush();
// when
User found = userRepository.findByUsername("john").orElse(null);
// then
assertThat(found).isNotNull();
assertThat(found.getUsername()).isEqualTo("john");
}
}
6.5 使用AssertJ让断言更优雅
JUnit 5的断言够用,但AssertJ提供了流式API,断言失败时的错误信息也更清晰。
import static org.assertj.core.api.Assertions.*;
@Test
void assertJExample() {
User user = userService.getUserById(1L);
// 链式调用,可读性极强
assertThat(user)
.isNotNull()
.hasFieldOrPropertyWithValue("id", 1L)
.hasFieldOrProperty("username")
.satisfies(u -> assertThat(u.getEmail()).contains("@"));
// 集合断言
List<User> users = userService.getAllUsers();
assertThat(users)
.isNotEmpty()
.hasSize(3)
.extracting(User::getUsername) // 提取属性
.contains("alice", "bob");
}
7. 构建可维护的测试代码结构
当项目变大,测试类越来越多时,良好的组织结构能极大提升效率。
- 镜像生产代码结构 :在
src/test/java下建立与src/main/java相同的包结构。这样找测试文件会很方便。 - 使用测试基类(谨慎) :如果多个测试类有大量相同的配置(如
@ExtendWith、公共的Mock对象、@BeforeEach方法),可以考虑创建一个抽象的测试基类。但要避免基类变得过于庞大和复杂。 - 提取测试数据工厂 :如果创建复杂的测试对象很繁琐,可以考虑使用工厂模式,如静态工厂方法、Builder模式或像ObjectMother这样的模式来生成测试数据。
public class TestUserFactory { public static User createValidUser(Long id, String username) { User user = new User(); user.setId(id); user.setUsername(username); user.setEmail(username + "@example.com"); return user; } } // 在测试中使用 User testUser = TestUserFactory.createValidUser(1L, "test"); - 为集成测试和单元测试分类 :可以使用Maven的surefire插件配置,通过命名约定来区分运行。例如,单元测试以
*Test.java结尾,集成测试以*IT.java(Integration Test) 结尾,然后在Maven配置中分别用mvn test和mvn verify来运行。
最后,记住单元测试的终极目标不是写测试,而是通过测试来驱动出更好的软件设计(可测试性本身就是良好设计的一个指标),并给你未来修改代码时提供信心和安全网。从今天开始,为你写的每一段核心业务逻辑,配上相应的单元测试吧。
更多推荐


所有评论(0)