Mockito dễ dùng tới mức nguy hiểm. Vài dòng là có một đối tượng giả, và cảm giác "test kỹ" đến rất nhanh.
Bài này về ranh giới giữa test bảo vệ bạn và test cản đường bạn — và tôi đo được ranh giới đó bằng một phép thử đơn giản.
Phép thử
Một dịch vụ nhỏ: lấy giá trị từ kho, không có thì tạo mới.
public String layHoacTao(String ma){
if (kho.ton(ma)) {
return kho.tim(ma).orElseThrow();
}
String moi = "gia-tri-" + ma;
kho.luu(ma, moi);
return moi;
}
Tôi viết hai test cho nó. Cả hai đều xanh.
Test A — kiểm hành vi:
when(kho.ton("x")).thenReturn(false);
assertEquals("gia-tri-x", dv.layHoacTao("x"));
verify(kho).luu("x", "gia-tri-x");
Test B — kiểm quá tay:
when(kho.ton("x")).thenReturn(false);
assertEquals("gia-tri-x", dv.layHoacTao("x"));
InOrder thuTu = inOrder(kho);
thuTu.verify(kho, times(1)).ton("x");
thuTu.verify(kho, times(1)).luu("x", "gia-tri-x");
verify(kho, never()).tim(anyString());
verifyNoMoreInteractions(kho);
Test B trông "kỹ hơn" — nó kiểm thứ tự, số lần gọi, và không cho gọi thừa.
Giờ tôi tái cấu trúc phần cài đặt. Cùng hành vi, cùng giá trị trả về, cùng tác dụng phụ — chỉ bỏ một lần đi mạng thừa:
public String layHoacTao(String ma){
Optional<String> co = kho.tim(ma); // một lần gọi thay vì hai
if (co.isPresent()) return co.get();
String moi = "gia-tri-" + ma;
kho.luu(ma, moi);
return moi;
}
Kết quả:
Tests run: 2, Failures: 1
GionTest.kiemQuaTay <<< FAILURE!
Wanted but not invoked:
kho.ton("x");
However, there were exactly 2 interactions with this mock:
kho.tim("x");
kho.luu("x", "gia-tri-x");
Test A xanh. Test B đỏ.
Chương trình không hề sai. Nó trả về đúng giá trị cũ, vẫn lưu đúng thứ cần lưu, và còn nhanh hơn. Nhưng test B nói nó hỏng.
Điều tệ hơn là nó xói mòn lòng tin. Khi test hay đỏ vì lý do vô hại, người ta bắt đầu sửa test cho qua thay vì đọc xem nó đang nói gì. Và đúng lúc đó, một test đỏ thật sẽ bị bỏ qua.
Ba câu hỏi trước khi gõ verify
Từ phép thử trên, tôi rút ra ba câu:
Đây có phải tác dụng phụ mà người gọi trông đợi không? luu là có — nếu không lưu thì chức năng hỏng. ton thì không — nó chỉ là cách cài đặt.
Đã có assertEquals nào kiểm điều này chưa? Nếu kết quả trả về đã chứng minh logic đúng, verify thêm chỉ là trùng lặp và thêm chỗ gãy.
Thứ tự có thật sự quan trọng không? inOrder chỉ đúng khi thứ tự là một phần của hợp đồng — mở giao dịch trước khi ghi chẳng hạn. Với hầu hết trường hợp, nó là chi tiết cài đặt.
Và verifyNoMoreInteractions gần như luôn là quá tay. Nó biến mọi lời gọi mới thành lỗi test, kể cả việc thêm một dòng ghi log.
Quy tắc gọn: verify cho lệnh, assert cho truy vấn. Phương thức thay đổi trạng thái bên ngoài thì verify; phương thức trả về giá trị thì kiểm chính giá trị đó.
Mock trả gì khi chưa khai báo
tim() chưa khai -> Optional.empty
ton() chưa khai -> false
Mockito trả về giá trị rỗng theo kiểu, không phải null: false cho boolean, 0 cho số, Optional.empty() cho Optional, danh sách rỗng cho List, và null cho các đối tượng khác.
Điều này tiện nhưng cũng là bẫy: một mock chưa khai báo gì vẫn "chạy được", nên test có thể xanh trong khi bạn quên mất một nhánh. Đó là lý do nên bật chế độ nghiêm ngặt.
Chế độ nghiêm ngặt bắt lỗi hộ bạn
when(kho.tim("x")).thenReturn(Optional.of("a")); // khai nhưng không dùng
UnnecessaryStubbingException:
Unnecessary stubbings detected.
Following stubbings are unnecessary:
1. -> at ThuaTest.khaiThua(ThuaTest.java:13)
MockitoExtension mặc định bật chế độ nghiêm ngặt, và nó làm test hỏng khi có khai báo thừa.
Nghe phiền nhưng rất đáng: khai báo thừa gần như luôn nghĩa là một trong hai chuyện — test đang chuẩn bị cho một nhánh không bao giờ chạy, hoặc mã đã đổi mà test chưa dọn. Cả hai đều đáng biết.
Tắt bằng @MockitoSettings(strictness = Strictness.LENIENT) khi thật sự cần, nhưng đừng tắt theo thói quen.
ArgumentCaptor: kiểm cái đã được truyền đi
var ma = ArgumentCaptor.forClass(String.class);
var gt = ArgumentCaptor.forClass(String.class);
verify(m).luu(ma.capture(), gt.capture());
đã lưu: ma='abc' gia_tri='gia-tri-abc'
Hữu ích khi đối số là đối tượng phức tạp mà bạn chỉ muốn kiểm vài trường, hoặc khi giá trị được tính bên trong nên bạn không viết sẵn được vào verify.
Với đối số đơn giản thì verify(m).luu("abc", "gia-tri-abc") gọn hơn — đừng dùng captor khi không cần.
spy: vật thật có ghi hình
List<String> theo = spy(new ArrayList<>());
theo.add("a"); theo.add("b");
spy vẫn hoạt động thật: size=2 nội dung=[a, b]
sau khi ghi đè size(): 99 nhưng nội dung vẫn [a, b]
Khác mock ở chỗ: mọi phương thức chạy thật, trừ những cái bạn ghi đè.
Chú ý dòng cuối — sau khi ghi đè size() thành 99, danh sách vẫn có 2 phần tử. Bạn vừa tạo ra một đối tượng tự mâu thuẫn, và đó là rủi ro chính của spy.
Cú pháp cũng khác: phải dùng doReturn(99).when(theo).size() chứ không phải when(theo.size()) — vì cách thứ hai sẽ gọi thật size() trước khi Mockito kịp can thiệp.
Tôi dùng spy rất hiếm, và gần như luôn khi làm việc với mã cũ không tách được phụ thuộc. Với mã mới, cần spy thường là dấu hiệu lớp đang làm quá nhiều việc.
Khi nào đừng mock
Đối tượng giá trị. record, String, LocalDate, DTO — cứ tạo cái thật. Mock chúng vừa dài dòng vừa che mất lỗi trong chính logic của chúng.
Cấu trúc dữ liệu. List, Map, Optional — dùng bản thật. new ArrayList<>() luôn tốt hơn mock(List.class).
Thứ bạn không sở hữu. Mock một driver JDBC hay một SDK bên thứ ba nghĩa là bạn đang mã hoá giả định của mình về cách nó hoạt động. Giả định sai thì test vẫn xanh còn sản xuất thì hỏng. Với những thứ này, Testcontainers hoặc một máy chủ giả lập cho kết quả đáng tin hơn nhiều — và đó là chủ đề của bài mai.
Chính lớp đang test. Nếu phải spy lớp đang kiểm để giả một phương thức của nó, hãy tách phương thức đó ra thành một phụ thuộc riêng.
Chỗ mock thật sự đúng: ranh giới ra ngoài hệ thống — mạng, hệ thống tệp, đồng hồ, sinh số ngẫu nhiên, gửi email. Những thứ chậm, không xác định, hoặc có tác dụng phụ thật.
Một cách nghĩ khác
Với những phụ thuộc đơn giản, tôi thường thấy viết một bản giả bằng tay rõ hơn Mockito:
class KhoTrongBoNho implements Kho {
private final Map<String,String> du = new HashMap<>();
public Optional<String> tim(String ma){ return Optional.ofNullable(du.get(ma)); }
public void luu(String ma, String gt){ du.put(ma, gt); }
public boolean ton(String ma){ return du.containsKey(ma); }
}
Mười dòng, dùng lại được cho mọi test, và nó có hành vi thật: lưu rồi tìm thì thấy. Test viết bằng nó đọc như mô tả nghiệp vụ, và quan trọng nhất — nó không quan tâm bạn gọi ton hay tim, nên phép tái cấu trúc ở đầu bài sẽ không làm nó gãy.
Đây là lựa chọn mặc định của tôi khi phụ thuộc có ít phương thức và ngữ nghĩa rõ ràng.
Thử ba mươi giây
Chọn một lớp trong dự án của bạn, tái cấu trúc phần bên trong mà không đổi hành vi công khai — gộp hai lời gọi thành một, đổi thứ tự hai bước độc lập.
Chạy test. Cái nào đỏ chính là cái đang khoá bạn vào cách cài đặt cũ, và danh sách đó cho bạn biết bộ test của mình đang bảo vệ hành vi hay đang bảo vệ mã.
Ngày mai: Testcontainers — chạy test với cơ sở dữ liệu thật trong Docker, thay vì mock một thứ bạn không kiểm soát.