使用 @WebMvcTest 和 MockMvc 为 controller 编写测试

1. 总览

使用 @SpringBootTest 测试 controller 会加载整个应用程序,包括数据库、消息代理和其他所有 bean。这种方式很慢,也很难隔离错误。Spring Boot 提供了 @WebMvcTest 来只加载 web 层。配合 MockMvc,你可以发送模拟请求,而无需启动真实的服务器。

读完这篇文章,你将学会如何使用 @WebMvcTest 测试 REST controller。具体来说就是检查状态码、header 和返回的 JSON,测试验证、测试异常处理器,以及避免常见的错误。

2. 依赖

你需要 spring-boot-starter-web 来编写 controller,spring-boot-starter-validation 来进行 Bean Validation,以及 spring-boot-starter-test 来编写测试:

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

spring-boot-starter-test 已经包含了 JUnit 5、AssertJ、Mockito、JsonPath 和 Spring Test。你不需要再添加任何其他东西。

3. 需要测试的 controller

我们使用一个简单的订单管理 API。首先是模型和请求:

public record Order(Long id, String customerName, BigDecimal total) {}

public record CreateOrderRequest(
        @NotBlank String customerName,
        @NotNull @Positive BigDecimal total) {}

service 层是一个接口。在测试中,我们会对它进行 mock:

public interface OrderService {
    Order findById(Long id);
    Order create(CreateOrderRequest request);
}

public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(Long id) {
        super("找不到订单 " + id);
    }
}

controller 有两个端点:

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping("/{id}")
    public Order getOrder(@PathVariable Long id) {
        return orderService.findById(id);
    }

    @PostMapping
    public ResponseEntity<Order> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        Order created = orderService.create(request);
        return ResponseEntity
                .created(URI.create("/api/orders/" + created.id()))
                .body(created);
    }
}

最后是一个 @RestControllerAdvice 来将 OrderNotFoundException 转换为 HTTP 404:

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ProblemDetail handleNotFound(OrderNotFoundException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
    }
}

4. 使用 @WebMvcTest 编写第一个测试

4.1. 测试框架

@WebMvcTest(OrderController.class)
class OrderControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @MockitoBean
    private OrderService orderService;
}

各个部分的说明如下:

  • @WebMvcTest(OrderController.class) 创建一个 Spring context,只包含与 MVC 相关的 bean。包括 OrderController、@ControllerAdvice、Converter、Filter、WebMvcConfigurer 和 Jackson。常见的 @Service、@Repository 或 @Component 不会被加载。
  • MockMvc 已自动配置,你只需要注入它。
  • ObjectMapper 是 Spring Boot 配置的 Jackson bean。用它来序列化请求,帮助测试与真实配置保持一致。
  • @MockitoBean 为 OrderService 创建一个 Mockito mock,并将其注册到 context 中。这样 OrderController 就有了初始化所需的依赖。

注意: 从 Spring Boot 3.4 开始,@MockBean 被标记为已弃用。你应该使用 org.springframework.test.context.bean.override.mockito 包中的 @MockitoBean。如果你正在使用 Spring Boot 3.3 及更低版本,请使用 @MockBean。两者的工作方式相同。

4.2. 测试返回 JSON 的 GET 请求

@Test
void shouldReturnOrderWhenFound() throws Exception {
    given(orderService.findById(1L))
            .willReturn(new Order(1L, "Nguyen Van A", new BigDecimal("250000")));

    mockMvc.perform(get("/api/orders/{id}", 1L))
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.customerName").value("Nguyen Van A"))
            .andExpect(jsonPath("$.total").value(250000));
}

需要的静态导入:

import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

逐步解释:

  • given(...).willReturn(...) 规定 mock 在被调用时返回一个固定的 Order,当 id 为 1 时。
  • perform(get(...)) 通过 DispatcherServlet 发送 GET /api/orders/1 请求。请求不会经过网络。
  • andDo(print()) 将整个请求和响应打印到控制台。当测试失败时,这一步非常有用。
  • jsonPath("$.customerName") 查询 JSON 响应体中的字段。

print() 的输出如下所示:

MockHttpServletResponse:
           Status = 200
    Error message = null
          Headers = [Content-Type:"application/json"]
     Content type = application/json
             Body = {"id":1,"customerName":"Nguyen Van A","total":250000}

5. 测试带请求体的 POST 请求

5.1. 成功的情况

@Test
void shouldCreateOrder() throws Exception {
    var request = new CreateOrderRequest("Tran Thi B", new BigDecimal("99000"));
    given(orderService.create(request))
            .willReturn(new Order(10L, "Tran Thi B", new BigDecimal("99000")));

    mockMvc.perform(post("/api/orders")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isCreated())
            .andExpect(header().string("Location", "/api/orders/10"))
            .andExpect(jsonPath("$.id").value(10));
}

这里有两点需要注意:

  • contentType(MediaType.APPLICATION_JSON) 是必需的。如果缺少,Spring 会返回 415 Unsupported Media Type。
  • given(orderService.create(request)) 之所以有效,是因为 record 自动生成了 equals() 方法。controller 反序列化后的请求将等于测试中的 request 对象。如果你使用普通类而没有覆盖 equals(),请改用 any(CreateOrderRequest.class)。

5.2. 测试验证

@Test
void shouldReturnBadRequestWhenCustomerNameIsBlank() throws Exception {
    String invalidJson = """
            {"customerName": "", "total": -5}
            """;

    mockMvc.perform(post("/api/orders")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(invalidJson))
            .andExpect(status().isBadRequest());

    verify(orderService, never()).create(any());
}

verify(orderService, never()).create(any()) 这一行确保当数据无效时不会调用 service。这正是你想要保护的行为:验证在 web 层就阻止了请求。

这个测试使用 JSON 文本块而不是 ObjectMapper。这种写法让你可以发送 Java record 无法表示的负载,比如类型错误的 JSON。

6. 测试异常处理器

由于 @WebMvcTest 会自动加载 @RestControllerAdvice,你可以测试异常到 HTTP 状态码的映射:

@Test
void shouldReturnNotFoundWhenOrderDoesNotExist() throws Exception {
    given(orderService.findById(99L)).willThrow(new OrderNotFoundException(99L));

    mockMvc.perform(get("/api/orders/{id}", 99L))
            .andExpect(status().isNotFound())
            .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
            .andExpect(jsonPath("$.status").value(404))
            .andExpect(jsonPath("$.detail").value("找不到订单 99"));
}

根据 RFC 9457(Problem Details)标准的响应体:

{"type":"about:blank","title":"Not Found","status":404,"detail":"找不到订单 99","instance":"/api/orders/99"}

这是 @WebMvcTest 相比纯粹单元测试直接调用 orderController.getOrder(99L) 的优势。纯粹的单元测试只会看到被抛出的异常。它无法检查客户端实际收到的状态码和响应体是什么。

7. 使用 MockMvcTester 以 AssertJ 风格编写断言

从 Spring Boot 3.4(Spring Framework 6.2)开始,如果 classpath 上有 AssertJ,Spring Boot 会自动配置 MockMvcTester。这个 bean 允许你用 AssertJ 风格编写断言,而不是用 andExpect 链:

@WebMvcTest(OrderController.class)
class OrderControllerAssertJTest {

    @Autowired
    private MockMvcTester mvc;

    @MockitoBean
    private OrderService orderService;

    @Test
    void shouldReturnOrderWhenFound() {
        given(orderService.findById(1L))
                .willReturn(new Order(1L, "Nguyen Van A", new BigDecimal("250000")));

        assertThat(mvc.get().uri("/api/orders/{id}", 1L))
                .hasStatusOk()
                .hasContentType(MediaType.APPLICATION_JSON)
                .bodyJson()
                .extractingPath("$.customerName")
                .isEqualTo("Nguyen Van A");
    }
}

与 MockMvc 相比,这种写法有三个优势:

  • 不需要 throws Exception。
  • 不需要记住 MockMvcResultMatchers 的许多静态导入。
  • AssertJ 的错误消息通常更容易理解。

这两个 API 在功能上是等效的。请选择其中一个并在项目中保持一致。

8. 限制加载的内容

8.1. 指定 controller

如果 @WebMvcTest 没有参数,Spring 会加载应用程序中的所有 controller。这样你就必须 mock 每个 controller 的依赖,包括与测试无关的 controller。因此,应该始终指定要测试的 controller:

@WebMvcTest(OrderController.class)

8.2. 加载特定的 bean

有时 controller 使用一个小型的、没有副作用的 bean,比如 OrderMapper。mock 这个 bean 没有任何价值。你可以使用 @Import 加载真实的 bean:

@WebMvcTest(OrderController.class)
@Import(OrderMapper.class)
class OrderControllerTest {
    // ...
}

8.3. 当有 Spring Security 时

如果 classpath 上有 spring-boot-starter-security,@WebMvcTest 会加载整个 security filter chain。上面的测试会返回 401 或 403。你需要添加以下依赖:

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

然后模拟用户,并为修改数据的请求附加 CSRF token:

@Test
@WithMockUser(roles = "USER")
void shouldCreateOrderWhenAuthenticated() throws Exception {
    // ... stub orderService 如第 5.1 节所示
    mockMvc.perform(post("/api/orders")
                    .with(csrf())
                    .contentType(MediaType.APPLICATION_JSON)
                    .content("""
                            {"customerName": "Le Van C", "total": 120000}
                            """))
            .andExpect(status().isCreated());
}

注意: 你自己定义的 SecurityFilterChain 类(标记为 @Configuration)不会在所有版本中自动加载。如果你想测试自己的权限规则,请添加 @Import(SecurityConfig.class)。

9. 常见错误

启动测试时出现 NoSuchBeanDefinitionException。 controller 依赖于一个非 web 层的 bean,而你还没有 mock 它。解决方法:为该 bean 添加 @MockitoBean,或用 @Import 导入它。

@SpringBootApplication 类上的注解破坏了测试。 例如,你在主类上直接放置 @EnableJpaRepositories 或 @EnableScheduling。@WebMvcTest 仍会读取这个类,因此会尝试创建 repository,并因为缺少 DataSource 而失败。解决方法:将这些注解移到单独的 @Configuration 类中。

忘记 contentType。 结果会是 415,而不是预期的状态码。

使用 MockMvc 检查结果,但逻辑在 service 中。 @WebMvcTest 应该只检查 web 层的工作:

  • 路由、
  • 序列化、
  • 验证、
  • 状态码、
  • 异常映射。

业务逻辑应该在 service 层用纯单元测试单独测试。

过度 mock。 如果一个测试必须 stub 五六个方法,可能 controller 做得太多了。这是重构的信号,而不是写更多 stub 的理由。

注意: 不应该在想要检查端到端流程(通过数据库)或需要测试真实服务器行为(端口、HTTP/2、容器过滤器)时使用 @WebMvcTest。这种情况下应该使用 @SpringBootTest(webEnvironment = RANDOM_PORT) 配合 TestRestTemplate 或 WebTestClient。如果应用程序使用 WebFlux,对应的 slice 是 @WebFluxTest,而不是 @WebMvcTest。

10. 结论

@WebMvcTest 可以帮助你快速、集中地测试 controller。它只加载 web 层,后面的依赖被替换为 @MockitoBean。结合 MockMvc 或 MockMvcTester,你可以检查客户端真正看到的行为:状态码、header、JSON、验证和异常映射。

需要记住的主要规则:

  • 始终在 @WebMvcTest 中指定 controller。
  • mock 的正是 controller 需要的。
  • 发送请求体时不要忘记 contentType。
  • 保持 @SpringBootApplication 类干净,以便测试 slice 不会被拉入多余的配置。
spring-boottestingmockmvcjunit5webmvctest