SpringBoot单元测试实战:JUnit5+Mockito+MockMvc构建安全代码

📅 2026/7/12 11:29:36
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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 你的其他业务依赖如MyBatis, JDBC等 -- !-- 测试专用依赖scope均为test -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope !-- 排除JUnit 4强制使用JUnit 5 -- exclusions exclusion groupIdjunit/groupId artifactIdjunit/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”这种让人摸不着头脑的错误。scopetest/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 JpaRepositoryUser, 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(newexample.com); User savedUser new User(); savedUser.setId(1L); savedUser.setUsername(newUser); savedUser.setEmail(newexample.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它提供了更流畅的断言APIspring-boot-starter-test已默认引入例如assertThat(actualUser).isNotNull().hasFieldOrPropertyWithValue(id, 1L)可读性更强。行为验证使用verify(mockObject, times(n)).methodName(arguments)来验证模拟对象的某个方法是否被调用了预期的次数。times(1)是默认值可以省略。never()表示从未被调用。这对于验证“当用户名重复时save方法不应该被调用”这样的业务逻辑至关重要。实操心得关于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 ResponseEntityUser getUserById(PathVariable Long id) { User user userService.getUserById(id); return ResponseEntity.ok(user); } PostMapping public ResponseEntityUser 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(newemail.com); User savedUser new User(); savedUser.setId(100L); savedUser.setUsername(newUser); savedUser.setEmail(newemail.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请求和执行断言。ObjectMapperSpring 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的交互时使用。注意如果这里不MockUserServiceSpring会注入真实的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(ClassT)匹配任何或任何特定类型的非空参数。对于基本类型使用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); }合理使用SpySpy是部分模拟。它会创建一个真实对象的包装你可以选择性地模拟它的某些方法而其他方法则保持真实行为。慎用因为它通常意味着你的类职责过多可能需要重构。测试覆盖率是辅助不是目标追求高测试覆盖率是好的但不要本末倒置。覆盖率高不代表测试质量高。要确保测试覆盖了核心业务逻辑、边界条件和异常流程。6. 常见问题排查与调试技巧实录即使按照最佳实践来写测试时还是会遇到各种奇怪的问题。这里记录几个我踩过的坑和解决方法。6.1 “No runnable methods” 或 “No tests found”症状在IDE中运行测试类时提示没有可运行的方法。原因1JUnit版本冲突。项目里同时存在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。原因1WebMvcTest只加载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时没有MockUserRepository而是让Spring注入了一个真实的、连接了数据库的Repository那么这不是单元测试而是集成测试。集成测试速度慢依赖外部环境但能验证数据库交互是否正确。如何选择单元测试使用Mockito模拟Repository只测试Service的业务逻辑。快、稳定、不依赖数据库。集成测试使用DataJpaTestSpring 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()); // 集合断言 ListUser 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来运行。最后记住单元测试的终极目标不是写测试而是通过测试来驱动出更好的软件设计可测试性本身就是良好设计的一个指标并给你未来修改代码时提供信心和安全网。从今天开始为你写的每一段核心业务逻辑配上相应的单元测试吧。