Backend

99 Ngày Spring — Ngày 52: @WebMvcTest & MockMvc

SSite Admin
17 tháng 09, 2026 7 phút đọc 0 lượt xem
99 Ngày Spring — Ngày 52: @WebMvcTest & MockMvc

Hôm qua ta test service mà không cần Spring. Nhưng có những câu hỏi chỉ tầng web mới trả lời được: định tuyến đúng chưa, JSON có đúng hình dạng không, @Valid có thật sự chạy không, và ngoại lệ có biến thành đúng mã HTTP không. @WebMvcTest nạp đúng một lát cắt — tầng web và không gì khác — rồi MockMvc gọi request thật vào đó mà không cần dựng server.

Sketchnote Ngày 52: WebMvcTest và MockMvc — slice test nạp gì và không nạp gì, MockMvc chạy không cần server, test validation và ControllerAdvice, và bốn cái bẫy gồm 401 vì Spring Security

Slice test — nạp đúng một lát cắt

@WebMvcTest nạp controller, @ControllerAdvice, bộ chuyển JSON và Spring Security — nhưng không nạp @Service, @Repository hay JPA. Đó là lý do mọi service controller cần đều phải khai bằng @MockitoBean, và cũng là lỗi số một của người mới.

// SLICE TEST — nạp ĐÚNG một lát cắt của ứng dụng, không nạp cả cái.

@WebMvcTest(DonHangController.class)     // ⭐ CHỈ nạp tầng web của ĐÚNG controller này
class DonHangControllerTest {

    @Autowired MockMvc mockMvc;          // Spring dựng sẵn cho bạn
    @MockitoBean DonHangService service; // ⭐ Spring Boot 3.4+ (trước là @MockBean)

    // @WebMvcTest NẠP:      controller, @ControllerAdvice, converter JSON,
    //                       WebMvcConfigurer, filter, Spring Security
    // @WebMvcTest KHÔNG NẠP: @Service, @Repository, DataSource, JPA
    // ➜ Vì thế mọi service mà controller cần đều PHẢI khai bằng @MockitoBean,
    //   nếu không: "No qualifying bean of type ... DonHangService".
    //   Đây là lỗi số một của người mới dùng slice test.
}

// ⭐ MockMvc CHẠY MÀ KHÔNG CÓ SERVER, KHÔNG CÓ CỔNG MẠNG.
//    Nó gọi thẳng vào DispatcherServlet trong bộ nhớ.
//    ➜ Nhanh hơn hàng chục lần, và không bao giờ dính "port already in use" trên CI.

// ❗ Vẫn nhanh hơn @SpringBootTest rất nhiều, nhưng KHÔNG nhanh bằng unit test
//    hôm qua: nó vẫn dựng một context Spring (nhỏ). Vài trăm mili-giây, không phải vài mili.
//    ➜ QUY TẮC CHỌN: logic nghiệp vụ thì unit test (Ngày 51).
//      Còn @WebMvcTest để trả lời những câu CHỈ tầng web mới trả lời được:
//      định tuyến đúng chưa, JSON đúng hình dạng chưa, validation có chạy không,
//      lỗi có thành đúng mã HTTP không.
  • MockMvc chạy không cần server, không cần cổng mạng — nó gọi thẳng vào DispatcherServlet trong bộ nhớ, nên không bao giờ dính "port already in use" trên CI.

  • Nhanh hơn @SpringBootTest rất nhiều nhưng vẫn dựng một context nhỏ — vài trăm mili-giây, không phải vài mili như unit test hôm qua.

  • Quy tắc chọn: logic nghiệp vụ thì unit test; @WebMvcTest để trả lời những câu chỉ tầng web mới trả lời được.

Gọi request và những thứ chỉ ở đây mới chứng minh được

Đây là chỗ @WebMvcTest hơn hẳn unit test: nó chứng minh @ControllerAdvice của Ngày 16 thật sự biến ngoại lệ thành đúng mã HTTP, và @Valid của Ngày 14 thật sự chặn dữ liệu sai ngay tại cửa. Unit test cho service không bao giờ trả lời được hai câu đó.

// GỌI REQUEST — đọc như một câu chuyện HTTP.

@Test
void lay_don_tra_ve_200_va_dung_json() throws Exception {
    when(service.tim(1L)).thenReturn(new DonHang(1L, "SP-01", 2));

    mockMvc.perform(get("/api/don/{id}", 1L)          // ⭐ dùng {id}, đừng nối chuỗi
                .accept(MediaType.APPLICATION_JSON))
           .andExpect(status().isOk())
           .andExpect(jsonPath("$.id").value(1))
           .andExpect(jsonPath("$.maSanPham").value("SP-01"))
           .andExpect(jsonPath("$.matKhau").doesNotExist());   // ⭐ khẳng định KHÔNG lộ
}

@Test
void khong_thay_don_thi_tra_404() throws Exception {
    when(service.tim(99L)).thenThrow(new KhongTimThayException("Đơn 99"));

    mockMvc.perform(get("/api/don/99"))
           .andExpect(status().isNotFound())              // ⭐ TEST LUÔN @ControllerAdvice
           .andExpect(jsonPath("$.message").value("Đơn 99"));
}
// ⭐ Đây là chỗ @WebMvcTest hơn hẳn unit test: nó chứng minh rằng
//    @ControllerAdvice của Ngày 16 THẬT SỰ biến ngoại lệ thành đúng mã HTTP.
//    Unit test cho service không bao giờ trả lời được câu đó.

@Test
void so_luong_am_thi_tra_400() throws Exception {
    mockMvc.perform(post("/api/don")
                .contentType(MediaType.APPLICATION_JSON)
                .content("""
                    { "maSanPham": "SP-01", "soLuong": -5 }
                    """))
           .andExpect(status().isBadRequest());
    verifyNoInteractions(service);        // ⭐ chặn NGAY tại tầng web, không xuống service
}
// ⭐ Cũng vậy: @Valid của Ngày 14 chỉ chứng minh được ở tầng này.

// ❗ KHI TEST THẤT BẠI mà bạn không hiểu vì sao — thêm đúng một dòng:
.andDo(print())      // in cả request lẫn response ra console
// Nó tiết kiệm nhiều thời gian hơn mọi mẹo khác trong bài này.
  • jsonPath("$.matKhau").doesNotExist() khẳng định thứ không được lộ ra — đúng tinh thần Ngày 47 về DTO riêng cho tầng API.

  • verifyNoInteractions(service) chứng minh dữ liệu sai bị chặn ngay tại tầng web, chưa kịp xuống tới service.

  • Dùng get("/api/don/{id}", 1L) thay vì nối chuỗi — nó tự lo mã hóa URL và đọc rõ hơn.

  • .andDo(print()) in cả request lẫn response — tiết kiệm thời gian hơn mọi mẹo khác khi test đỏ mà bạn không hiểu vì sao.

Bốn cái bẫy

Cái bẫy đầu tiên khiến nhiều người bỏ cuộc ngay hôm đầu: mọi test đều trả 401 dù chẳng liên quan gì tới bảo mật. Lý do đơn giản — @WebMvcTest có nạp Spring Security, và bạn chưa đăng nhập.

// BỐN CÁI BẪY của @WebMvcTest — cái đầu tiên khiến nhiều người bỏ cuộc.

// ① 401 TRÊN MỌI TEST — vì @WebMvcTest CÓ nạp Spring Security.
mockMvc.perform(get("/api/don/1"))       // ❌ 401 dù test chẳng liên quan gì tới bảo mật
       .andExpect(status().isOk());

@Test
@WithMockUser(roles = "USER")            // ✅ giả lập người dùng đã đăng nhập
void lay_don() throws Exception { ... }
// Hoặc tắt hẳn: @AutoConfigureMockMvc(addFilters = false)
// ❗ Nhưng tắt filter thì bạn cũng mất luôn khả năng test phân quyền —
//    mà đó lại là thứ ĐÁNG test nhất ở tầng này. Nên ưu tiên @WithMockUser.

// ② 403 KHI POST — vì CSRF vẫn bật (đúng như Ngày 50 đã nói):
mockMvc.perform(post("/api/don").with(csrf()))   // ✅ thêm token vào

// ③ @MockBean ĐÃ BỊ KHAI TỬ — Spring Boot 3.4 trở đi dùng @MockitoBean.
//    @MockBean vẫn chạy nhưng đã đánh dấu deprecated; code mới thì dùng cái mới.

// ④ QUÊN THAM SỐ CỦA @WebMvcTest:
@WebMvcTest                              // ❌ nạp TẤT CẢ controller trong dự án
@WebMvcTest(DonHangController.class)     // ✅ chỉ đúng cái đang test
// Không giới hạn thì bạn phải mock hết service của MỌI controller, và test chậm dần
// theo kích thước dự án — đúng thứ mà slice test sinh ra để tránh.

// ⭐ CUỐI CÙNG, ĐỪNG TEST LOGIC NGHIỆP VỤ Ở ĐÂY:
//    "giảm giá 20% cho khách VIP" → unit test cho service (Ngày 51)
//    "đơn không tồn tại thì trả 404" → @WebMvcTest
//    Nhầm chỗ thì test vừa chậm vừa khó đọc, mà vẫn không bảo vệ được nhiều hơn.
  • @WithMockUser tốt hơn addFilters = false: tắt filter thì mất luôn khả năng test phân quyền, mà đó là thứ đáng test nhất ở tầng này.

  • POST bị 403 là vì CSRF vẫn bật — thêm .with(csrf()), đúng như Ngày 50 đã giải thích.

  • @MockBean đã deprecated từ Spring Boot 3.4; code mới dùng @MockitoBean.

  • Nhớ truyền tham số cho @WebMvcTest — quên là nạp mọi controller trong dự án, đúng thứ slice test sinh ra để tránh.

Bài tập nhỏ

  • Viết @WebMvcTest đầu tiên và gặp 401 — rồi sửa bằng @WithMockUser.

  • Test rằng đơn không tồn tại trả về 404 với đúng thông điệp từ @ControllerAdvice.

  • Gửi JSON có soLuong âm và khẳng định service không hề được gọi.

  • Thêm .andDo(print()) vào một test đang đỏ và đọc kỹ phần response.

  • Bỏ tham số của @WebMvcTest rồi đo xem test chậm đi bao nhiêu.

Kết lại

Bốn ý gói lại hôm nay: @WebMvcTest nạp đúng tầng web nên mọi service phải khai bằng @MockitoBean — đây là lỗi số một của người mới; MockMvc gọi thẳng vào DispatcherServlet nên không cần server và không dính lỗi cổng mạng trên CI; tầng này là nơi duy nhất chứng minh được @Valid@ControllerAdvice thật sự chạy; và cái bẫy 401 chỉ là vì Spring Security được nạp — dùng @WithMockUser thay vì tắt filter, để không mất luôn khả năng test phân quyền. Ngày 53 xuống tầng dưới cùng: @DataJpaTest — test repository, H2 hay Testcontainers, và @Sql. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Java — Ngày 52: Functional interface

Bốn interface phân biệt bằng hai câu hỏi — nhận gì vào, trả gì ra; biến thể nguyên thủy tránh boxing; vì sao chỉ nên dùng andThen — và khi nào tự viết interface là đúng, kể cả để lách checked exception.

17 thg 9, 20268 phút0
99 Ngày Spring — Ngày 51: Unit test tầng service

Service là class Java bình thường nên test nó không cần Spring — vài mili-giây thay vì vài giây; Mockito với Mock và InjectMocks, verifyNoInteractions khẳng định điều không xảy ra, và bốn cái bẫy tạo ra test luôn xanh mà vô dụng.

16 thg 9, 20267 phút5
99 Ngày Java — Ngày 51: Lambda expression

Bốn dạng cú pháp và lý do lược bớt được, vì sao lambda KHÔNG phải anonymous class viết gọn — this trỏ khác chỗ và nó dựng bằng invokedynamic — cùng lý do thật đằng sau luật effectively final.

16 thg 9, 20268 phút4