当前位置:首页 > 技术文章 > 正文内容

SpringBoot中4种WebMVC测试实现方案

在项目开发中,测试是确保应用质量的关键环节。对于基于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测试提供了丰富的工具和策略,从轻量的单元测试到全面的端到端测试。选择合适的测试方案,需要权衡测试覆盖范围、执行效率、维护成本和团队熟悉度。

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

相关文章

jenkins2.107+tomcat8+jdk1.8的安装和发布代码3种方式

jenkins2.107+tomcat8+jdk1.8的安装和发布代码3种方式如果对运维课程感兴趣,可以在b站上或csdn上搜索我的账号: 运维实战课程,可以关注我,学习更多免费的运维实战技术视频1....

高效使用 Vim 编辑器的 10 个技巧

在 Reverb,我们使用 MacVim 来标准化开发环境,使配对更容易,并提高效率。当我开始使用 Reverb 时,我以前从未使用过 Vim。我花了几个星期才开始感到舒服,但如果没有这样的提示,可能...

HTML5+眼球追踪?黑科技颠覆传统手机体验

今天,iH5工具推出一个新的神秘功能——眼动追踪,可以通过摄像头捕捉观众眼球活动!为了给大家具体演示该功能的使用,我做了一个案例,供大家参考。实际效果如下:案例比较简单,就是通过眼动功能获取视觉焦点位...

HTML5设计与制作哪家强?全省50多所高职院校齐聚中山比拼

3月22日下午,2018-2019年广东省职业院校学生专业技能大赛“HTML5交互融媒体内容设计与制作”赛项在中山火炬职业技术学院开幕。全省51所高职院校的52支参赛队伍参加此次大赛。参赛师生将于3月...

解锁无限潜力,在没有数组溢出情况下,掌握Filter公式正确用法

嗨,朋友们!今天我要和大家分享一些关于Filter公式的知识,这将帮助你们解决没有数组溢出情况下的问题。你是否曾经在处理数据时遇到过没有数组溢出的情况?不用担心,因为我将教你一些正确使用Filter公...

WordPress 内置的数组处理相关函数大全

我们使用 WordPress 开发的时候,有很大一部分的工作和数组处理有关,WordPress 本身也内置了一些非常方便的数组处理函数,今天给大家罗列一下,也方便自己以后写代码的时候查询。wp_par...