Viết test cho controller với @WebMvcTest và MockMvc
1. Tổng quan
Test controller bằng @SpringBootTest sẽ nạp toàn bộ ứng dụng, gồm cả database, message broker và mọi bean khác. Cách này chậm và khó cô lập lỗi. Spring Boot cung cấp @WebMvcTest để chỉ nạp tầng web. Khi đi kèm MockMvc, bạn có thể gửi request giả lập mà không cần khởi động server thật.
Đọc xong bài này, bạn sẽ biết cách test một REST controller với @WebMvcTest. Cụ thể là kiểm tra status, header và JSON trả về, test validation, test exception handler, và tránh các lỗi hay gặp.
2. Dependency
Bạn cần spring-boot-starter-web cho controller, spring-boot-starter-validation cho Bean Validation, và spring-boot-starter-test cho phần 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 đã có sẵn JUnit 5, AssertJ, Mockito, JsonPath và Spring Test. Bạn không cần thêm gì khác.
3. Controller cần test
Ta dùng một API quản lý đơn hàng đơn giản. Đầu tiên là model và request:
public record Order(Long id, String customerName, BigDecimal total) {}
public record CreateOrderRequest(
@NotBlank String customerName,
@NotNull @Positive BigDecimal total) {}
Tầng service là một interface. Trong test, ta sẽ mock nó:
public interface OrderService {
Order findById(Long id);
Order create(CreateOrderRequest request);
}
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long id) {
super("Không tìm thấy đơn hàng " + id);
}
}
Controller có hai endpoint:
@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);
}
}
Cuối cùng là một @RestControllerAdvice để chuyển OrderNotFoundException thành HTTP 404:
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
}
4. Test đầu tiên với @WebMvcTest
4.1. Khung test
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockitoBean
private OrderService orderService;
}
Từng phần làm việc sau:
@WebMvcTest(OrderController.class)tạo một Spring context chỉ gồm bean liên quan tới MVC. Đó làOrderController, các@ControllerAdvice,Converter,Filter,WebMvcConfigurervà Jackson. Các@Service,@Repositoryhay@Componentthông thường không được nạp.MockMvcđược auto-configure sẵn, bạn chỉ cần inject.ObjectMapperlà bean Jackson mà Spring Boot cấu hình. Dùng nó để serialize request giúp test khớp với cấu hình thật.@MockitoBeantạo một Mockito mock choOrderServicevà đăng ký nó vào context. Nhờ đóOrderControllercó dependency để khởi tạo.
Lưu ý: Từ Spring Boot 3.4,
@MockBeanbị đánh dấu deprecated. Bạn nên dùng@MockitoBeantrong packageorg.springframework.test.context.bean.override.mockito. Nếu đang ở Spring Boot 3.3 trở xuống, hãy dùng@MockBean. Cách hoạt động tương tự.
4.2. Test GET trả về JSON
@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));
}
Các static import cần có:
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.*;
Diễn giải từng bước:
given(...).willReturn(...)quy định mock trả về mộtOrdercố định khi được gọi với id1.perform(get(...))gửi requestGET /api/orders/1quaDispatcherServlet. Request không đi qua mạng.andDo(print())in toàn bộ request và response ra console. Bước này rất hữu ích khi test fail.jsonPath("$.customerName")truy vấn field trong body JSON.
Phần output của print() sẽ có dạng:
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. Test POST với request body
5.1. Trường hợp thành công
@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));
}
Ở đây có hai điểm cần chú ý:
contentType(MediaType.APPLICATION_JSON)là bắt buộc. Nếu thiếu, Spring trả về 415 Unsupported Media Type.given(orderService.create(request))hoạt động được vìrecordtự sinhequals(). Request do controller deserialize sẽ bằng với objectrequesttrong test. Nếu dùng class thường mà không overrideequals(), hãy dùngany(CreateOrderRequest.class)thay thế.
5.2. Test validation
@WebMvcTest có nạp cấu hình validation. Vì vậy @Valid hoạt động giống như khi chạy thật:
@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());
}
Dòng verify(orderService, never()).create(any()) đảm bảo service không bị gọi khi dữ liệu không hợp lệ. Đây chính là hành vi bạn muốn bảo vệ: validation chặn request ngay ở tầng web.
Test này dùng text block JSON thay vì ObjectMapper. Cách viết này giúp bạn gửi cả những payload mà record Java không biểu diễn được, ví dụ JSON sai kiểu dữ liệu.
6. Test exception handler
Vì @WebMvcTest tự nạp các @RestControllerAdvice, bạn có thể test luôn việc ánh xạ exception sang HTTP status:
@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("Không tìm thấy đơn hàng 99"));
}
Response body theo chuẩn RFC 9457 (Problem Details):
{"type":"about:blank","title":"Not Found","status":404,"detail":"Không tìm thấy đơn hàng 99","instance":"/api/orders/99"}
Đây là điểm mạnh của @WebMvcTest so với unit test thuần gọi thẳng orderController.getOrder(99L). Unit test thuần chỉ thấy exception bị ném ra. Nó không kiểm tra được client thực sự nhận về status và body gì.
7. Viết assertion kiểu AssertJ với MockMvcTester
Từ Spring Boot 3.4 (Spring Framework 6.2), nếu AssertJ có trên classpath, Spring Boot auto-configure thêm MockMvcTester. Bean này cho phép viết assertion bằng AssertJ thay cho chuỗi 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");
}
}
So với MockMvc, cách viết này có ba ưu điểm:
- Không cần
throws Exception. - Không phải nhớ nhiều static import từ
MockMvcResultMatchers. - Thông báo lỗi của AssertJ thường dễ đọc hơn.
Hai API tương đương nhau về khả năng. Hãy chọn một và dùng thống nhất trong dự án.
8. Giới hạn những gì được nạp
8.1. Chỉ định controller
Nếu viết @WebMvcTest không có tham số, Spring nạp tất cả controller trong ứng dụng. Khi đó bạn phải mock dependency của mọi controller, kể cả những controller không liên quan tới test. Vì vậy hãy luôn chỉ định controller cần test:
@WebMvcTest(OrderController.class)
8.2. Nạp thêm bean cụ thể
Đôi khi controller dùng một bean nhỏ, không có side effect, ví dụ một OrderMapper. Mock bean này không mang lại giá trị gì. Bạn có thể nạp bean thật bằng @Import:
@WebMvcTest(OrderController.class)
@Import(OrderMapper.class)
class OrderControllerTest {
// ...
}
8.3. Khi có Spring Security
Nếu spring-boot-starter-security có trên classpath, @WebMvcTest sẽ nạp cả security filter chain. Các test ở trên sẽ trả về 401 hoặc 403. Bạn cần thêm dependency sau:
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
Sau đó giả lập user, và gửi kèm CSRF token cho các request thay đổi dữ liệu:
@Test
@WithMockUser(roles = "USER")
void shouldCreateOrderWhenAuthenticated() throws Exception {
// ... stub orderService như mục 5.1
mockMvc.perform(post("/api/orders")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"customerName": "Le Van C", "total": 120000}
"""))
.andExpect(status().isCreated());
}
Lưu ý: Class
SecurityFilterChaindo bạn tự định nghĩa (đánh dấu@Configuration) không tự động được nạp trong mọi phiên bản. Nếu muốn test đúng rule phân quyền của mình, hãy thêm@Import(SecurityConfig.class).
9. Lỗi hay gặp
NoSuchBeanDefinitionException khi khởi động test. Controller phụ thuộc vào một bean không thuộc tầng web mà bạn chưa mock. Cách sửa: thêm @MockitoBean cho bean đó, hoặc @Import nó.
Annotation trên class @SpringBootApplication làm hỏng test. Ví dụ bạn đặt @EnableJpaRepositories hay @EnableScheduling trực tiếp trên class main. @WebMvcTest vẫn đọc class này, nên sẽ cố tạo repository và lỗi vì thiếu DataSource. Cách sửa: tách các annotation đó sang một class @Configuration riêng.
Quên contentType. Kết quả là 415 thay vì status mong đợi.
Kiểm tra kết quả bằng MockMvc nhưng logic nằm ở service. @WebMvcTest chỉ nên kiểm tra phần việc của tầng web:
- routing,
- serialization,
- validation,
- status code,
- exception mapping.
Logic nghiệp vụ hãy test riêng ở service bằng unit test thuần.
Over-mock. Nếu một test phải stub năm, sáu method, có thể controller đang làm quá nhiều việc. Đó là tín hiệu để refactor, không phải để viết thêm stub.
Lưu ý: Không nên dùng
@WebMvcTestkhi bạn muốn kiểm tra luồng end-to-end qua database, hoặc khi cần test hành vi của server thật (port, HTTP/2, filter của container). Khi đó hãy dùng@SpringBootTest(webEnvironment = RANDOM_PORT)cùngTestRestTemplatehoặcWebTestClient. Nếu ứng dụng dùng WebFlux, slice tương ứng là@WebFluxTest, không phải@WebMvcTest.
10. Kết luận
@WebMvcTest giúp bạn test controller nhanh và tập trung. Nó chỉ nạp tầng web, còn dependency phía sau được thay bằng @MockitoBean. Kết hợp với MockMvc hoặc MockMvcTester, bạn kiểm tra được các hành vi mà client thực sự thấy: status code, header, JSON, validation và exception mapping.
Quy tắc chính cần nhớ:
- Luôn chỉ định controller trong
@WebMvcTest. - Mock đúng những gì controller cần.
- Đừng quên
contentTypekhi gửi body. - Giữ class
@SpringBootApplicationsạch để các test slice không bị kéo theo cấu hình thừa.