99 Ngày Spring — Ngày 56: Mock bean & test security
Ngày 52 gặp @MockitoBean lần đầu ở slice test web; Ngày 54 thấy @WithMockUser vô nghĩa khi request đi qua HTTP thật. Hôm nay là bài chuyên sâu về hai thứ đó, cộng câu hỏi ai cũng gặp sau mười ngày bảo mật: làm sao test một endpoint đứng sau JWT mà không ký token thật, không gọi issuer, không cần mạng — và vì sao mỗi bộ mock khác nhau là mười lăm giây cộng thêm vào bộ test.

@MockitoBean — thay bean nào, trả giá gì
@MockitoBean gỡ bean thật ra khỏi context và đặt mock vào đúng chỗ đó; @MockitoSpyBean giữ bean thật nhưng ghi lại mọi lời gọi. Câu hỏi đầu tiên không phải "dùng thế nào" mà là mock cái gì: chỉ những thứ nằm ngoài biên giới ứng dụng hoặc không đoán trước được. Và câu hỏi thứ hai là cái giá — bài cache context của Ngày 54 quay lại.
// @MockitoBean — THAY một bean trong context bằng mock. Và trả giá cho việc đó.
// Ngày 52 đã gặp nó ở @WebMvcTest; hôm nay là bài chuyên sâu.
// Spring Boot 3.4 trở đi: @MockitoBean / @MockitoSpyBean (thay @MockBean / @SpyBean đã deprecated).
@SpringBootTest
class ThanhToanServiceTest {
@MockitoBean CongThanhToan congThanhToan; // ① bean THẬT bị gỡ ra, mock đặt vào đúng chỗ đó
@MockitoSpyBean GuiEmailService email; // ② bean THẬT vẫn chạy, nhưng ghi lại mọi lời gọi
@Autowired ThanhToanService service; // service THẬT — nó nhận mock qua constructor
@Test
void thanhToanThatBai_thiKhongGuiEmail() {
when(congThanhToan.tru(any(), anyLong()))
.thenThrow(new CongThanhToanException("Thẻ bị từ chối"));
assertThrows(ThanhToanException.class, () -> service.thanhToan(donHang));
verify(email, never()).guiXacNhan(any()); // ⭐ điều KHÔNG được xảy ra (Ngày 51)
}
}
// ⭐ MOCK CÁI GÌ? Đúng một tiêu chí: thứ nằm NGOÀI biên giới ứng dụng, hoặc không đoán trước được.
// ✅ cổng thanh toán, dịch vụ email/SMS, API bên thứ ba, Clock (giờ hệ thống), sinh số ngẫu nhiên
// ❌ repository của chính bạn — Ngày 53 và 55 đã cho bạn DB thật trong vài trăm mili-giây
// ❌ service của chính bạn — mock nó là test đang kiểm tra... chính cái mock
// ❗ CÁI GIÁ THẬT (Ngày 54): mỗi tập @MockitoBean KHÁC NHAU là một ApplicationContext MỚI.
// Class A mock {X}, class B mock {X, Y}, class C mock {Y} → BA context, ba lần mười lăm giây.
// ➜ Gom TOÀN BỘ mock vào một lớp cha cấu hình test — mọi class kế thừa dùng chung một context:
@SpringBootTest
abstract class IntegrationTestBase {
@MockitoBean protected CongThanhToan congThanhToan; // ai cũng có, dù không dùng
@MockitoBean protected GuiEmailService email;
}
class ThanhToanTest extends IntegrationTestBase { ... } // context được CACHE, không dựng lại
class HoanTienTest extends IntegrationTestBase { ... }
// ⭐ CÁCH KHÁC không đụng cache: một bean GIẢ thật sự — không phải mock, là một class nhỏ:
@TestConfiguration
class FakeCongThanhToanConfig {
@Bean @Primary CongThanhToan congThanhToanGia() { return new CongThanhToanGia(); }
}
// Fake chạy nhanh, có hành vi thật (nhớ giao dịch, trả lỗi khi thẻ hết tiền), và không cần
// when(...).thenReturn(...) ở từng test. Dự án lớn thường mock ở test nhỏ, fake ở test tích hợp.
// ❗ MOCK ĐƯỢC RESET SAU MỖI TEST (MockReset.AFTER mặc định) — nên stub trong @BeforeEach
// hoặc trong từng test, đừng stub một lần ở static rồi thắc mắc sao test thứ hai không thấy.Mock ngoài biên giới — cổng thanh toán, email, API bên thứ ba,
Clock; không mock repository hay service của chính bạn, vì khi đó test đang kiểm tra cái mock.Mỗi tập mock khác nhau là một context mới — gom toàn bộ
@MockitoBeanvào một lớp cha để mọi class test dùng chung một context được cache.Test tích hợp thì cân nhắc fake — một class nhỏ có hành vi thật, khai bằng
@TestConfigurationvà@Primary— thay cho hàng chụcwhen(...).thenReturn(...).Mock được reset sau mỗi test — stub trong
@BeforeEachhoặc trong từng test, không stub một lần ở static.
@WithMockUser và họ hàng — giả lập người đã đăng nhập
Cơ chế rất đơn giản: trước khi test chạy, Spring Security Test đặt sẵn một Authentication vào SecurityContext của Ngày 41, nên filter chain thấy "đã xác thực" và cho qua. Vì thế nó chỉ hoạt động khi request không đi qua HTTP thật — @WebMvcTest và MockMvc. Hai cái bẫy: roles tự thêm tiền tố ROLE_ còn authorities thì không; và @WithMockUser tạo một User trống, không phải principal thật của bạn.
// @WithMockUser và họ hàng — GIẢ LẬP người đã đăng nhập, không cần đăng nhập.
// Cơ chế: trước khi test chạy, Spring Security Test đặt sẵn một Authentication vào
// SecurityContext (Ngày 41). Filter chain thấy "đã xác thực" và cho qua. Chỉ hoạt động khi
// request KHÔNG đi qua HTTP thật — tức là @WebMvcTest / MockMvc, KHÔNG phải RANDOM_PORT (Ngày 54).
@WebMvcTest(DonHangController.class)
@Import(SecurityConfig.class) // ⭐ nạp đúng cấu hình security THẬT của bạn
class DonHangControllerSecurityTest {
@Autowired MockMvc mvc;
@MockitoBean DonHangService service;
@Test
@WithMockUser(username = "an", roles = "USER") // → ROLE_USER
void userThuong_xemDuocDonCuaMinh() throws Exception {
mvc.perform(get("/api/don-hang/cua-toi")).andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "USER")
void userThuong_khongVaoDuocAdmin() throws Exception {
mvc.perform(get("/api/admin/bao-cao")).andExpect(status().isForbidden()); // 403 — đã xác thực, không đủ quyền
}
@Test
@WithAnonymousUser
void chuaDangNhap_bi401() throws Exception {
mvc.perform(get("/api/don-hang/cua-toi")).andExpect(status().isUnauthorized()); // 401 — chưa xác thực
}
@Test
@WithMockUser(roles = "ADMIN")
void admin_xoaDon_canCsrf() throws Exception {
mvc.perform(delete("/api/don-hang/42").with(csrf())) // ⭐ POST/PUT/DELETE cần token CSRF
.andExpect(status().isNoContent()); // nếu bạn chưa tắt CSRF (Ngày 50)
}
}
// ❗ BẪY 1 — roles vs authorities, chính là bẫy hasRole/hasAuthority của Ngày 41:
@WithMockUser(roles = "ADMIN") // authority thật sự là "ROLE_ADMIN"
@WithMockUser(authorities = "ROLE_ADMIN") // giống hệt ở trên
@WithMockUser(authorities = "ADMIN") // ❌ KHÔNG có tiền tố → hasRole("ADMIN") trả false
@WithMockUser(authorities = "SCOPE_orders:read") // ✅ đúng cho phân quyền theo scope (Ngày 49)
// ❗ BẪY 2 — @WithMockUser tạo một User "trống": chỉ có tên và quyền. Nếu controller cần
// principal THẬT của bạn (ví dụ record NguoiDung có id), nó sẽ ClassCastException.
// ➜ Hai lối ra:
@WithUserDetails("an@example.com") // ① nạp user THẬT qua UserDetailsService (cần DB test)
@WithNguoiDung(id = 7, vaiTro = "USER") // ② annotation TỰ VIẾT với WithSecurityContextFactory:
@Retention(RUNTIME) @WithSecurityContext(factory = NguoiDungFactory.class)
public @interface WithNguoiDung { long id(); String vaiTro() default "USER"; }
class NguoiDungFactory implements WithSecurityContextFactory<WithNguoiDung> {
public SecurityContext createSecurityContext(WithNguoiDung a) {
var principal = new NguoiDung(a.id(), "an", Set.of(a.vaiTro())); // principal ĐÚNG KIỂU của bạn
var auth = new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities());
var ctx = SecurityContextHolder.createEmptyContext(); ctx.setAuthentication(auth); return ctx;
}
}
// ⭐ CÁCH VIẾT KHÁC, theo từng request thay vì từng test:
mvc.perform(get("/api/admin").with(user("an").roles("ADMIN"))); // SecurityMockMvcRequestPostProcessors
// Hợp khi một test cần THỬ NHIỀU người dùng liên tiếp trên cùng endpoint.Test đủ ba trạng thái cho mỗi endpoint có bảo vệ: chưa đăng nhập là 401, đăng nhập thiếu quyền là 403, đủ quyền là 200 — so status code, không so chuỗi lỗi.
roles = "ADMIN"thành authorityROLE_ADMIN;authorities = "ADMIN"thì không có tiền tố vàhasRoletrả false — bẫy Ngày 41 ở dạng test.Controller cần principal thật thì dùng
@WithUserDetails(nạp từUserDetailsService) hoặc viết annotation riêng vớiWithSecurityContextFactory.POST/PUT/DELETE trong
MockMvccần.with(csrf())nếu CSRF còn bật — thiếu là 403 dù đã đăng nhập, và lỗi đó nhìn rất giống lỗi phân quyền.
Test endpoint có JWT — không cần token thật
Với resource server của Ngày 49, spring-security-test cho bạn post-processor jwt(): nó đặt thẳng một JwtAuthenticationToken vào SecurityContext, không đi qua JwtDecoder, nên không cần khóa, không cần issuer, không cần mạng — bạn test luật phân quyền, không test chữ ký. Với filter tự viết của Ngày 45 thì test theo ba tầng: JwtService bằng unit test với Clock cố định, filter bằng MockMvc với khóa test, và đúng một test tích hợp đăng nhập thật.
// TEST ENDPOINT CÓ JWT — không ký token thật, không gọi Google, không cần khóa bí mật.
// Hai kiểu JWT đã học, hai cách test:
// A. Resource server của Ngày 49 (spring-boot-starter-oauth2-resource-server, JwtDecoder)
// B. Filter tự viết của Ngày 45 (OncePerRequestFilter đọc header Bearer)
// ── A. RESOURCE SERVER: post-processor jwt() của spring-security-test ─────────────
@WebMvcTest(DonHangController.class)
@Import(SecurityConfig.class)
class DonHangJwtTest {
@Autowired MockMvc mvc;
@MockitoBean DonHangService service;
@Test
void tokenCoScopeDoc_thiDocDuoc() throws Exception {
mvc.perform(get("/api/don-hang")
.with(jwt().jwt(j -> j.subject("an").claim("scope", "orders:read")) // ⭐ JWT GIẢ, đủ claim
.authorities(new SimpleGrantedAuthority("SCOPE_orders:read"))))
.andExpect(status().isOk());
}
@Test
void tokenThieuScope_thi403() throws Exception {
mvc.perform(get("/api/don-hang").with(jwt())) // JWT hợp lệ nhưng KHÔNG có scope
.andExpect(status().isForbidden());
}
@Test
void khongToken_thi401() throws Exception {
mvc.perform(get("/api/don-hang")).andExpect(status().isUnauthorized());
}
}
// ⭐ jwt() đặt thẳng một JwtAuthenticationToken vào SecurityContext — KHÔNG đi qua JwtDecoder,
// nên không cần khóa, không cần issuer chạy, không cần mạng. Bạn test LUẬT PHÂN QUYỀN,
// không test chữ ký — chữ ký là việc của thư viện, họ đã test rồi.
// ❗ Nhưng vì JwtDecoder KHÔNG chạy, test này không phát hiện được cấu hình issuer-uri sai.
// Đó là việc của MỘT test tích hợp (Ngày 54) với token thật — đúng một cái, không hơn.
// ── B. FILTER TỰ VIẾT (Ngày 45): test theo ba tầng, đừng gom vào một ────────────────
// ① JwtService — class thường, unit test thuần (Ngày 51): ký rồi đọc lại, token hết hạn, sai chữ ký.
@Test void tokenHetHan_biTuChoi() {
var svc = new JwtService(KHOA_TEST, Clock.fixed(Instant.parse("2026-09-21T00:00:00Z"), UTC));
var token = svc.phatHanh("an", Duration.ofMinutes(-1)); // ⭐ Clock cố định → hết hạn NGAY
assertThrows(TokenHetHanException.class, () -> svc.docToken(token));
}
// ② Filter — MockMvc + @Import(SecurityConfig, JwtAuthFilter) và một JwtService THẬT với khóa test:
@Test void bearerHopLe_thiVaoDuoc() throws Exception {
var token = jwtService.phatHanh("an", Duration.ofMinutes(15));
mvc.perform(get("/api/don-hang").header(AUTHORIZATION, "Bearer " + token))
.andExpect(status().isOk());
}
@Test void bearerHong_thi401_khongPhai500() throws Exception { // bẫy Ngày 45: filter ném → 500
mvc.perform(get("/api/don-hang").header(AUTHORIZATION, "Bearer abc.def.ghi"))
.andExpect(status().isUnauthorized());
}
// ③ MỘT test tích hợp RANDOM_PORT: đăng nhập thật → lấy token → gọi endpoint với Bearer (Ngày 54).
// ── PHÂN QUYỀN Ở METHOD (@PreAuthorize trên service) — hay bị quên ─────────────────
// @WebMvcTest KHÔNG nạp service, nên @PreAuthorize trên service KHÔNG được test ở slice web.
@SpringBootTest(classes = { DonHangService.class, MethodSecurityConfig.class }) // context NHỎ, có proxy
class DonHangServiceSecurityTest {
@MockitoBean DonHangRepository repo;
@Autowired DonHangService service;
@Test @WithMockUser(roles = "USER")
void user_khongXoaDuoc() {
assertThrows(AccessDeniedException.class, () -> service.xoa(42L)); // ⭐ proxy chặn TRƯỚC khi vào method
}
}
// ⭐ KIM TỰ THÁP CHO SECURITY: nhiều unit test JwtService · một slice test cho luật phân quyền
// mỗi endpoint (jwt()/@WithMockUser) · ĐÚNG MỘT test tích hợp đăng nhập thật đầu-cuối.
// ❗ ĐỪNG test 401/403 bằng cách so chuỗi JSON lỗi — so status code. Chuỗi đổi, luật không đổi.jwt()không chạy JwtDecoder — nhanh và không cần mạng, nhưng cũng không phát hiện đượcissuer-urisai; việc đó dành cho một test tích hợp duy nhất.Clocktiêm vàoJwtServicelà thứ cho phép test "token hết hạn" trong một mili-giây, thay vìThread.sleep.Token hỏng phải là 401, không phải 500 — bẫy Ngày 45, và một test hai dòng giữ nó không bao giờ quay lại.
@PreAuthorizetrên service không được test bởi@WebMvcTest— dựng một context nhỏ với@SpringBootTest(classes = ...)để proxy method security tồn tại.
Bài tập nhỏ
Đếm số
ApplicationContextbộ test của bạn dựng (bật logorg.springframework.test.context.cache) — gom@MockitoBeanvào lớp cha rồi đếm lại.Viết ba test 401/403/200 cho một endpoint admin bằng
@WithAnonymousUser,@WithMockUser(roles = "USER"),@WithMockUser(roles = "ADMIN").Đổi
roles = "ADMIN"thànhauthorities = "ADMIN"và xem test đỏ ở đâu.Viết một annotation
@WithNguoiDungtự tạo principal đúng kiểu của dự án.Với resource server: test
jwt()có và không có scope; với filter tự viết: testJwtServicebằngClock.fixedcho trường hợp hết hạn.
Kết lại
Bốn ý gói lại hôm nay: @MockitoBean chỉ dành cho thứ nằm ngoài biên giới — và mỗi bộ mock khác nhau là một context mới, nên gom vào lớp cha hoặc dùng fake; @WithMockUser đặt sẵn Authentication vào SecurityContext nên chỉ có nghĩa với MockMvc, và roles khác authorities đúng một tiền tố; jwt() cho bạn test luật phân quyền mà không cần token thật, còn chữ ký và hết hạn là việc của unit test JwtService với Clock cố định; và security cũng có kim tự tháp: nhiều unit test, một slice test mỗi endpoint, đúng một test tích hợp đăng nhập thật. Ngày 57 nói về phần thường bị coi nhẹ nhất của bộ test: dữ liệu test — builder, @Sql, và chiến lược dọn dẹp. Hẹn gặp lại!
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.


