使用 @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 不会被拉入多余的配置。