Test controller: @WebMvcTest, MockMvc, @MockitoBean
Tiếp đi: test controller. Hiện test của mình đều dùng @SpringBootTest, chạy mất 40 giây, có cách nào nhanh hơn không?
Có: test slice @WebMvcTest. Nó chỉ dựng tầng web, service được mock. Test chạy trong vài giây và kiểm tra đúng những gì chương này đã học: mapping, validation, JSON, status code, exception handler.
1. Bài toán
Cần test OrderController của shop-app:
POST /api/ordershợp lệ → 201 + Location.quantity = 0→ 400 ProblemDetail có lỗi field (bài 4.2.6, 4.2.7).- Order không có → 404; hết hàng → 422.
2. Vấn đề
@SpringBootTestdựng toàn bộ context: DB, Kafka, RestClient tới payment-service. Chậm, cần hạ tầng, test hỏng vì lý do không liên quan controller.- Unit test thuần
new OrderController(service).create(...)bỏ qua mọi thứ quan trọng: không có@Valid, Jackson,@RestControllerAdvice, mapping URL.
3. Giải pháp
@WebMvcTest(OrderController.class): chỉ nạp controller đó,@ControllerAdvice, converter, filter,WebMvcConfigurer, Jackson. Không nạp@Service,@Repository.MockMvc: gửi request giả quaDispatcherServletthật, không mở cổng mạng.@MockitoBean(Spring Framework 6.2 / Boot 3.4+): đặt mock Mockito vào context. Thay cho@MockBeancủa Boot (deprecated từ 3.4).MockMvcTester(6.2+): cùng MockMvc nhưng assert kiểu AssertJ.
4. Code từng bước
Bước 1: Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <!-- JUnit 5, Mockito, AssertJ, JsonPath -->
<scope>test</scope>
</dependency>
Bước 2: Config. Không cần. Nếu có security, slice sẽ nạp cấu hình security; xem test security để dùng @WithMockUser.
Bước 3: Code Java
@WebMvcTest(OrderController.class)
class OrderControllerTest {
@Autowired MockMvc mvc;
@MockitoBean OrderService orderService;
@MockitoBean InvoiceService invoiceService;
@Test
void createReturns201WithLocation() throws Exception {
given(orderService.createOnce(eq("k-1"), any()))
.willReturn(new CreateResult(new OrderResponse(42L, "NEW", 3), false));
mvc.perform(post("/api/orders")
.header("Idempotency-Key", "k-1")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"productId": 10, "quantity": 2}
"""))
.andExpect(status().isCreated())
.andExpect(header().string("Location", endsWith("/api/orders/42")))
.andExpect(jsonPath("$.id").value(42))
.andExpect(jsonPath("$.status").value("NEW"));
}
@Test
void invalidQuantityReturns400ProblemDetail() throws Exception {
mvc.perform(post("/api/orders")
.header("Idempotency-Key", "k-2")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"productId": 10, "quantity": 0}
"""))
.andExpect(status().isBadRequest())
.andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("$.errors[0].field").value("quantity"));
then(orderService).shouldHaveNoInteractions(); // validation chặn trước service
}
@Test
void missingOrderReturns404() throws Exception {
given(orderService.get(99L)).willThrow(new OrderNotFoundException(99L));
mvc.perform(get("/api/orders/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.title").value("Not Found"));
}
@Test
void outOfStockReturns422() throws Exception {
given(orderService.createOnce(any(), any())).willThrow(new OutOfStockException(10L));
mvc.perform(post("/api/orders").header("Idempotency-Key", "k-3")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"productId\":10,\"quantity\":2}"))
.andExpect(status().isUnprocessableEntity());
}
}
Cùng test với MockMvcTester (AssertJ, không cần throws Exception):
@WebMvcTest(OrderController.class)
class OrderControllerAssertJTest {
@Autowired MockMvcTester mvc; // Boot 3.4 tự cấu hình khi có AssertJ
@MockitoBean OrderService orderService;
@Test
void getOrder() {
given(orderService.get(42L)).willReturn(new OrderResponse(42L, "PAID", 3));
assertThat(mvc.get().uri("/api/orders/42"))
.hasStatusOk()
.hasHeader("ETag", "\"3\"")
.bodyJson().extractingPath("$.status").isEqualTo("PAID");
}
}
Bước 4: Chạy
./mvnw test -Dtest=OrderControllerTest
# Tests run: 4, Failures: 0 — khoảng 2–4 giây, không cần DB
5. Cách hoạt động
@WebMvcTest(OrderController.class)
├─ nạp: OrderController, @ControllerAdvice, Jackson, Validator,
│ Filter, HandlerInterceptor, WebMvcConfigurer, (Security)
├─ bỏ: @Service, @Repository, @Component thường, DataSource
└─ @MockitoBean OrderService → bean mock thay chỗ trống
mvc.perform(post(...))
→ MockHttpServletRequest
→ Filter chain → DispatcherServlet thật
→ HandlerMapping → @Valid → controller → mock service
→ exception? → @RestControllerAdvice → ProblemDetail
→ HttpMessageConverter ghi JSON
→ MockHttpServletResponse → andExpect / assertThat
6. So sánh
| Cách | Context | Tốc độ | Kiểm tra được |
|---|---|---|---|
| Unit test thuần | Không | ms | Logic trong method |
@WebMvcTest + MockMvc | Tầng web | Vài giây | Mapping, validation, JSON, status, advice, filter |
@SpringBootTest + MockMvc | Toàn bộ | Chậm | Luồng thật, không qua mạng |
@SpringBootTest(RANDOM_PORT) + RestClient/TestRestTemplate | Toàn bộ + Tomcat thật | Chậm nhất | End-to-end HTTP thật |
@MockBean | @MockitoBean | |
|---|---|---|
| Đến từ | Spring Boot | Spring Framework 6.2 (test context) |
| Trạng thái | Deprecated từ Boot 3.4 | Thay thế chính thức |
| Spy | @SpyBean | @MockitoSpyBean |
Đặt trên @Configuration test | Được | Không, chỉ trên field của test class |
7. Khi nào nên dùng / không nên dùng
- Mỗi controller một
@WebMvcTest: happy path + validation + mỗi loại lỗi. - Vài
@SpringBootTest(với Testcontainers) cho luồng quan trọng đầu-cuối. - Không assert lại logic service trong controller test; đó là việc của unit test service.
- Không dùng
@WebMvcTestkhông chỉ định controller trong project lớn: nó nạp mọi controller và đòi mock mọi dependency. - App WebFlux dùng
@WebFluxTest+WebTestClient.
Test bị lỗi "No qualifying bean of type PaymentGateway" dù controller không dùng nó. Sao vậy?
Gần như chắc chắn là một bean có được slice nạp đang cần nó. Hay gặp:
- Một
HandlerInterceptor/FilterhoặcWebMvcConfigurerinject service. Slice nạp chúng, nên dependency của chúng cũng phải có → thêm@MockitoBean. - Class
@SpringBootApplicationcó@EnableJpaRepositories,@ComponentScantùy biến hoặc@Beantrực tiếp: slice cũng đọc nó. Chuyển các thứ đó sang class@Configurationriêng. - Thiếu
controllers = ...nên mọi controller được nạp.
Đọc kỹ stack trace để biết bean nào yêu cầu dependency đó.
jsonPath với so sánh cả chuỗi JSON, nên dùng cái nào?
Đúng là có hai trường phái, chọn theo mục đích:
- jsonPath từng field: test ít gãy khi thêm field mới. Dùng cho hầu hết test.
- So sánh toàn bộ (
content().json(expected), mặc định lenient: bỏ qua field thừa và thứ tự): dùng khi muốn khóa "hợp đồng" API. Muốn chặt hơn thìcontent().json(expected, JsonCompareMode.STRICT).
.andExpect(content().json("""
{"id": 42, "status": "NEW"}
""")); // lenient: có thêm field "total" vẫn pass
Với hợp đồng giữa các team, contract test (Spring Cloud Contract, Pact) phù hợp hơn so sánh chuỗi.
Phỏng vấn hỏi về test controller trong Spring thì trả lời sao?
Q1. @WebMvcTest khác @SpringBootTest thế nào?
🇻🇳 Tiếng Việt:
@WebMvcTest là test slice chỉ nạp tầng web: controller, ControllerAdvice, converter, validator, filter. Service và repository không được nạp nên phải mock. Nó nhanh và kiểm tra mapping, validation, JSON, status. @SpringBootTest nạp toàn bộ context, dùng cho integration test, chậm hơn và thường cần database thật hoặc Testcontainers.
🇬🇧 English:
@WebMvcTest is a test slice that only loads the web layer: controllers, controller advice, converters, the validator and filters. Services and repositories are not loaded, so I mock them. It is fast and verifies mapping, validation, JSON and status codes. @SpringBootTest loads the whole context for integration tests; it is slower and usually needs a real database or Testcontainers.
Q2. @MockitoBean và @MockBean khác gì?
🇻🇳 Tiếng Việt:
Cả hai đặt một mock Mockito vào ApplicationContext thay cho bean thật. @MockBean là của Spring Boot và bị deprecated từ Boot 3.4. @MockitoBean thuộc Spring Framework 6.2, là thay thế chính thức; tương tự @SpyBean thành @MockitoSpyBean.
🇬🇧 English:
Both replace a bean in the ApplicationContext with a Mockito mock. @MockBean comes from Spring Boot and is deprecated since Boot 3.4. @MockitoBean is part of Spring Framework 6.2 and is the official replacement; likewise @SpyBean becomes @MockitoSpyBean.
Q3. Test validation và error response thế nào?
🇻🇳 Tiếng Việt:
Dùng MockMvc gửi body không hợp lệ, assert status 400, content type application/problem+json và field lỗi bằng jsonPath, rồi verify service không bị gọi. Với lỗi nghiệp vụ, cho mock service ném exception và assert status do ControllerAdvice trả, như 404 hay 422.
🇬🇧 English:
I send an invalid body with MockMvc, assert a 400 status, the application/problem+json content type and the field error with jsonPath, and verify the service was never called. For business errors I make the mocked service throw and assert the status produced by the controller advice, such as 404 or 422.
Cách nhớ ngắn gọn
- @WebMvcTest = tầng web thật + service mock.
- MockMvc chạy DispatcherServlet thật, không mở cổng.
- Boot 3.4+: @MockitoBean thay @MockBean; MockMvcTester cho AssertJ.
- Mỗi controller: happy path, validation 400, từng lỗi nghiệp vụ.
Tổng kết chương Spring MVC
| Bài | Chủ đề | Ý chính |
|---|---|---|
| 4.2.1 | Request flow | Client → Tomcat → Filter → DispatcherServlet → Controller → response |
| 4.2.2 | DispatcherServlet | Front controller: HandlerMapping, HandlerAdapter, ExceptionResolver |
| 4.2.3 | @Controller vs @RestController | View vs body; @RestController = @Controller + @ResponseBody |
| 4.2.4 | Request params | @PathVariable, @RequestParam, @RequestBody, @RequestHeader |
| 4.2.5 | Message converter, Jackson | HttpMessageConverter, content negotiation, ObjectMapper |
| 4.2.6 | Validation | @Valid, Bean Validation, lỗi 400 |
| 4.2.7 | Exception handling | @RestControllerAdvice, ProblemDetail |
| 4.2.8 | Filter, Interceptor, AOP | Ba lớp chặn, chọn đúng tầng |
| 4.2.9 | ResponseEntity | 201+Location, 202, 204, 409/422, ETag, idempotent create |
| 4.2.10 | Upload/Download | Giới hạn size, stream, Resource/StreamingResponseBody, pre-signed URL |
| 4.2.11 | Gọi API khác | RestClient, pool, 3 timeout, onStatus, retry idempotent |
| 4.2.12 | Async MVC, SSE | DeferredResult, SseEmitter, virtual threads |
| 4.2.13 | Test controller | @WebMvcTest, MockMvc, @MockitoBean, MockMvcTester |
Tự kiểm tra
@WebMvcTest có nạp @Service không?
Không. Phải cung cấp bằng @MockitoBean.
MockMvc có mở cổng HTTP không?
Không; dùng request/response giả qua DispatcherServlet thật.
Thay thế cho @MockBean và @SpyBean từ Boot 3.4?
@MockitoBean và @MockitoSpyBean.
Làm sao chứng minh validation chặn trước service?
Assert 400 và then(orderService).shouldHaveNoInteractions().
content().json(...) mặc định so sánh chặt hay lỏng?
Lỏng (lenient): cho phép field thừa.