Backend

99 Ngày Spring — Ngày 51: Unit test tầng service

SSite Admin
16 tháng 09, 2026 7 phút đọc 5 lượt xem
99 Ngày Spring — Ngày 51: Unit test tầng service

Mười ngày bảo mật khép lại, hôm nay mở giai đoạn nhẹ nhàng hơn nhiều nhưng có sức ảnh hưởng lâu dài hơn: kiểm thử. Và bài đầu tiên bắt đầu bằng một điều nhiều người không để ý: service của bạn là một class Java bình thường. Nó chỉ "thuộc về Spring" vì có annotation — nên để test nó, bạn không cần Spring, và nhờ vậy test chạy trong vài mili-giây thay vì vài giây.

Sketchnote Ngày 51: unit test tầng service — vì sao không dùng SpringBootTest, constructor injection cho phép new service lên, Mockito với Mock và InjectMocks, và bốn cái bẫy khi mock

Vì sao không nạp cả context Spring

@SpringBootTest nạp toàn bộ context: ba tới mười lăm giây mỗi lần chạy. Hai trăm test là ngồi chờ mười phút — và một bộ test chậm là một bộ test không ai chạy nữa. Thật ra bạn không cần nó: cứ new service lên rồi tự đưa vào những thứ nó phụ thuộc.

// UNIT TEST TẦNG SERVICE — và câu hỏi đầu tiên: VÌ SAO KHÔNG DÙNG @SpringBootTest?

@SpringBootTest                  // ❌ nạp TOÀN BỘ context: 3–15 giây MỖI lần chạy
class DonHangServiceTest { }     //    200 test = ngồi chờ 10 phút

class DonHangServiceTest { }     // ✅ chỉ là một class Java bình thường: ~5 mili-giây

// ⭐ Ý TƯỞNG CỐT LÕI CỦA CẢ BÀI:
//    Service của bạn là một class Java BÌNH THƯỜNG. Nó chỉ "thuộc về Spring"
//    vì có annotation. Muốn test nó, bạn KHÔNG cần Spring — bạn chỉ cần
//    new nó lên và tự đưa vào các thứ nó phụ thuộc.
//    ➜ Đây chính là phần thưởng của constructor injection (Ngày 04):
//      dùng field injection thì bạn KHÔNG new lên được, và buộc phải nạp context.

@Service
class DonHangService {
    private final DonHangRepository repo;
    private final DichVuEmail email;
    DonHangService(DonHangRepository repo, DichVuEmail email) {   // ⭐ constructor
        this.repo = repo; this.email = email;
    }
    ...
}

// Test thì chỉ đơn giản:
var service = new DonHangService(repoGia, emailGia);   // KHÔNG Spring, KHÔNG chờ đợi

// ❗ Nếu service của bạn KHÔNG new lên được vì có @Autowired trên field,
//    thì đó không phải vấn đề của test — đó là dấu hiệu thiết kế cần sửa.
  • Đây chính là phần thưởng của constructor injection ở Ngày 04 — dùng field injection thì bạn không new lên được và buộc phải nạp context.

  • Service không new lên được không phải vấn đề của test — đó là dấu hiệu thiết kế cần sửa.

  • Vài mili-giây so với vài giây nghe nhỏ, nhưng nhân với hai trăm test và mỗi ngày chạy chục lần thì nó quyết định bạn có thật sự chạy test hay không.

Mockito — diễn viên đóng thế

Service thường phụ thuộc repository, dịch vụ email, HTTP client. Trong unit test, ta thay chúng bằng đối tượng giả để mỗi test chỉ kiểm tra đúng một hành vi. Ba annotation là đủ: @Mock, @InjectMocks, và @ExtendWith(MockitoExtension.class).

// MOCKITO — tạo "diễn viên đóng thế" cho các thứ service phụ thuộc.

@ExtendWith(MockitoExtension.class)      // ⭐ của JUnit 5 — KHÔNG phải @RunWith (JUnit 4)
class DonHangServiceTest {

    @Mock  DonHangRepository repo;       // đối tượng giả, mọi method trả mặc định
    @Mock  DichVuEmail email;
    @InjectMocks DonHangService service; // Mockito tự new và nhét hai mock ở trên vào

    @Test
    void tao_don_thanh_cong_thi_gui_email() {
        // GIVEN — dựng tình huống
        var don = new DonHang(1L, "SP-01", 2);
        when(repo.save(any(DonHang.class))).thenReturn(don);

        // WHEN — chạy đúng MỘT hành vi đang được kiểm tra
        var kq = service.tao("SP-01", 2);

        // THEN — khẳng định kết quả VÀ tương tác
        assertThat(kq.id()).isEqualTo(1L);
        verify(email).gui(eq("SP-01"), anyString());     // email ĐÃ được gửi
        verify(repo, times(1)).save(any());              // lưu ĐÚNG một lần
    }

    @Test
    void het_hang_thi_nem_loi_va_KHONG_gui_email() {
        when(repo.timTonKho("SP-01")).thenReturn(0);

        assertThatThrownBy(() -> service.tao("SP-01", 2))
            .isInstanceOf(HetHangException.class)
            .hasMessageContaining("SP-01");

        verifyNoInteractions(email);      // ⭐ khẳng định điều KHÔNG xảy ra
    }                                     //    — thường quan trọng hơn điều đã xảy ra
}

// ❗ mock vs spy: mock thay TOÀN BỘ hành vi; spy giữ hành vi thật, chỉ ghi đè vài method.
//    Dùng spy nhiều là dấu hiệu class đang làm quá nhiều việc.
  • Cấu trúc GIVEN – WHEN – THEN giúp test đọc như một câu chuyện: dựng tình huống, chạy một hành vi, khẳng định kết quả.

  • verifyNoInteractions khẳng định điều không xảy ra — thường quan trọng hơn điều đã xảy ra, ví dụ "hết hàng thì tuyệt đối không gửi email".

  • Dùng @ExtendWith của JUnit 5, không phải @RunWith của JUnit 4 — nhầm cái này thì mock im lặng không được khởi tạo.

  • Dùng spy nhiều là dấu hiệu class đang làm quá nhiều việc, nên nghĩ tới việc tách nó ra.

Bốn cái bẫy và một câu hỏi

Test dễ viết, nhưng test có giá trị thì khó. Bốn cái bẫy dưới đây đều tạo ra những test luôn xanh mà không bảo vệ gì cả — và cách phát hiện chúng chỉ gói trong một câu hỏi: nếu tôi làm hỏng logic này, test có đỏ không?

// BỐN CÁI BẪY — và một câu hỏi nên tự hỏi trước mỗi test.

// ① MOCK CHÍNH THỨ MÌNH ĐANG TEST — test luôn xanh, và vô nghĩa.
when(service.tinhTong(any())).thenReturn(100);   // ❌ bạn đang test cái mock, không phải code

// ② MOCK QUÁ SÂU — test biết quá nhiều về BÊN TRONG.
when(repo.query().where().orderBy().fetch()).thenReturn(ds);   // ❌ đổi cách viết query là test vỡ
// Test tốt bám vào HÀNH VI (kết quả, tương tác), không bám vào cách hiện thực.

// ③ MỘT TEST KIỂM TRA NĂM THỨ — hỏng là không biết hỏng cái nào.
//    Một test = một hành vi. Tên test nên đọc như một câu tiếng Việt:
void het_hang_thi_nem_loi_va_khong_gui_email()   // ✅ đọc là biết nó bảo vệ điều gì
void test1()                                     // ❌ hỏng lúc 2 giờ sáng thì chịu

// ④ @Mock CHO THỨ KHÔNG CẦN MOCK:
@Mock BoChuyenDoi converter;        // ❌ nếu nó thuần túy tính toán, KHÔNG I/O
BoChuyenDoi converter = new BoChuyenDoi();   // ✅ dùng đồ thật, test thật hơn
// ⭐ QUY TẮC: chỉ mock những gì CHẬM hoặc KHÔNG ĐOÁN TRƯỚC ĐƯỢC —
//    database, HTTP, email, đồng hồ, số ngẫu nhiên. Còn lại dùng đồ thật.

// ❗ THỜI GIAN cũng phải mock, nếu không test sẽ hỏng vào một ngày đẹp trời:
class DonHangService {
    private final Clock clock;                    // ⭐ tiêm Clock vào
    boolean quaHan(DonHang d) {
        return d.hetHan().isBefore(Instant.now(clock));
    }
}
var clock = Clock.fixed(Instant.parse("2026-09-16T00:00:00Z"), ZoneOffset.UTC);
// Không có nó thì test "đơn quá hạn" sẽ đúng hôm nay và sai vào tháng sau.

// ➜ CÂU HỎI TỰ HỎI: "Nếu tôi làm hỏng logic này, test có đỏ không?"
//    Không → test đó không bảo vệ gì cả, chỉ tô đẹp con số coverage.
  • Chỉ mock những gì chậm hoặc không đoán trước được — database, HTTP, email, đồng hồ, số ngẫu nhiên; còn lại dùng đồ thật cho test có ý nghĩa.

  • Tiêm Clock thay vì gọi Instant.now() — nếu không thì test "đơn quá hạn" sẽ đúng hôm nay và sai vào tháng sau.

  • Tên test nên đọc như một câu: het_hang_thi_nem_loi_va_khong_gui_email nói rõ nó bảo vệ điều gì; test1 thì không.

  • Mock quá sâu là buộc test vào cách hiện thực — đổi cách viết truy vấn là test vỡ dù hành vi không đổi.

Bài tập nhỏ

  • Viết một unit test cho service sẵn có mà không dùng @SpringBootTest, rồi đo thời gian chạy.

  • Đổi service sang field injection và thử new nó lên — thấy vì sao constructor injection quan trọng.

  • Viết một test khẳng định email không được gửi khi hết hàng, bằng verifyNoInteractions.

  • Cố tình làm hỏng một dòng logic và xem test của bạn có đỏ không — nếu không, test đó vô dụng.

  • Tiêm Clock.fixed vào service rồi test một quy tắc phụ thuộc thời gian.

Kết lại

Bốn ý gói lại hôm nay: 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, và một bộ test nhanh là bộ test người ta thật sự chạy; constructor injection của Ngày 04 chính là thứ khiến điều đó khả thi; verifyNoInteractions khẳng định điều không xảy ra, thường quan trọng hơn điều đã xảy ra; và chỉ mock những gì chậm hoặc không đoán trước được, với câu hỏi kiểm chứng duy nhất: nếu tôi làm hỏng logic này, test có đỏ không? Ngày 52 lên một tầng: test tầng web với @WebMvcTest và MockMvc — gọi request thật mà không cần dựng server. 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 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
99 Ngày Spring — Ngày 50: Security best practices

CSRF chỉ phụ thuộc một câu hỏi — trình duyệt có tự gửi thông tin đăng nhập không, CORS bảo vệ người dùng chứ không bảo vệ server, secret lỡ commit thì phải đổi — và rate limiting, thứ Spring Security không có sẵn.

15 thg 9, 20268 phút6
99 Ngày Java — Ngày 50: Checkpoint — xử lý file dữ liệu

Mốc nửa chặng: gộp mười bài vừa học thành một chương trình đọc CSV, thống kê bằng collections và xuất báo cáo — cùng quyết định phân biệt code chạy được với code dùng được: bỏ dòng hỏng hay dừng cả file.

15 thg 9, 20269 phút7