Test controller: @WebMvcTest, MockMvc, @MockitoBean

4 câu hỏi~25 phútTừ khóa: @WebMvcTest, MockMvc, test slice, @MockitoBean, @MockBean, jsonPath, ProblemDetail, MockMvcTester, AssertJ, @SpringBootTest

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:

2. Vấn đề

3. Giải pháp

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áchContextTốc độKiểm tra được
Unit test thuầnKhôngmsLogic trong method
@WebMvcTest + MockMvcTầng webVài giâyMapping, validation, JSON, status, advice, filter
@SpringBootTest + MockMvcToàn bộChậmLuồng thật, không qua mạng
@SpringBootTest(RANDOM_PORT) + RestClient/TestRestTemplateToàn bộ + Tomcat thậtChậm nhấtEnd-to-end HTTP thật
@MockBean@MockitoBean
Đến từSpring BootSpring Framework 6.2 (test context)
Trạng tháiDeprecated từ Boot 3.4Thay thế chính thức
Spy@SpyBean@MockitoSpyBean
Đặt trên @Configuration testĐượcKhông, chỉ trên field của test class

7. Khi nào nên dùng / không nên dùng

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:

Đọ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:

.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


Tổng kết chương Spring MVC

BàiChủ đềÝ chính
4.2.1Request flowClient → Tomcat → Filter → DispatcherServlet → Controller → response
4.2.2DispatcherServletFront controller: HandlerMapping, HandlerAdapter, ExceptionResolver
4.2.3@Controller vs @RestControllerView vs body; @RestController = @Controller + @ResponseBody
4.2.4Request params@PathVariable, @RequestParam, @RequestBody, @RequestHeader
4.2.5Message converter, JacksonHttpMessageConverter, content negotiation, ObjectMapper
4.2.6Validation@Valid, Bean Validation, lỗi 400
4.2.7Exception handling@RestControllerAdvice, ProblemDetail
4.2.8Filter, Interceptor, AOPBa lớp chặn, chọn đúng tầng
4.2.9ResponseEntity201+Location, 202, 204, 409/422, ETag, idempotent create
4.2.10Upload/DownloadGiới hạn size, stream, Resource/StreamingResponseBody, pre-signed URL
4.2.11Gọi API khácRestClient, pool, 3 timeout, onStatus, retry idempotent
4.2.12Async MVC, SSEDeferredResult, SseEmitter, virtual threads
4.2.13Test 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.