Backend

99 Ngày Spring — Ngày 56: Mock bean & test security

SSite Admin
21 tháng 09, 2026 11 phút đọc 3 lượt xem
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.

Sketchnote Ngày 56: mock bean và test security — MockitoBean thay bean và tạo context mới, mock ngoài biên giới còn fake cho test tích hợp, WithMockUser với bẫy roles và authorities, jwt() post-processor không cần JwtDecoder, kim tự tháp test security

@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ộ @MockitoBean và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 @TestConfiguration@Primary — thay cho hàng chục when(...).thenReturn(...).

  • Mock được reset sau mỗi test — stub trong @BeforeEach hoặ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@WebMvcTestMockMvc. 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 authority ROLE_ADMIN; authorities = "ADMIN" thì không có tiền tốhasRole trả 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ới WithSecurityContextFactory.

  • POST/PUT/DELETE trong MockMvc cầ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 được issuer-uri sai; việc đó dành cho một test tích hợp duy nhất.

  • Clock tiêm vào JwtService là 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.

  • @PreAuthorize trê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ố ApplicationContext bộ test của bạn dựng (bật log org.springframework.test.context.cache) — gom @MockitoBean và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ành authorities = "ADMIN" và xem test đỏ ở đâu.

  • Viết một annotation @WithNguoiDung tự 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: test JwtService bằng Clock.fixed cho 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!

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 56: Terminal operations

Năm họ phép kết thúc nhận ra bằng kiểu trả về, stream chỉ dùng một lần, reduce với identity trung hòa và collect cho container, toList() không sửa được — và vì sao allMatch trả true trên tập rỗng còn findAny không hứa trả phần tử đầu.

21 thg 9, 202610 phút3
99 Ngày Spring — Ngày 55: Testcontainers

Phụ thuộc thật trong Docker thay vì thứ gần giống, @ServiceConnection biến sáu dòng cấu hình thành một annotation, một container cho cả bộ test — và bức tranh khép lại năm ngày kiểm thử.

20 thg 9, 20268 phút9
99 Ngày Java — Ngày 55: Intermediate operations

Phân biệt map và flatMap bằng kiểu trả về, distinct im lặng không lọc gì nếu thiếu equals, sorted phá vỡ tính lười, peek không được đảm bảo chạy — và vì sao skip không phải cách phân trang dữ liệu từ database.

20 thg 9, 20268 phút6