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

SpringBoot中4种WebMVC测试实现方案

zonemu2个月前 (08-24)技术文章23

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

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

相关文章

jenkins+gitlab 实现自动化部署(gitlab触发jenkins)

目录1、安装jdk,要记住安装路径2、安装maven,要记住安装路径3、安装git,要记住安装路径4、安装gitlab5、安装jenkins(centos7)创建安装目录下载通用war包启动和关闭Je...

育知HTML5培训,为什么要学习“HTML5混合式开发技术”

HTML5 的广泛应用,强势崛起企业现在安卓、iOS开发人员都在学习HTML5混合开发,节约成本、一专多能是未来很多企业用人趋势!HTML5工程师在今后的工作中与 Android、iOS工程师对接的几...

UEPlus for HTML5摩尔线程S50 GPU+统信UOS+海光CPU测试

UEPlus for HTML5案例测试。今天给大家带来UEPlus for HTML5的案例测试效果展示。测试环境由客户提供,处理器是海光3250,显卡是摩尔线程S50,8G显存。操作系统是统信V2...

JavaScript 数组的常用方法(javascript数组的常用方法)

在我们前端来说数组作为一个常用的类型,今天我们讲一下在实际使用中经常用到的方法和使用场景。concat() 多数组组合concat() 可以用来连接两个或多个数组,返回要给新的数组并且不会影响之前的数...

73 “动态数组”用法详解(八) -选择行列CHOOSECOLS函数

今天继续分享动态数组专属函数系列行列数组函数系列之选择行列。选择行列这个函数原来没有发现他们的特殊之处,一直到有一天需要把常规公式更改为动态数组后,才发现这个函数的优秀之处。老规矩,还是看语法,再看案...

2023 前端是否还需要 lodash ?(前端会被淘汰吗)

大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发!前言Lodash 是一个 JavaScri...