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.

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
newlên được và buộc phải nạp context.Service không
newlê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ả.
verifyNoInteractionskhẳ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
@ExtendWithcủa JUnit 5, không phải@RunWithcủ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
Clockthay vì gọiInstant.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_emailnói rõ nó bảo vệ điều gì;test1thì 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ử
newnó 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.fixedvà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!
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.


