SpringBoot中4种WebMVC测试实现方案

27 阅读8分钟

在项目开发中,测试是确保应用质量的关键环节。对于基于SpringBoot构建的Web应用,高效测试MVC层可以极大提高开发及联调效率。一个设计良好的测试策略不仅能发现潜在问题,还能提高代码质量、促进系统稳定性,并为后续的重构和功能扩展提供保障。

方案一:使用MockMvc进行控制器单元测试

工作原理

MockMvc是Spring Test框架提供的一个核心类,它允许开发者在不启动HTTP服务器的情况下模拟HTTP请求和响应,直接测试控制器方法。这种方法速度快、隔离性好,特别适合纯粹的单元测试。

实现步骤

引入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

编写待测试控制器

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    private final UserService userService;
    
    public UserController(UserService userService) {
        this.userService = userService;
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<UserDto> getUserById(@PathVariable Long id) {
        UserDto user = userService.findById(id);
        return ResponseEntity.ok(user);
    }
    
    @PostMapping
    public ResponseEntity<UserDto> createUser(@RequestBody @Valid UserCreateRequest request) {
        UserDto createdUser = userService.createUser(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(createdUser);
    }
}

编写MockMvc单元测试

import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@ExtendWith(MockitoExtension.class)
public class UserControllerUnitTest {
    
    @Mock
    private UserService userService;
    
    @InjectMocks
    private UserController userController;
    
    private MockMvc mockMvc;
    
    private ObjectMapper objectMapper;
    
    @BeforeEach
    void setUp() {
        // 设置MockMvc实例
        mockMvc = MockMvcBuilders
                .standaloneSetup(userController)
                .setControllerAdvice(new GlobalExceptionHandler()) // 添加全局异常处理
                .build();
                
        objectMapper = new ObjectMapper();
    }
    
    @Test
    void getUserById_ShouldReturnUser() throws Exception {
        // 准备测试数据
        UserDto mockUser = new UserDto(1L, "John Doe", "john@example.com");
        
        // 配置Mock行为
        when(userService.findById(1L)).thenReturn(mockUser);
        
        // 执行测试
        mockMvc.perform(get("/api/users/1")
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.name").value("John Doe"))
                .andExpect(jsonPath("$.email").value("john@example.com"));
                
        // 验证交互
        verify(userService, times(1)).findById(1L);
    }
    
    @Test
    void createUser_ShouldReturnCreatedUser() throws Exception {
        // 准备测试数据
        UserCreateRequest request = new UserCreateRequest("Jane Doe", "jane@example.com");
        UserDto createdUser = new UserDto(2L, "Jane Doe", "jane@example.com");
        
        // 配置Mock行为
        when(userService.createUser(any(UserCreateRequest.class))).thenReturn(createdUser);
        
        // 执行测试
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id").value(2))
                .andExpect(jsonPath("$.name").value("Jane Doe"))
                .andExpect(jsonPath("$.email").value("jane@example.com"));
                
        // 验证交互
        verify(userService, times(1)).createUser(any(UserCreateRequest.class));
    }
    
    @Test
    void getUserById_WhenUserNotFound_ShouldReturnNotFound() throws Exception {
        // 配置Mock行为
        when(userService.findById(99L)).thenThrow(new UserNotFoundException("User not found"));
        
        // 执行测试
        mockMvc.perform(get("/api/users/99")
                .contentType(MediaType.APPLICATION_JSON))
                .andExpect(status().isNotFound());
                
        // 验证交互
        verify(userService, times(1)).findById(99L);
    }
}

优点与局限性

优点

  • 运行速度快:不需要启动Spring上下文或嵌入式服务器
  • 隔离性好:只测试控制器本身,不涉及其他组件
  • 可精确控制依赖行为:通过Mockito等工具模拟服务层行为
  • 便于覆盖边界情况和异常路径

局限性

  • 不测试Spring配置和依赖注入机制
  • 不验证请求映射注解的正确性
  • 不测试过滤器、拦截器和其他Web组件
  • 可能不反映实际运行时的完整行为

方案二:使用@WebMvcTest进行切片测试

工作原理

@WebMvcTest是Spring Boot测试中的一个切片测试注解,它只加载MVC相关组件(控制器、过滤器、WebMvcConfigurer等),不会启动完整的应用上下文。

这种方法在单元测试和集成测试之间取得了平衡,既测试了Spring MVC配置的正确性,又避免了完整的Spring上下文加载成本。

实现步骤

引入依赖

与方案一相同,使用spring-boot-starter-test依赖。

编写切片测试

import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest(UserController.class)
public class UserControllerWebMvcTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserService userService;
    
    @Autowired
    private ObjectMapper objectMapper;
    
    @Test
    void getUserById_ShouldReturnUser() throws Exception {
        // 准备测试数据
        UserDto mockUser = new UserDto(1L, "John Doe", "john@example.com");
        
        // 配置Mock行为
        when(userService.findById(1L)).thenReturn(mockUser);
        
        // 执行测试
        mockMvc.perform(get("/api/users/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.name").value("John Doe"))
                .andExpect(jsonPath("$.email").value("john@example.com"));
    }
    
    @Test
    void createUser_WithValidationError_ShouldReturnBadRequest() throws Exception {
        // 准备无效请求数据(缺少必填字段)
        UserCreateRequest invalidRequest = new UserCreateRequest("", null);
        
        // 执行测试
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(invalidRequest)))
                .andExpect(status().isBadRequest())
                .andDo(print()); // 打印请求和响应详情,便于调试
    }
    
    @Test
    void testSecurityConfiguration() throws Exception {
        // 测试需要认证的端点
        mockMvc.perform(delete("/api/users/1"))
                .andExpect(status().isUnauthorized());
    }
}

测试自定义过滤器和拦截器

@WebMvcTest(UserController.class)
@Import({RequestLoggingFilter.class, AuditInterceptor.class})
public class UserControllerWithFiltersTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @MockBean
    private UserService userService;
    
    @MockBean
    private AuditService auditService;
    
    @Test
    void requestShouldPassThroughFiltersAndInterceptors() throws Exception {
        // 准备测试数据
        UserDto mockUser = new UserDto(1L, "John Doe", "john@example.com");
        when(userService.findById(1L)).thenReturn(mockUser);
        
        // 执行请求,验证经过过滤器和拦截器后成功返回数据
        mockMvc.perform(get("/api/users/1")
                .header("X-Trace-Id", "test-trace-id"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1));
                
        // 验证拦截器调用了审计服务
        verify(auditService, times(1)).logAccess(anyString(), eq("GET"), eq("/api/users/1"));
    }
}

优点与局限性

优点

  • 测试MVC配置的完整性:包括请求映射、数据绑定、验证等
  • 涵盖过滤器和拦截器:验证整个MVC请求处理链路
  • 启动速度较快:只加载MVC相关组件,不加载完整应用上下文
  • 支持测试安全配置:可以验证访问控制和认证机制

局限性

  • 不测试实际的服务实现:依赖于模拟的服务层
  • 不测试数据访问层:不涉及实际的数据库交互
  • 配置复杂度增加:需要模拟或排除更多依赖
  • 启动速度虽比完整集成测试快,但比纯单元测试慢

方案三:基于@SpringBootTest的集成测试

工作原理

@SpringBootTest会加载完整的Spring应用上下文,可以与嵌入式服务器集成,测试真实的HTTP请求和响应。这种方法提供了最接近生产环境的测试体验,但启动速度较慢,适合端到端功能验证。

实现步骤

引入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<!-- 可选:如果需要测试数据库层 -->
<dependency>
    <groupId>com.h2database</groupId>
    <artifactId>h2</artifactId>
    <scope>test</scope>
</dependency>

编写集成测试(使用模拟端口)

@SpringBootTest
@AutoConfigureMockMvc
class UserControllerIntegrationTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private ObjectMapper objectMapper;
    
    @Autowired
    private UserRepository userRepository;
    
    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
        
        // 准备测试数据
        User user = new User();
        user.setId(1L);
        user.setName("John Doe");
        user.setEmail("john@example.com");
        userRepository.save(user);
    }
    
    @Test
    void getUserById_ShouldReturnUser() throws Exception {
        mockMvc.perform(get("/api/users/1"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.id").value(1))
                .andExpect(jsonPath("$.name").value("John Doe"))
                .andExpect(jsonPath("$.email").value("john@example.com"));
    }
    
    @Test
    void createUser_ShouldSaveToDatabase() throws Exception {
        UserCreateRequest request = new UserCreateRequest("Jane Doe", "jane@example.com");
        
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.name").value("Jane Doe"));
                
        // 验证数据是否实际保存到数据库
        Optional<User> savedUser = userRepository.findByEmail("jane@example.com");
        assertTrue(savedUser.isPresent());
        assertEquals("Jane Doe", savedUser.get().getName());
    }
}

编写集成测试(使用真实端口)

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerServerIntegrationTest {
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Autowired
    private UserRepository userRepository;
    
    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
        
        // 准备测试数据
        User user = new User();
        user.setId(1L);
        user.setName("John Doe");
        user.setEmail("john@example.com");
        userRepository.save(user);
    }
    
    @Test
    void getUserById_ShouldReturnUser() {
        ResponseEntity<UserDto> response = restTemplate.getForEntity("/api/users/1", UserDto.class);
        
        assertEquals(HttpStatus.OK, response.getStatusCode());
        assertEquals("John Doe", response.getBody().getName());
    }
    
    @Test
    void createUser_ShouldReturnCreatedUser() {
        UserCreateRequest request = new UserCreateRequest("Jane Doe", "jane@example.com");
        
        ResponseEntity<UserDto> response = restTemplate.postForEntity(
                "/api/users", request, UserDto.class);
                
        assertEquals(HttpStatus.CREATED, response.getStatusCode());
        assertNotNull(response.getBody().getId());
        assertEquals("Jane Doe", response.getBody().getName());
    }
    
    @Test
    void testCaching() {
        // 第一次请求
        long startTime = System.currentTimeMillis();
        ResponseEntity<UserDto> response1 = restTemplate.getForEntity("/api/users/1", UserDto.class);
        long firstRequestTime = System.currentTimeMillis() - startTime;
        
        // 第二次请求(应该从缓存获取)
        startTime = System.currentTimeMillis();
        ResponseEntity<UserDto> response2 = restTemplate.getForEntity("/api/users/1", UserDto.class);
        long secondRequestTime = System.currentTimeMillis() - startTime;
        
        // 验证两次请求返回相同数据
        assertEquals(response1.getBody().getId(), response2.getBody().getId());
        
        // 通常缓存请求会明显快于首次请求
        assertTrue(secondRequestTime < firstRequestTime, 
                   "第二次请求应该更快(缓存生效)");
    }
}

使用测试配置覆盖生产配置

创建测试专用配置文件src/test/resources/application-test.yml:

spring:
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password: 
  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: create-drop

# 禁用某些生产环境组件
app:
  scheduling:
    enabled: false
  external-services:
    payment-gateway: mock

在测试类中指定配置文件:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class UserControllerConfiguredTest {
    // 测试内容
}

优点与局限性

优点

  • 全面测试:覆盖从HTTP请求到数据库的完整流程
  • 真实行为验证:测试实际的服务实现和组件交互
  • 发现集成问题:能找出组件集成时的问题
  • 适合功能测试:验证完整的业务功能

局限性

  • 启动速度慢:需要加载完整Spring上下文
  • 测试隔离性差:测试可能相互影响
  • 配置和设置复杂:需要管理测试环境配置
  • 调试困难:出错时定位问题复杂
  • 不适合覆盖全部场景:不可能覆盖所有边界情况

方案四:使用TestRestTemplate/WebTestClient进行端到端测试

工作原理

此方法使用专为测试设计的HTTP客户端,向实际运行的嵌入式服务器发送请求,接收并验证响应。TestRestTemplate适用于同步测试,而WebTestClient支持反应式和非反应式应用的测试,并提供更流畅的API。

实现步骤

使用TestRestTemplate(同步测试)

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerE2ETest {
    
    @Autowired
    private TestRestTemplate restTemplate;
    
    @Test
    void testCompleteUserLifecycle() {
        // 1. 创建用户
        UserCreateRequest createRequest = new UserCreateRequest("Test User", "test@example.com");
        ResponseEntity<UserDto> createResponse = restTemplate.postForEntity(
                "/api/users", createRequest, UserDto.class);
                
        assertEquals(HttpStatus.CREATED, createResponse.getStatusCode());
        Long userId = createResponse.getBody().getId();
        
        // 2. 获取用户
        ResponseEntity<UserDto> getResponse = restTemplate.getForEntity(
                "/api/users/" + userId, UserDto.class);
                
        assertEquals(HttpStatus.OK, getResponse.getStatusCode());
        assertEquals("Test User", getResponse.getBody().getName());
        
        // 3. 更新用户
        UserUpdateRequest updateRequest = new UserUpdateRequest("Updated User", null);
        restTemplate.put("/api/users/" + userId, updateRequest);
        
        // 验证更新成功
        ResponseEntity<UserDto> afterUpdateResponse = restTemplate.getForEntity(
                "/api/users/" + userId, UserDto.class);
                
        assertEquals("Updated User", afterUpdateResponse.getBody().getName());
        assertEquals("test@example.com", afterUpdateResponse.getBody().getEmail());
        
        // 4. 删除用户
        restTemplate.delete("/api/users/" + userId);
        
        // 验证删除成功
        ResponseEntity<UserDto> afterDeleteResponse = restTemplate.getForEntity(
                "/api/users/" + userId, UserDto.class);
                
        assertEquals(HttpStatus.NOT_FOUND, afterDeleteResponse.getStatusCode());
    }
}

使用WebTestClient(支持反应式测试)

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UserControllerWebClientTest {
    
    @Autowired
    private WebTestClient webTestClient;
    
    @Test
    void testUserApi() {
        // 创建用户并获取ID
        UserCreateRequest createRequest = new UserCreateRequest("Reactive User", "reactive@example.com");
        
        UserDto createdUser = webTestClient.post()
                .uri("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .bodyValue(createRequest)
                .exchange()
                .expectStatus().isCreated()
                .expectBody(UserDto.class)
                .returnResult()
                .getResponseBody();
                
        Long userId = createdUser.getId();
        
        // 获取用户
        webTestClient.get()
                .uri("/api/users/{id}", userId)
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .jsonPath("$.name").isEqualTo("Reactive User")
                .jsonPath("$.email").isEqualTo("reactive@example.com");
                
        // 验证查询API
        webTestClient.get()
                .uri(uriBuilder -> uriBuilder
                        .path("/api/users")
                        .queryParam("email", "reactive@example.com")
                        .build())
                .exchange()
                .expectStatus().isOk()
                .expectBodyList(UserDto.class)
                .hasSize(1)
                .contains(createdUser);
    }
    
    @Test
    void testPerformance() {
        // 测试API响应时间
        webTestClient.get()
                .uri("/api/users")
                .exchange()
                .expectStatus().isOk()
                .expectBody()
                .consumeWith(response -> {
                    long responseTime = response.getResponseHeaders()
                            .getFirst("X-Response-Time") != null
                            ? Long.parseLong(response.getResponseHeaders().getFirst("X-Response-Time"))
                            : 0;
                            
                    // 验证响应时间在可接受范围内
                    assertTrue(responseTime < 500, "API响应时间应小于500ms");
                });
    }
}

优点与局限性

优点

  • 完整测试:验证应用在真实环境中的行为
  • 端到端验证:测试从HTTP请求到数据库的全流程
  • 符合用户视角:从客户端角度验证功能
  • 支持高级场景:可测试认证、性能、流量等

局限性

  • 运行慢:完整上下文启动耗时长
  • 环境依赖:可能需要外部服务和资源
  • 维护成本高:测试复杂度和脆弱性增加
  • 不适合单元覆盖:难以覆盖所有边界情况
  • 调试困难:问题定位和修复复杂

方案对比与选择建议

特性MockMvc单元测试@WebMvcTest切片测试@SpringBootTest集成测试TestRestTemplate/WebTestClient
上下文加载不加载只加载MVC组件完整加载完整加载
启动服务器可选
测试速度最快最慢
测试隔离性最高
覆盖范围控制器逻辑MVC配置和组件全栈集成全栈端到端
配置复杂度
适用场景控制器单元逻辑MVC配置验证功能集成测试用户端体验验证
模拟依赖完全模拟部分模拟少量或不模拟少量或不模拟

总结

SpringBoot为WebMVC测试提供了丰富的工具和策略,从轻量的单元测试到全面的端到端测试。选择合适的测试方案,需要权衡测试覆盖范围、执行效率、维护成本和团队熟悉度。

无论选择哪种测试方案,持续测试和持续改进都是软件质量保障的核心理念。