Một unit test muốn chạy đúng một class ở trạng thái cô lập. Class đó thường có collaborator — một repository nói chuyện với database, một mail sender mở socket, một clock mà mỗi giây trả về một giá trị khác. Có mấy thứ đó trong khung hình thì không assert được cái gì ổn định, và một test cần database đang chạy thì không còn là unit test nữa.
Test double là bất kỳ object nào bạn đặt vào chỗ của collaborator để test kiểm soát được nó. Mockito là thư viện mà phần lớn project Java dùng để tạo ra double, nhưng phần quan trọng không nằm ở thư viện. Nó nằm ở chỗ biết mình cần loại double nào, mock thực sự ghi lại cái gì, và khi nào thì cách làm này bắt đầu sinh ra những test xanh trong khi code đang sai.
![]()
Mọi thứ bên dưới — mọi output, mọi exception, mọi báo cáo lỗi — đều được compile và chạy thật trên OpenJDK 21.0.6 (arm64) với Mockito 5.14.2, JUnit Jupiter 5.11.3 trên JUnit Platform 1.11.3, Byte Buddy 1.15.4 và Objenesis 3.3 trên classpath.
Vì sao cần đến test double
Đây là code được test cho cả bài. Một service, hai collaborator nằm sau interface, và một record.
public record User(long id, String email, boolean active) {}
public interface UserRepository {
User findById(long id);
void save(User user);
}
public interface EmailSender {
boolean send(String to, String subject);
}
public class AccountService {
private final UserRepository repo;
private final EmailSender email;
public AccountService(UserRepository repo, EmailSender email) {
this.repo = repo;
this.email = email;
}
public String deactivate(long id) {
User u = repo.findById(id);
if (u == null) return "not found";
repo.save(new User(u.id(), u.email(), false));
email.send(u.email(), "Account deactivated");
return "ok";
}
}AccountService không hề nhắc tới database hay SMTP server. Nó phụ thuộc vào hai interface, và đó chính là seam: bất cứ thứ gì implement UserRepository đều đưa vào constructor được. Trên production đó là một implementation JDBC; trong test thì là bất cứ thứ gì bạn muốn.

Điều đầu tiên cần thấy: bạn không cần thư viện nào để tận dụng cái seam đó. Một double chỉ là một class.
@Test
void aFakeIsJustAClass() {
UserRepository fake = new UserRepository() {
public User findById(long id) { return new User(id, "ann@example.com", true); }
public void save(User user) { }
};
EmailSender fakeSender = (to, subject) -> true;
assertEquals("ok", new AccountService(fake, fakeSender).deactivate(1L));
}Test đó pass, không cần dependency nào ngoài JUnit. EmailSender chỉ có một abstract method nên double của nó gọn trong một lambda. Mockito tồn tại vì viết tay như vậy hết dễ chịu ngay khi interface có mười lăm method, khi bạn muốn mỗi argument một câu trả lời khác nhau, và khi bạn muốn assert rằng một method đã được gọi. Nó là tiện ích chứ không phải điều kiện bắt buộc — và nghĩ tới fake viết tay trước thường là phản xạ tốt hơn, nhất là với một in-memory repository dùng chung cho nhiều test.
Cũng test đó, viết bằng Mockito:
@Test
void theSameTestWithMockito() {
UserRepository repo = mock(UserRepository.class);
EmailSender email = mock(EmailSender.class);
when(repo.findById(1L)).thenReturn(new User(1L, "ann@example.com", true));
assertEquals("ok", new AccountService(repo, email).deactivate(1L));
}Dummy, stub, spy, mock, fake — chúng không đồng nghĩa
Gần như ai cũng gọi cả năm thứ là "mock". Cách phân biệt đến từ Gerard Meszaros và đáng để biết, vì nó mô tả một double dùng để làm gì:
| Double | Nó làm gì | Dùng khi nào |
|---|---|---|
| Dummy | Được truyền qua truyền lại và không bao giờ dùng tới | Điền vào một parameter bạn không quan tâm |
| Stub | Trả về câu trả lời đã soạn sẵn | Cấp input cho unit đang được test |
| Spy | Object thật, đồng thời ghi lại chuyện gì xảy ra với nó | Giữ hành vi thật, chỉ thay một method |
| Mock | Stub mà các lời gọi lên nó cũng là một phần của assertion | Assert rằng một side effect đã xảy ra |
| Fake | Implementation chạy được nhưng đơn giản hoá | Repository trong bộ nhớ, clock trong bộ nhớ |
Mockito dựng tất cả từ một lời gọi mock(). Một object tạo bằng mock() là stub khi bạn gọi when(...) lên nó và là mock khi bạn verify(...) nó — khác nhau ở chỗ bạn làm gì, không phải ở chỗ bạn tạo ra cái gì. spy() là thứ duy nhất có factory method riêng, còn Mockito.mock(Foo.class) truyền vào rồi không bao giờ đụng tới chính là dummy.
Một mock trả về gì khi bạn chưa stub
Mọi method trên một mock vừa tạo đều đã có sẵn câu trả lời. Đây là nguồn gây bối rối lớn nhất khi test đỏ, nên tốt nhất là nhìn thẳng vào nó thay vì đoán:
interface Probe {
String text();
int count();
long total();
double rate();
boolean enabled();
List<String> items();
Map<String, Integer> index();
Optional<String> maybe();
User user();
int[] array();
}
@Test
void whatAnUnstubbedMockReturns() {
Probe p = mock(Probe.class);
System.out.println("text() -> " + p.text());
System.out.println("count() -> " + p.count());
System.out.println("total() -> " + p.total());
System.out.println("rate() -> " + p.rate());
System.out.println("enabled() -> " + p.enabled());
System.out.println("items() -> " + p.items() + " size=" + p.items().size());
System.out.println("index() -> " + p.index());
System.out.println("maybe() -> " + p.maybe());
System.out.println("user() -> " + p.user());
System.out.println("array() -> " + Arrays.toString(p.array()));
}text() -> null
count() -> 0
total() -> 0
rate() -> 0.0
enabled() -> false
items() -> [] size=0
index() -> {}
maybe() -> Optional.empty
user() -> null
array() -> nullTóm lại: primitive nhận giá trị zero của nó, String và mọi reference type khác nhận null, còn những return type dạng collection nhận một instance rỗng chứ không phải null. Optional trả về Optional.empty — không phải null, và đây là chỗ nhiều người nhớ sai nhất. Array thì ngược lại: int[] trả về null chứ không phải array rỗng.
Cái null mới là thứ cắn người. Lấy đúng service đó nhưng bỏ đi phần kiểm tra null:
static class NaiveAccountService {
private final UserRepository repo;
NaiveAccountService(UserRepository repo) { this.repo = repo; }
String emailOf(long id) { return repo.findById(id).email().toLowerCase(); }
}
@Test
void forgettingToStubGivesYouAnNpe() {
UserRepository repo = mock(UserRepository.class);
NullPointerException e = assertThrows(NullPointerException.class,
() -> new NaiveAccountService(repo).emailOf(1L));
System.out.println("message: " + e.getMessage());
}message: Cannot invoke "User.email()" because the return value of "UserRepository.findById(long)" is nullService không hỏng chỗ nào. Test chỉ đơn giản là quên stub findById, và mock làm đúng thứ nó luôn làm. Helpful NullPointerException, bật mặc định từ Java 15, chỉ đích danh lời gọi nào trả về null — đọc nó trước khi kết luận code production sai.
Stubbing: lập trình sẵn câu trả lời
when(call).thenReturn(value) ghi lại một câu trả lời cho một mẫu lời gọi. Lời gọi bên trong when(...) là một invocation thật lên mock; Mockito chặn nó lại và coi đó là mẫu chứ không phải là một lời gọi cần ghi nhận.
@Test
void basicStubbing() {
UserRepository repo = mock(UserRepository.class);
when(repo.findById(1L)).thenReturn(new User(1L, "ann@example.com", true));
assertEquals("ann@example.com", repo.findById(1L).email());
assertNull(repo.findById(2L), "an argument you did not stub falls back to the default");
}Một argument bạn chưa stub không phải là lỗi — nó rơi về giá trị mặc định ở phần trên. thenThrow lo nhánh thất bại, còn nhiều lời gọi liên tiếp trên cùng một stub sẽ nối các câu trả lời theo thứ tự, câu cuối cùng lặp lại mãi mãi:
when(repo.findById(1L))
.thenThrow(new IllegalStateException("timeout"))
.thenReturn(new User(1L, "ann@example.com", true));call 1 -> java.lang.IllegalStateException: timeout
call 2 -> User[id=1, email=ann@example.com, active=true]
call 3 -> User[id=1, email=ann@example.com, active=true]Một method void thì không bọc vào when(...) được, vì when cần một giá trị trả về để gắn vào. Dùng họ do*, đặt mock lên trước:
doThrow(new IllegalArgumentException("read-only")).when(repo).save(any());doThrow -> java.lang.IllegalArgumentException: read-onlyHai luật dễ bỏ sót. Stub lại cùng một lời gọi sẽ thay thế câu trả lời cũ, và khi hai mẫu khác nhau cùng khớp một lời gọi thì mẫu khai báo sau thắng. Ngoài ra thenReturn chỉ tính giá trị của nó đúng một lần, ngay lúc stub — muốn mỗi lời gọi một giá trị mới thì dùng thenAnswer.
Argument matcher, và cái luật ai cũng phạm một lần
Một giá trị thô trong stub nghĩa là "đúng argument này". Matcher nới nó ra:
EmailSender email = mock(EmailSender.class);
when(email.send(anyString(), eq("Welcome"))).thenReturn(true);
when(email.send(argThat(to -> to != null && to.endsWith("@blocked.test")), anyString()))
.thenReturn(false);ann@example.com / Welcome -> true
ann@example.com / Receipt -> false
bob@blocked.test / Welcome -> falseDòng thứ ba chính là luật khai-báo-sau-thắng: cả hai stub đều khớp, và cái sau trả lời. Dòng thứ hai không khớp stub nào cả nên rơi về giá trị mặc định của boolean.
| Matcher | Khớp với |
|---|---|
any() | Mọi giá trị, kể cả null |
anyString(), anyInt(), anyLong() | Mọi giá trị khác null của type đó |
eq(v) | Giá trị bằng v, dùng để chen một literal vào giữa các matcher |
argThat(pred) | Bất cứ thứ gì predicate chấp nhận |
isNull(), isNotNull() | Đúng như tên gọi |
same(v) | Cùng một reference, không chỉ là bằng nhau |
Giờ tới cái luật. Matcher không phải là giá trị — mỗi lời gọi any() đẩy một entry vào một stack thread-local, và Mockito lấy ra một entry cho mỗi argument khi lời gọi được stub trả về. Chen một literal vào giữa là số lượng không khớp:
when(email.send("ann@example.com", anyString())).thenReturn(true);org.mockito.exceptions.misusing.InvalidUseOfMatchersException
Invalid use of argument matchers!
2 matchers expected, 1 recorded:
-> at MatchersTest.mixingRawValuesAndMatchersFails(MatchersTest.java:24)
This exception may occur if matchers are combined with raw values:
//incorrect:
someMethod(any(), "raw String");
When using matchers, all arguments have to be provided by matchers.
For example:
//correct:
someMethod(any(), eq("String by matcher"));
For more info see javadoc for Matchers class.Cách sửa nằm ngay trong thông báo: bọc literal vào eq(...). Hoặc toàn matcher, hoặc không matcher nào, không có kiểu pha trộn. Stack đó là thread-local, cũng là lý do một matcher bị bỏ quên ở test này có thể nổ thành một lỗi kỳ quặc ở test kế tiếp.
Verification: kiểm tra những lời gọi đi ra
Stubbing kiểm soát thứ đi vào unit đang được test. Verification assert lên thứ đi ra khỏi nó — những lời gọi mà nó thực hiện lên collaborator. Đó là hai nửa độc lập của cùng một mock, và phân biệt rạch ròi hai nửa này là phần lớn cái làm nên một test dễ đọc.

@Test
void verifyTheCallsThatMatter() {
UserRepository repo = mock(UserRepository.class);
EmailSender email = mock(EmailSender.class);
when(repo.findById(1L)).thenReturn(new User(1L, "ann@example.com", true));
new AccountService(repo, email).deactivate(1L);
verify(repo).findById(1L);
verify(repo, times(1)).save(any(User.class));
verify(email).send("ann@example.com", "Account deactivated");
verifyNoMoreInteractions(repo, email);
}verify(mock) không kèm mode nghĩa là times(1). Các mode còn lại là never(), times(n), atLeastOnce(), atLeast(n), atMost(n) và only(). Verify rằng một thứ không xảy ra thường mới là assertion có giá trị:
String result = new AccountService(repo, email).deactivate(404L);
assertEquals("not found", result);
verify(repo, never()).save(any());
verify(email, never()).send(anyString(), anyString());
verify(repo, atLeastOnce()).findById(anyLong());Verification thất bại là một AssertionError, và báo cáo cho bạn biết nó muốn gì và nhận được gì:
org.mockito.exceptions.verification.TooFewActualInvocations
emailSender.send(<any string>, <any string>);
Wanted 2 times:
-> at VerifyTest.whatAFailedVerifyLooksLike(VerifyTest.java:74)
But was 1 time:
-> at AccountService.deactivate(AccountService.java:14)verifyNoMoreInteractions assert rằng mọi interaction lên mock đều đã được verify. Khi nó thất bại, nó in ra toàn bộ log lời gọi và đánh dấu những entry chưa verify bằng dấu hỏi:
No interactions wanted here:
-> at VerifyTest.whatVerifyNoMoreInteractionsLooksLikeWhenItFails(VerifyTest.java:93)
But found this interaction on mock 'userRepository':
-> at AccountService.deactivate(AccountService.java:13)
***
For your reference, here is the list of all invocations ([?] - means unverified).
1. -> at AccountService.deactivate(AccountService.java:11)
2. [?]-> at AccountService.deactivate(AccountService.java:13)Đó vừa là một công cụ chẩn đoán hữu ích vừa là một assertion nguy hiểm — phần sau về bẫy sẽ nói vì sao.
InOrder verify thứ tự tương đối, trên một hoặc nhiều mock:
InOrder inOrder = inOrder(repo, email);
inOrder.verify(repo).save(any(User.class));
inOrder.verify(email).send(anyString(), anyString());Còn ArgumentCaptor trả lời câu hỏi mà verify không trả lời được: không phải "có được gọi không" mà là "được gọi với cái gì". Đây chính là thứ biến một interaction test trở lại thành assertion thật sự.
ArgumentCaptor<User> saved = ArgumentCaptor.forClass(User.class);
verify(repo).save(saved.capture());
System.out.println("captured: " + saved.getValue());
assertFalse(saved.getValue().active());captured: User[id=1, email=ann@example.com, active=false]getValue() trả về argument bắt được sau cùng; getAllValues() trả về tất cả, theo thứ tự lời gọi. Hãy capture ở giai đoạn verification, đừng capture lúc stubbing: bản thân captor là một matcher, nên when(repo.findById(captor.capture())) sẽ nới stub ra khớp mọi argument rồi nhét vào captor những giá trị nhìn thấy trong lúc stub thay vì những giá trị bạn định assert.
@Mock, @InjectMocks và MockitoExtension
MockitoExtension tạo các mock có annotation trước mỗi test và kiểm tra cách dùng sau đó. Nó nằm trong artifact riêng mockito-junit-jupiter.
@ExtendWith(MockitoExtension.class)
class AnnotationsTest {
@Mock UserRepository repo;
@Mock EmailSender email;
@InjectMocks AccountService service;
@Test
void annotationsWireTheServiceForYou() {
when(repo.findById(1L)).thenReturn(new User(1L, "ann@example.com", true));
assertEquals("ok", service.deactivate(1L));
verify(email).send("ann@example.com", "Account deactivated");
}
}Extension này cũng bật strict stubs, làm đỏ những test khai báo stub mà không ai gọi. Nghe có vẻ khắt khe cho tới khi bạn nhận ra một stub không dùng tới thường có nghĩa là test không đi qua nhánh mà tác giả tưởng nó đi qua:
org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
Following stubbings are unnecessary (click to navigate to relevant line of code):
1. -> at StrictStubsTest.aStubNobodyCalls(StrictStubsTest.java:14)
Please remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class.@InjectMocks mới là chỗ cần cẩn thận. Nó chọn constructor lớn nhất mà nó thoả mãn được rồi điền những gì có; parameter nào không có mock tương ứng thì nhận null, im lặng. Thêm một collaborator thứ ba vào constructor mà quên thêm @Mock tương ứng thì lúc wiring không có ai lên tiếng:
public AccountServiceV2(UserRepository repo, EmailSender email, AuditLog audit) { ... }@Mock UserRepository repo;
@Mock EmailSender email; // no @Mock AuditLog
@InjectMocks AccountServiceV2 service;NPE from @InjectMocks gap: Cannot invoke "AuditLog.record(String)" because "this.audit" is nullField vẫn được tạo, test vẫn chạy, và lỗi nổ ra sâu bên trong service dưới dạng NullPointerException mà không có gì chỉ về nguyên nhân thật. Tự gọi constructor tốn đúng một dòng và không thể hỏng theo kiểu này:
@Mock UserRepository repo;
@Mock EmailSender email;
@Mock AuditLog audit;
private AccountServiceV2 service;
@BeforeEach
void setUp() { service = new AccountServiceV2(repo, email, audit); }Thiếu một collaborator giờ là compile error. Đó là toàn bộ lý lẽ cho việc tự inject qua constructor.
Spy: object thật với vài method bị thay
spy(obj) bọc quanh một instance thật. Method chưa stub thì chạy code thật; method đã stub thì không.
static class RateTable {
private final Map<String, Double> rates = new HashMap<>(Map.of("EUR", 1.08));
double rate(String currency) {
System.out.println(" [real rate() ran for " + currency + "]");
Double r = rates.get(currency);
if (r == null) throw new NoSuchElementException("no rate for " + currency);
return r;
}
double convert(double amount, String currency) { return amount * rate(currency); }
}RateTable spy = spy(new RateTable());
assertEquals(108.0, spy.convert(100, "EUR"), 1e-9); // real code, real answerGiờ tới cái bẫy. when(spy.rate("JPY")) buộc phải tính spy.rate("JPY") trước khi when kịp nhìn thấy gì — và trên một spy, phép tính đó chạy method thật:
RateTable spy = spy(new RateTable());
when(spy.rate("JPY")).thenReturn(0.0065);--- when(spy.rate("JPY")) ---
[real rate() ran for JPY]
threw: java.util.NoSuchElementException: no rate for JPYMethod thật đã chạy và đã ném exception trước khi việc stub kịp diễn ra. Trên một mock thường thì chuyện này không bao giờ lộ ra, vì không có method thật nào để chạy. Trên spy thì lần nào cũng vậy, và đó là lý do họ do* tồn tại — chúng đặt mock lên trước và không bao giờ tính lời gọi:
RateTable spy = spy(new RateTable());
doReturn(0.0065).when(spy).rate("JPY");
assertEquals(65.0, spy.convert(10000, "JPY"), 1e-9);--- doReturn().when(spy).rate("JPY") ---
convert -> 65.0Không có dòng nào của method thật trong output: rate("JPY") chưa từng thực sự được gọi. Hãy dùng doReturn().when(spy).method() trên spy như một thói quen, không phải như một phương án dự phòng. Điều đó đúng với mọi method mà việc chạy thật sẽ chậm, phá dữ liệu hoặc ném exception.
⚠️ Spy bọc quanh một bản sao của object bạn truyền vào. Mockito tạo instance mới rồi copy các field sang, nên thay đổi qua spy không nhìn thấy được trên reference gốc, và stubbing dựa vào identity sẽ làm bạn bất ngờ.
Mock final class và static method
Đây là chỗ câu trả lời phụ thuộc vào version, nên hãy kiểm tra thay vì nhớ. Mockito chọn một MockMaker lúc khởi động, và bạn in ra được nó chọn cái nào:
MockMaker maker = org.mockito.internal.configuration.plugins.Plugins.getMockMaker();
System.out.println("mock maker: " + maker.getClass().getName());Trên Mockito 5.14.2, không cấu hình gì:
mock maker: org.mockito.internal.creation.bytebuddy.InlineByteBuddyMockMakerInline mock maker là mặc định của Mockito 5. Nó instrument các class đã load qua instrumentation API của JVM thay vì sinh subclass, nên final class, final method và static method đều mock được ngay:
static final class Tokenizer {
String tokenize(String card) { return "real-" + card; }
}
static class Ids {
static String next() { return "real-id"; }
}Tokenizer t = mock(Tokenizer.class);
when(t.tokenize(anyString())).thenReturn("tok_123");
System.out.println("final class mock -> " + t.tokenize("4111"));
System.out.println("before scope: " + Ids.next());
try (MockedStatic<Ids> ids = mockStatic(Ids.class)) {
ids.when(Ids::next).thenReturn("id_42");
System.out.println("inside scope: " + Ids.next());
}
System.out.println("after scope: " + Ids.next());final class mock -> tok_123
before scope: real-id
inside scope: id_42
after scope: real-idmockStatic trả về một resource có scope và bắt buộc phải đóng. Bản thay thế được đăng ký theo từng thread, nên bỏ qua try-with-resources là nó vẫn còn sống, và test kế tiếp mock cùng class đó trong cùng thread sẽ đỏ với thông báo nói thẳng ra điều này:
org.mockito.exceptions.base.MockitoException
For FinalAndStaticTest$Ids, static mocking is already registered in the current thread
To create a new mock, the existing static mock registration must be deregisteredTrên Mockito 2, 3 và 4, mặc định là subclass mock maker, thứ không làm được gì trong số trên. Cấu hình đó tới giờ vẫn chọn được, bằng cách đặt một file tên mockito-extensions/org.mockito.plugins.MockMaker chứa mock-maker-subclass lên classpath, và đáng để xem nó báo gì, vì đây chính là thông báo lỗi mà hàng nghìn câu trả lời trên Stack Overflow đang nói tới:
mock maker: org.mockito.internal.creation.bytebuddy.ByteBuddyMockMaker
Cannot mock/spy class SubclassMakerProbe$Tokenizer
Mockito cannot mock/spy because :
- final class
The used MockMaker SubclassByteBuddyMockMaker does not support the creation of static mocks
Mockito's inline mock maker supports static mocks based on the Instrumentation API.
You can simply enable this mock mode, by placing the 'mockito-inline' artifact where you are currently using 'mockito-core'.
Note that Mockito's inline mock maker is not supported on Android.Nếu gặp thông báo đó trên một project hiện đại thì nguyên nhân gần như luôn là Mockito cũ chứ không phải bạn làm sai. Trên Mockito 5, artifact mockito-inline riêng mà thông báo nhắc tới không còn cần nữa.
Có một điểm gợn cần nói thật về inline mock maker trên JDK đời mới. Nó cần một Java agent, và mặc định nó tự attach agent vào chính JVM của mình lúc runtime, điều mà JDK giờ cảnh báo:
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build what is described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org/mockito/Mockito.html#0.3
WARNING: A Java agent has been loaded dynamically (byte-buddy-agent-1.15.4.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: Dynamic loading of agents will be disallowed by default in a future releaseKhông có gì hỏng — test vẫn xanh — nhưng cơ chế tự attach đang trên đường bị loại bỏ, và cảnh báo đang bảo bạn khai báo agent ngay từ đầu. mockito-core có sẵn Premain-Class, nên thêm nó vào dạng agent là tắt được cả hai cảnh báo:
java -javaagent:mockito-core-5.14.2.jar \
-cp junit-platform-console-standalone-1.11.3.jar:out:mockito-core-5.14.2.jar:byte-buddy-1.15.4.jar:byte-buddy-agent-1.15.4.jar:objenesis-3.3.jar \
org.junit.platform.console.ConsoleLauncher execute --select-class=FinalAndStaticTestChạy lệnh đó cho ra đúng kết quả xanh như cũ, không còn thông báo self-attach và không còn cảnh báo agent của JDK. Tự ghép classpath bằng tay như vậy chỉ dành cho một setup tối giản; bình thường build tool làm hộ bạn.
Những cái bẫy khiến một test đầy mock trở nên vô dụng
Mọi thứ ở trên là cơ chế. Phần này mới là phần quyết định những test bạn viết có đáng với chi phí bảo trì hay không.

Một verification là một assertion về implementation của bạn. Lấy một service tình cờ gọi repository hai lần:
public String greet(long id) {
if (repo.findById(id) == null) return "unknown";
return "Hello " + repo.findById(id).email();
}Một test quá chi tiết đóng đinh chuyện đó lại:
new GreetingService(repo).greet(1L);
verify(repo, times(2)).findById(1L);Giờ làm đúng cái dọn dẹp hiển nhiên nhất — giữ kết quả trong một local thay vì tra cứu hai lần:
public String greet(long id) {
User u = repo.findById(id);
if (u == null) return "unknown";
return "Hello " + u.email();
}Hành vi quan sát được giống hệt nhau tới từng byte. Một test assert lên chuỗi trả về thì pass với cả hai phiên bản. Còn interaction test thì thế này:
org.mockito.exceptions.verification.TooFewActualInvocations
userRepository.findById(1L);
Wanted 2 times:
-> at OverMockingTest.interactionTestBreaksAfterTheRewrite(OverMockingTest.java:31)
But was 1 time:
-> at GreetingServiceRefactored.greet(GreetingServiceRefactored.java:7)Đỏ vì một lần refactor không sửa gì và cũng không làm hỏng gì. Nhân chuyện đó lên vài nghìn test là bạn có một bộ test trừng phạt việc dọn code, đúng thứ hành vi mà bạn ít muốn cản trở nhất. InOrder và verifyNoMoreInteractions là hai công cụ sắc nhất để dựng nên loại test này, nên chỉ dùng chúng ở chỗ thứ tự hoặc việc không có lời gọi thừa thực sự là một phần của contract, và không dùng ở đâu khác.
Và điểm mạnh hơn: một test mock mọi thứ thì chẳng test được gì. Đây là một service có bug thật — nó lưu user mà không hề tắt cờ active:
public String deactivate(long id) {
User u = repo.findById(id);
if (u == null) return "not found";
repo.save(new User(u.id(), u.email(), true)); // bug: should be false
email.send(u.email(), "Account deactivated");
return "ok";
}Và đây là loại test hay được viết ra khi trong tay chỉ có mock:
new BuggyAccountService(repo, email).deactivate(1L);
verify(repo).findById(1L);
verify(repo).save(any(User.class));
verify(email).send(anyString(), anyString());Nó pass. Xanh, trên một tính năng deactivate không deactivate được ai. Mọi stub trả về đúng thứ tác giả test đã viết, mọi verification xác nhận một lời gọi mà tác giả test vốn đã biết, và không dòng nào nhìn vào dữ liệu cả. Assertion duy nhất còn đứng vững là "code của tôi có gọi những method tôi bảo nó gọi".
Một captor là biến nó lại thành test thật:
ArgumentCaptor<User> saved = ArgumentCaptor.forClass(User.class);
verify(repo).save(saved.capture());
assertFalse(saved.getValue().active());state assertion: expected: <false> but was: <true> saved=User[id=1, email=ann@example.com, active=true]Luật rút ra từ đây: assert lên state và giá trị trả về ở mọi chỗ có thể, và chỉ dùng verification cho những side effect không để lại state quan sát được — một email thật sự được gửi, một message thật sự được publish, một row thật sự bị xoá. Và khi đã verify thì hãy capture argument.
Khi nào không nên mock
Có những thứ không bao giờ nên có double, và lý do luôn giống nhau: cái double tốn công hơn object thật mà lại kém đáng tin hơn.
Value object. Một record, một String, một LocalDate, một BigDecimal. Tạo ra nó không tốn gì và nó không có hành vi nào để bạn phải cô lập. Mockito 5 mock một record ngon lành, và kết quả thì vô nghĩa:
User u = mock(User.class);
System.out.println("mocked record: id=" + u.id() + " email=" + u.email() + " active=" + u.active());
System.out.println("toString -> " + u.toString());mocked record: id=0 email=null active=false
toString -> Mock for User, hashCode: 1471633238Mọi accessor trả về giá trị mặc định, toString không còn mô tả giá trị nữa, và cái assertion đỏ mà bạn đọc được sau đó chẳng nói lên điều gì. Cứ viết new User(1L, "ann@example.com", true).
Collection. Một List bị mock không còn là list — nó là một object đã quên mất cách làm list:
List<String> list = mock(List.class);
list.add("a");
list.add("b");
System.out.println("mocked list size after two adds -> " + list.size());
System.out.println("mocked list get(0) -> " + list.get(0));mocked list size after two adds -> 0
mocked list get(0) -> nullHai lời gọi add được ghi lại rồi vứt đi. Dùng new ArrayList<>().
Chính class đang được test. Nếu bạn thấy mình đang mock một method trên chính object đang test — việc này bắt buộc phải dùng spy — thì class đó đang làm hai việc và cần được tách ra.
Bất cứ thứ gì rẻ mà bạn tự sở hữu. Một implementation trong bộ nhớ cho chính interface repository của bạn thường rõ ràng hơn năm dòng stubbing, dùng chung được cho cả test class, và giữ lại hành vi thật kiểu "lưu xong rồi load thì ra đúng thứ vừa lưu" mà một stub không diễn tả được.
Cái biên đáng mock là cái biên bạn không kiểm soát và không thể làm cho nhanh hay ổn định: network, filesystem, clock, một client bên thứ ba, một cổng thanh toán. Bên trong cái biên đó, ưu tiên object thật.
FAQ
Có bắt buộc dùng Mockito để viết unit test không?
Không. Test double là bất kỳ object nào đứng thay cho collaborator, và với một interface một hai method thì một lambda hoặc một anonymous class nhỏ là đủ, không cần dependency nào — cái fake viết tay ở đầu bài pass chỉ với JUnit. Mockito đáng dùng khi interface rộng, khi câu trả lời phải thay đổi theo argument, và khi bạn cần assert rằng một lời gọi đã xảy ra. Nghĩ tới fake viết tay trước là mặc định hợp lý, nhất là với một repository trong bộ nhớ dùng chung cho cả test class, nơi fake giữ được hành vi mà stubbing không diễn tả nổi.
Mock trả về gì nếu tôi quên stub một method?
Một giá trị mặc định, không bao giờ là lỗi. Primitive nhận giá trị zero, nên int là 0 còn boolean là false; reference type nhận null; những return type dạng collection nhận instance rỗng, và Optional nhận Optional.empty chứ không phải null. Array là ngoại lệ và trả về null. Chính cái null cho reference type là lý do một mock chưa stub thường nổ ra thành NullPointerException đâu đó bên trong class đang test thay vì thành một thông báo rõ ràng kiểu "bạn quên stub cái này". Helpful NullPointerException của Java gọi tên đúng lời gọi trả về null, và thường thế là đủ để tìm ra stub còn thiếu.
Vì sao when(spy.method()) lại chạy method thật?
Vì Java tính argument trước khi gọi method. when(spy.rate("JPY")) phải tạo ra một giá trị để đưa cho when, nên spy.rate("JPY") chạy trước, và trên spy thì implementation thật chưa stub chính là thứ chạy. Nếu method đó ném exception, đụng vào database hay xoá mất cái gì thì thiệt hại đã xảy ra trước khi Mockito kịp tham gia. Hãy dùng doReturn(value).when(spy).rate("JPY") thay thế: dạng đó đặt mock lên trước và không bao giờ tính lời gọi. Trên mock() thường thì vấn đề này vô hình vì phía sau lời gọi không có implementation thật nào, nên cái bẫy chỉ cắn người đúng lần đầu dùng spy.
Mockito có mock được final class và static method không?
Trên Mockito 5 thì có, không cần cấu hình gì — inline mock maker là mặc định, và trên 5.14.2 thì Plugins.getMockMaker() báo InlineByteBuddyMockMaker. Nó instrument class đã load thay vì sinh subclass, nên final class, final method và static method đều chạy được; mockStatic trả về một resource có scope và bắt buộc phải đóng, không thì phần đăng ký còn sống và làm đỏ test sau trong cùng thread. Trên Mockito 2 tới 4, mặc định là subclass mock maker, nó báo "Mockito cannot mock/spy because : - final class" và từ chối static mock hoàn toàn; artifact mockito-inline cũ sinh ra là vì chuyện đó. Mock được static method không có nghĩa đó là ý hay — một dependency static mà bạn truyền vào được như một parameter thì dễ test hơn nhiều so với một dependency phải instrument đi.
Stub và mock khác nhau chỗ nào?
Stub cấp input cho class đang được test; mock là double mà chính các lời gọi lên nó là thứ bạn assert. Trong Mockito cả hai đều ra từ mock() và khác nhau chỉ ở cách bạn dùng — when(...) biến nó thành stub, verify(...) biến nó thành mock. Phân biệt vẫn quan trọng lúc quyết định viết gì, vì stub đỏ nghĩa là code hỏi một thứ bạn chưa chuẩn bị, còn mock đỏ nghĩa là code không làm một thứ bạn yêu cầu. Lạm dụng loại thứ hai chính là thứ sinh ra những test dính chặt vào chi tiết implementation.
Có nên dùng @InjectMocks không?
Nó tiện và nó hỏng trong im lặng. @InjectMocks chọn constructor lớn nhất mà nó thoả mãn được rồi truyền null cho mọi parameter không có mock tương ứng, nên thêm một collaborator vào constructor mà quên thêm @Mock sẽ cho ra NullPointerException sâu bên trong class đang test thay vì một lỗi ngay lúc wiring. Tự gọi constructor trong @BeforeEach tốn một dòng và biến đúng lỗi đó thành compile error. Vẫn giữ @Mock để tạo double, vì phần đó không mập mờ chút nào.
Bao nhiêu mock trong một test thì là quá nhiều?
Không có ngưỡng cứng, nhưng bốn hay năm mock quanh một class là tín hiệu mạnh — thường là class đó có quá nhiều collaborator, hoặc cái biên đã bị vẽ sai chỗ. Câu hỏi hữu ích hơn là test còn chứng minh được gì khi mọi collaborator đều là đồ giả: nếu mọi câu trả lời đều do test viết ra và mọi assertion đều là verify, thì test chỉ xác nhận được rằng code của bạn gọi đúng những method bạn bảo nó gọi, đúng như ví dụ deactivate có bug ở trên. Hãy mock ở rìa hệ thống, giữ object thật ở bên trong, và assert lên state cùng giá trị trả về ở mọi chỗ chúng tồn tại.
Kết luận
Mockito nhỏ gọn một khi mô hình đã rõ: một mock là một bảng câu trả lời do when(...) ghi vào, cộng với một log lời gọi do verify(...) đọc ra. Stubbing kiểm soát thứ đi vào, verification assert thứ đi ra, còn ArgumentCaptor là thứ biến vế thứ hai từ "có được gọi" thành một assertion thật sự về dữ liệu.
Những cơ chế hay cắn người đều là hệ quả của mô hình đó. Method chưa stub trả lời bằng giá trị mặc định, nên một stub bị quên xuất hiện dưới dạng NullPointerException. Matcher sống trên một stack, nên trộn matcher với literal là ném InvalidUseOfMatchersException. Spy chạy code thật, nên when(spy.foo()) thực thi foo() trước khi kịp stub nó, và doReturn().when(spy).foo() là dạng không làm vậy. Trên Mockito 5, inline mock maker là mặc định nên final class và static method mock được, đổi lại là một agent tự attach mà JDK đời mới sẽ cảnh báo.
Những quyết định mang tính phán đoán còn quan trọng hơn tất cả những thứ đó. Chỉ verify các side effect không để lại state để assert; còn lại thì assert lên giá trị. Đừng bao giờ đóng đinh số lần gọi hay thứ tự gọi trừ khi nó thực sự là một phần của contract, vì đó là cách một bộ test đi tới chỗ đỏ vì những lần refactor không đổi gì cả. Và luôn nhớ cái biên: double thuộc về chỗ network, clock và filesystem bắt đầu, không phải giữa hai class của chính bạn vốn dĩ tạo ra rất rẻ.
Phần 6 đi tiếp với bài 30, về debugging và logging trong Java — đọc stack trace cho đúng, và lấy được thông tin hữu ích từ một chương trình đang chạy thay vì ngồi đoán.