
1. 环境准备与项目结构在开始编写控制器单元测试之前我们需要确保开发环境配置正确。这里我使用的是IntelliJ IDEA 2023.3.6版本搭配Spring Boot 3.x和JUnit 5。如果你还在用旧版本建议升级到最新版因为新版本对JUnit 5的支持更加完善。首先创建一个标准的Spring Boot项目结构重点注意测试目录的布局。Maven项目的标准结构应该是这样的src/ ├── main/ │ ├── java/ │ │ └── com/ │ │ └── example/ │ │ └── demo/ │ │ ├── controller/ │ │ │ └── ItemController.java │ │ ├── business/ │ │ │ └── ItemBusinessService.java │ │ └── model/ │ │ └── Item.java │ └── resources/ └── test/ └── java/ └── com/ └── example/ └── demo/ └── controller/ └── ItemControllerTest.java在pom.xml中确保已经添加了必要的测试依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency dependency groupIdorg.junit.jupiter/groupId artifactIdjunit-jupiter-api/artifactId version5.9.2/version scopetest/scope /dependencySpring Boot Starter Test已经包含了JUnit 5的核心依赖但如果你需要更精细的控制可以单独指定JUnit版本。我建议使用5.9.x系列这是目前最稳定的版本。2. 理解控制器测试的关键点在测试REST控制器时我们需要关注几个核心方面HTTP响应状态码确保不同情况下返回正确的状态码200、404等响应内容验证返回的JSON数据结构是否符合预期业务逻辑确认控制器是否正确调用了业务层方法异常处理测试边界条件和异常场景的处理以ItemController为例它有三个端点/dummy-item返回一个硬编码的Item对象/item-from-business-service通过业务服务获取Item/all-items-from-database获取所有Item列表测试时我们需要模拟(Mock)ItemBusinessService因为单元测试应该隔离外部依赖。这就是Mockito发挥作用的地方。3. 创建测试类与Mock对象在IDEA中创建测试类非常简单在ItemController类上按CtrlShiftTMac上是CmdShiftT或者右键点击类名选择Go to → Test。生成的测试类框架如下package com.example.demo.controller; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; class ItemControllerTest { Mock private ItemBusinessService businessService; InjectMocks private ItemController itemController; BeforeEach void setUp() { MockitoAnnotations.openMocks(this); } }这里有几个关键注解Mock创建一个ItemBusinessService的模拟对象InjectMocks创建ItemController实例并将模拟对象注入其中BeforeEach在每个测试方法执行前初始化模拟对象我建议在setUp方法中完成所有Mock对象的初始化和基本行为定义。这样可以避免测试方法中的重复代码。4. 测试硬编码端点我们先从最简单的/dummy-item端点开始测试。这个端点不依赖业务服务直接返回一个硬编码的Item对象。测试代码如下Test void dummyItem_ShouldReturnHardcodedItem() { // When Item result itemController.dummyItem(); // Then assertEquals(1, result.getId()); assertEquals(Ball, result.getName()); assertEquals(10, result.getPrice()); assertEquals(100, result.getQuantity()); // 使用assertAll可以一次验证多个断言即使某个失败也会继续执行其他断言 assertAll(验证所有Item属性, () - assertEquals(1, result.getId()), () - assertEquals(Ball, result.getName()), () - assertEquals(10, result.getPrice()), () - assertEquals(100, result.getQuantity()) ); }这个测试验证了控制器返回的Item对象是否包含预期的属性值。我使用了两种断言方式传统的assertEquals逐个验证assertAll组合验证这在验证多个属性时特别有用5. 测试依赖业务服务的端点/item-from-business-service端点依赖ItemBusinessService我们需要模拟这个服务的行为。这是Mockito的典型应用场景。Test void itemFromBusinessService_ShouldReturnItemFromService() { // Given Item expectedItem new Item(2, Item from Service, 20, 200); when(businessService.retreiveHardcodedItem()).thenReturn(expectedItem); // When Item result itemController.itemFromBusinessService(); // Then assertEquals(expectedItem, result); verify(businessService, times(1)).retreiveHardcodedItem(); }这个测试有三个关键部分Given设置测试前提条件使用Mockito的when().thenReturn()定义模拟行为When执行被测试的方法Then验证结果和交互行为特别注意最后的verify调用它确认业务服务的retreiveHardcodedItem方法被调用了一次。这是单元测试中常被忽视但非常重要的部分 - 不仅要验证结果还要验证正确的协作。6. 测试集合返回端点对于返回集合的端点/all-items-from-database我们需要测试集合内容和大小Test void retrieveAllItems_ShouldReturnAllItemsFromService() { // Given ListItem expectedItems Arrays.asList( new Item(1, Item 1, 10, 100), new Item(2, Item 2, 20, 200) ); when(businessService.retrieveAllItems()).thenReturn(expectedItems); // When ListItem result itemController.retrieveAllItems(); // Then assertEquals(2, result.size()); assertIterableEquals(expectedItems, result); verify(businessService, times(1)).retrieveAllItems(); }这里使用了assertIterableEquals来比较两个集合的内容是否完全一致。如果只需要验证集合大小使用assertEquals就够了。7. 异常和边界条件测试好的单元测试不仅要覆盖正常流程还要测试异常和边界条件。我们为ItemController添加一些边界测试Test void retrieveAllItems_ShouldReturnEmptyListWhenNoItems() { // Given when(businessService.retrieveAllItems()).thenReturn(Collections.emptyList()); // When ListItem result itemController.retrieveAllItems(); // Then assertTrue(result.isEmpty()); } Test void itemFromBusinessService_ShouldThrowExceptionWhenServiceFails() { // Given when(businessService.retreiveHardcodedItem()) .thenThrow(new RuntimeException(Service unavailable)); // When Then assertThrows(RuntimeException.class, () - { itemController.itemFromBusinessService(); }); }第一个测试验证当业务服务返回空列表时控制器的行为。第二个测试验证当业务服务抛出异常时控制器是否正确地传播了异常。8. 使用Mockito的高级特性Mockito提供了一些高级功能可以让测试更简洁参数匹配器当你不关心具体参数值时可以使用any()when(businessService.getItemById(anyInt())).thenReturn(new Item(...));验证调用顺序确保方法以特定顺序被调用InOrder inOrder inOrder(businessService); inOrder.verify(businessService).firstMethod(); inOrder.verify(businessService).secondMethod();捕获参数验证传递给模拟对象的参数ArgumentCaptorItem itemCaptor ArgumentCaptor.forClass(Item.class); verify(businessService).saveItem(itemCaptor.capture()); assertEquals(Expected Name, itemCaptor.getValue().getName());9. 测试HTTP响应细节前面的测试主要关注方法返回值。如果要测试完整的HTTP响应包括状态码、头信息等可以使用Spring的MockMvcWebMvcTest(ItemController.class) class ItemControllerWebTest { Autowired private MockMvc mockMvc; MockBean private ItemBusinessService businessService; Test void dummyItem_ShouldReturnOkStatus() throws Exception { mockMvc.perform(get(/dummy-item)) .andExpect(status().isOk()) .andExpect(jsonPath($.name).value(Ball)) .andExpect(jsonPath($.price).value(10)); } }这种测试方式更接近真实HTTP请求适合测试控制器的Web层行为。WebMvcTest会启动一个轻量级的Spring上下文只加载Web相关的组件。10. 测试代码组织和最佳实践在大型项目中测试代码的组织同样重要。我推荐以下实践命名规范测试方法名应该清晰地表达测试意图如methodName_Scenario_ExpectedResult测试分类使用Tag注解标记不同类型的测试如unit、integration测试生命周期合理使用BeforeEach和AfterEach进行初始化和清理避免过度Mock只Mock必要的依赖过度Mock会使测试变得脆弱测试独立性每个测试应该独立运行不依赖其他测试的状态一个良好组织的测试类示例DisplayName(ItemController单元测试) class ItemControllerTest { // 测试夹具(Test Fixture) Mock private ItemBusinessService businessService; InjectMocks private ItemController itemController; BeforeEach void init() { MockitoAnnotations.openMocks(this); } Nested DisplayName(dummyItem端点测试) class DummyItemTests { Test DisplayName(应该返回硬编码的Item) void dummyItem_ShouldReturnHardcodedItem() { // 测试代码... } } Nested DisplayName(itemFromBusinessService端点测试) class BusinessItemTests { Test DisplayName(当服务返回有效Item时应该成功) void itemFromBusinessService_ShouldReturnItemWhenServiceAvailable() { // 测试代码... } Test DisplayName(当服务不可用时应该抛出异常) void itemFromBusinessService_ShouldThrowWhenServiceFails() { // 测试代码... } } }使用Nested可以将相关测试分组使测试报告更加清晰。DisplayName可以为测试类和测试方法提供更友好的名称。11. 测试覆盖率与持续集成编写测试后我们需要确保测试覆盖了足够多的代码。IntelliJ IDEA内置了覆盖率工具右键点击测试类或整个测试目录选择Run Tests with Coverage查看覆盖率报告重点关注未覆盖的代码分支在持续集成环境中可以使用JaCoCo等工具生成覆盖率报告。我建议设置最低覆盖率要求如80%低于这个阈值时构建失败。12. 常见问题与解决在实际项目中你可能会遇到以下问题问题1Mockito无法注入Spring管理的Bean解决使用MockBean替代Mock或者手动在BeforeEach中注入问题2测试随机失败解决检查测试是否依赖外部状态或执行顺序确保测试完全独立问题3测试运行缓慢解决避免不必要的Spring上下文加载对于纯单元测试使用Mockito而非Spring测试问题4验证复杂对象解决使用AssertJ等库提供更丰富的断言或者实现自定义的equals方法13. 测试驱动开发(TDD)实践虽然本文主要讲的是为已有代码编写测试但测试驱动开发(TDD)是更好的实践方式。基本流程是编写一个失败的测试实现最简单的代码使测试通过重构代码同时保持测试通过对于ItemControllerTDD流程可能是Test void createItem_ShouldReturnCreatedStatus() throws Exception { // Given Item newItem new Item(null, New Item, 30, 300); Item savedItem new Item(3, New Item, 30, 300); when(businessService.createItem(any())).thenReturn(savedItem); // When Then mockMvc.perform(post(/items) .contentType(MediaType.APPLICATION_JSON) .content(asJsonString(newItem))) .andExpect(status().isCreated()) .andExpect(header().exists(Location)) .andExpect(jsonPath($.id).value(3)); } // 辅助方法对象转JSON private static String asJsonString(final Object obj) { try { return new ObjectMapper().writeValueAsString(obj); } catch (Exception e) { throw new RuntimeException(e); } }这个测试会驱动我们实现创建Item的端点。注意我们测试了HTTP状态码、响应头和响应体。14. 测试代码的可维护性随着项目发展测试代码也需要维护。保持测试代码质量的建议避免重复代码提取公共的测试工具方法使用构建器模式创建复杂的测试对象保持测试简洁每个测试应该只验证一件事定期重构测试就像重构生产代码一样删除过时测试删除不再相关的测试例如我们可以创建一个TestDataFactory类class TestDataFactory { static Item createTestItem(int id) { return new Item(id, Item id, id * 10, id * 100); } static ListItem createTestItems(int count) { return IntStream.rangeClosed(1, count) .mapToObj(TestDataFactory::createTestItem) .collect(Collectors.toList()); } }这样测试代码可以简化为Test void retrieveAllItems_ShouldReturnAllItems() { // Given ListItem expectedItems TestDataFactory.createTestItems(2); when(businessService.retrieveAllItems()).thenReturn(expectedItems); // When Then assertIterableEquals(expectedItems, itemController.retrieveAllItems()); }15. 集成测试与单元测试的平衡虽然本文聚焦单元测试但实际项目中需要平衡单元测试和集成测试单元测试快速、隔离测试单个组件集成测试较慢测试组件间的交互端到端测试最慢测试完整系统一个好的测试金字塔应该是大量单元测试底层适量集成测试中层少量端到端测试顶层对于控制器可以这样分层单元测试使用Mockito测试控制器逻辑集成测试使用WebMvcTest测试HTTP层端到端测试使用TestRestTemplate测试完整应用16. 性能考量测试性能也很重要特别是当测试套件变得庞大时避免不必要的Spring上下文加载SpringBootTest很重只在必要时使用复用测试夹具对于昂贵的资源可以使用BeforeAll初始化并行测试JUnit 5支持并行测试执行Mock耗时操作如数据库访问、网络调用在JUnit 5中启用并行测试# src/test/resources/junit-platform.properties junit.jupiter.execution.parallel.enabledtrue junit.jupiter.execution.parallel.mode.defaultconcurrent17. 测试报告与可视化好的测试报告可以帮助团队快速发现问题。IntelliJ IDEA提供了直观的测试结果视图绿色/红色条显示测试通过/失败点击失败测试查看堆栈跟踪支持过滤和分组测试结果对于CI/CD环境可以使用Surefire或JUnit XML报告然后通过工具如Jenkins、TeamCity可视化。18. 结语在真实的Spring Boot项目中良好的单元测试实践是保证代码质量的关键。通过JUnit 5和Mockito的组合我们可以为控制器编写全面而高效的测试。记住几个要点测试应该简单、快速、独立覆盖正常流程和异常情况验证行为而不仅是状态保持测试代码的整洁和可维护性将测试纳入持续集成流程我在实际项目中发现良好的测试覆盖率不仅能减少bug还能作为代码的活文档帮助新成员快速理解系统行为。当需要修改代码时完善的测试套件给了我们重构的信心。