Mock, stub và fake khác nhau thế nào
1. Tổng quan
Khi viết unit test, bạn thường phải thay các dependency thật như database, cổng thanh toán hay dịch vụ gửi email bằng một đối tượng “đóng thế”. Các đối tượng này gọi chung là test double. Nhiều người gọi tất cả là “mock”, nhưng mock, stub và fake phục vụ ba mục đích khác nhau.
Bài này giúp bạn phân biệt ba loại đó qua cùng một ví dụ, biết khi nào dùng loại nào và tránh các lỗi khiến test trở nên giòn hoặc vô nghĩa.
2. Ví dụ dùng xuyên suốt bài
2.1. Dependency
Trong bài, chúng ta dùng JUnit 5, AssertJ và Mockito. Nếu bạn dùng Spring Boot 3.x, spring-boot-starter-test đã có sẵn cả ba. Nếu không, hãy thêm các dependency sau:
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<version>5.14.2</version>
<scope>test</scope>
</dependency>
</dependencies>
2.2. Code cần test
Ta có một OrderService đơn giản. Service này thu tiền, lưu đơn hàng rồi gửi email xác nhận:
public record Order(String id, String customerEmail, BigDecimal amount) {}
public interface PaymentGateway {
boolean charge(String customerEmail, BigDecimal amount);
}
public interface OrderRepository {
void save(Order order);
Optional<Order> findById(String id);
}
public interface EmailSender {
void send(String to, String subject);
}
public class OrderService {
private final PaymentGateway paymentGateway;
private final OrderRepository orderRepository;
private final EmailSender emailSender;
public OrderService(PaymentGateway paymentGateway,
OrderRepository orderRepository,
EmailSender emailSender) {
this.paymentGateway = paymentGateway;
this.orderRepository = orderRepository;
this.emailSender = emailSender;
}
public boolean placeOrder(Order order) {
if (!paymentGateway.charge(order.customerEmail(), order.amount())) {
return false;
}
orderRepository.save(order);
emailSender.send(order.customerEmail(), "Đơn hàng " + order.id() + " đã được xác nhận");
return true;
}
}
OrderService có ba dependency. Mỗi dependency phù hợp với một loại test double khác nhau:
PaymentGatewaycung cấp dữ liệu đầu vào (kết quả thanh toán), nên phù hợp với stub.EmailSenderlà tác dụng phụ cần kiểm chứng (email có được gửi không), nên phù hợp với mock.OrderRepositorycó hành vi lưu trữ thật, nên phù hợp với fake.
3. Stub: trả về câu trả lời dựng sẵn
Stub là đối tượng trả về giá trị cố định cho các lời gọi. Stub không quan tâm nó được gọi bao nhiêu lần. Bạn cũng không assert gì trên stub. Mục đích duy nhất của stub là đưa code đang test vào đúng nhánh logic bạn muốn kiểm tra.
3.1. Stub viết tay
Cách đơn giản nhất là tự implement interface:
class DecliningPaymentGateway implements PaymentGateway {
@Override
public boolean charge(String customerEmail, BigDecimal amount) {
return false;
}
}
Lớp này luôn từ chối thanh toán. Ta dùng nó để kiểm tra nhánh “thanh toán thất bại”:
class OrderServiceStubTest {
@Test
void givenDeclinedPayment_whenPlaceOrder_thenReturnFalse() {
OrderService service = new OrderService(
new DecliningPaymentGateway(),
mock(OrderRepository.class),
mock(EmailSender.class));
boolean result = service.placeOrder(
new Order("ORD-1", "an@example.com", new BigDecimal("150000")));
assertThat(result).isFalse();
}
}
Trong test này:
DecliningPaymentGatewaylà stub. Nó épplaceOrder()đi vào nhánhreturn false.- Assert nằm trên kết quả trả về của service, không nằm trên stub.
mock(OrderRepository.class)vàmock(EmailSender.class)ở đây chỉ đóng vai dummy. Đây là những đối tượng truyền vào cho đủ tham số constructor, không dùng tới.
3.2. Stub bằng Mockito
Viết tay một lớp cho mỗi tình huống sẽ nhanh chóng trở nên rườm rà. Mockito cho phép tạo stub ngay trong test:
@Test
void givenApprovedPayment_whenPlaceOrder_thenReturnTrue() {
PaymentGateway paymentGateway = mock(PaymentGateway.class);
when(paymentGateway.charge(anyString(), any(BigDecimal.class))).thenReturn(true);
OrderService service = new OrderService(
paymentGateway, mock(OrderRepository.class), mock(EmailSender.class));
assertThat(service.placeOrder(
new Order("ORD-2", "binh@example.com", new BigDecimal("99000"))))
.isTrue();
}
Dù được tạo bằng hàm mock(), paymentGateway ở đây vẫn là stub về mặt vai trò: ta chỉ cấu hình giá trị trả về bằng when(...).thenReturn(...) và không hề verify nó. Tên hàm của thư viện không quyết định loại test double. Cách bạn dùng đối tượng mới là yếu tố quyết định.
4. Mock: kiểm chứng tương tác
Mock là đối tượng ghi lại các lời gọi tới nó, để sau đó bạn kiểm tra xem code có gọi đúng phương thức, đúng tham số, đúng số lần hay không. Khác với stub, assert nằm trên chính mock.
Mock hữu ích khi hành vi cần kiểm tra là một tác dụng phụ không trả về gì, ví dụ gửi email, publish event hay ghi log audit.
@ExtendWith(MockitoExtension.class)
class OrderServiceMockTest {
@Mock
PaymentGateway paymentGateway;
@Mock
OrderRepository orderRepository;
@Mock
EmailSender emailSender;
@InjectMocks
OrderService orderService;
@Test
void givenApprovedPayment_whenPlaceOrder_thenSendConfirmationEmail() {
when(paymentGateway.charge("an@example.com", new BigDecimal("150000")))
.thenReturn(true);
orderService.placeOrder(new Order("ORD-1", "an@example.com", new BigDecimal("150000")));
verify(emailSender).send("an@example.com", "Đơn hàng ORD-1 đã được xác nhận");
}
@Test
void givenDeclinedPayment_whenPlaceOrder_thenNoEmailSent() {
when(paymentGateway.charge(anyString(), any(BigDecimal.class))).thenReturn(false);
orderService.placeOrder(new Order("ORD-1", "an@example.com", new BigDecimal("150000")));
verifyNoInteractions(emailSender, orderRepository);
}
}
Phân tích từng phần:
@ExtendWith(MockitoExtension.class)kích hoạt Mockito cho JUnit 5. Các field@Mockđược khởi tạo trước mỗi test.@InjectMockstạoOrderServicevà inject ba đối tượng@Mockqua constructor.paymentGatewayvẫn là stub, cònemailSendermới là mock thực sự vì taverifynó.verify(emailSender).send(...)kiểm trasend()được gọi đúng một lần với đúng hai tham số.verifyNoInteractions(...)khẳng định khi thanh toán thất bại, hệ thống không lưu đơn và không gửi email.
Nếu bạn lỡ xoá dòng emailSender.send(...) trong OrderService, test đầu sẽ fail với thông báo tương tự:
Wanted but not invoked:
emailSender.send(
"an@example.com",
"Đơn hàng ORD-1 đã được xác nhận"
);
-> at OrderServiceMockTest.givenApprovedPayment_whenPlaceOrder_thenSendConfirmationEmail
Actually, there were zero interactions with this mock.
Lưu ý: Không nên vừa stub vừa verify cùng một lời gọi, ví dụ
when(paymentGateway.charge(...))rồi lạiverify(paymentGateway).charge(...). Nếu stub không được gọi, service đã không trả vềtruevà test sẽ fail ở chỗ khác. Lệnhverifylúc này thừa và chỉ làm test gắn chặt hơn vào implementation.
5. Fake: implementation thật nhưng đơn giản hoá
Fake là một implementation hoạt động thật nhưng dùng đường tắt không phù hợp cho production. Ví dụ điển hình là repository lưu dữ liệu trong HashMap thay vì database:
public class InMemoryOrderRepository implements OrderRepository {
private final Map<String, Order> storage = new HashMap<>();
@Override
public void save(Order order) {
storage.put(order.id(), order);
}
@Override
public Optional<Order> findById(String id) {
return Optional.ofNullable(storage.get(id));
}
}
Khác với stub, fake có logic và trạng thái. Gọi save() rồi gọi findById() sẽ trả về đúng đơn vừa lưu. Nhờ vậy, bạn kiểm tra trạng thái cuối cùng thay vì kiểm tra lời gọi:
class OrderServiceFakeTest {
private final InMemoryOrderRepository orderRepository = new InMemoryOrderRepository();
private final OrderService orderService = new OrderService(
(email, amount) -> true, // stub dạng lambda
orderRepository, // fake
(to, subject) -> { }); // dummy dạng lambda
@Test
void givenApprovedPayment_whenPlaceOrder_thenOrderIsPersisted() {
Order order = new Order("ORD-3", "chi@example.com", new BigDecimal("250000"));
orderService.placeOrder(order);
assertThat(orderRepository.findById("ORD-3")).contains(order);
}
}
Một vài điểm đáng chú ý:
PaymentGatewayvàEmailSenderđều chỉ có một phương thức abstract, nên ta tạo stub và dummy bằng lambda mà không cần Mockito.- Test không biết
OrderServicegọisave()mấy lần hay theo thứ tự nào. Test chỉ quan tâm sau khi đặt hàng, đơn có nằm trong kho không. - Nếu sau này bạn refactor
OrderServicesang dùng một phương thức khác, ví dụsaveAll(), test dùng fake vẫn chạy được miễn kết quả cuối cùng đúng. Test dùngverify(orderRepository).save(order)thì sẽ vỡ.
Lưu ý: Fake là code, nên fake cũng có thể có bug. Nếu fake hành xử khác implementation thật (ví dụ không ném lỗi khi trùng khoá), test có thể pass trong khi production lỗi. Với fake quan trọng, hãy viết một bộ contract test dùng chung, chạy trên cả fake lẫn implementation thật (chẳng hạn bản JPA chạy với Testcontainers).
6. So sánh và cách chọn
Bảng dưới tóm tắt sự khác nhau:
| Tiêu chí | Stub | Mock | Fake |
|---|---|---|---|
| Mục đích | Cung cấp dữ liệu đầu vào | Kiểm chứng tương tác | Thay thế hành vi thật một cách nhẹ hơn |
| Có logic không | Không, trả giá trị cố định | Không, chỉ ghi nhận lời gọi | Có, hoạt động như bản thật |
| Assert ở đâu | Trên kết quả của code đang test | Trên chính mock (verify) |
Trên trạng thái của fake |
| Kiểu kiểm thử | State-based | Interaction-based | State-based |
| Độ bền khi refactor | Cao | Thấp hơn | Cao |
Ngoài ba loại trên, bạn còn gặp hai loại khác:
- Dummy: đối tượng truyền vào cho đủ tham số, không bao giờ được dùng.
- Spy: bọc một đối tượng thật, vẫn chạy logic thật nhưng ghi lại lời gọi để
verify. Trong Mockito, bạn tạo spy bằngspy(...).
Một quy tắc chọn đơn giản:
- Dependency trả về dữ liệu cho code của bạn thì dùng stub.
- Dependency là tác dụng phụ ra bên ngoài (email, message queue, API bên thứ ba) và không có cách nào quan sát trạng thái thì dùng mock.
- Dependency có trạng thái và được dùng ở nhiều test (repository, cache, clock) thì cân nhắc viết fake một lần, dùng lại nhiều nơi.
Nói chung, nên ưu tiên kiểm tra trạng thái (stub, fake) hơn kiểm tra tương tác (mock). Test dựa trên trạng thái mô tả kết quả, còn test dựa trên tương tác mô tả cách làm, và cách làm thay đổi thường xuyên hơn.
7. Lỗi hay gặp
Mock mọi thứ. Nếu một test có năm lệnh when và bốn lệnh verify, test đó đang chép lại implementation chứ không còn kiểm tra hành vi. Chỉ cần đổi thứ tự hai dòng code, test sẽ đỏ dù kết quả vẫn đúng.
Mock value object. Đừng mock Order, BigDecimal hay LocalDate. Hãy tạo instance thật. Mockito 5 mặc định có thể mock cả lớp final và record, nhưng có làm được không có nghĩa là nên làm.
Mock kiểu dữ liệu bạn không sở hữu. Mock trực tiếp RestTemplate hay SDK của nhà cung cấp khiến test giả định sai về hành vi của thư viện. Hãy bọc thư viện sau một interface của bạn (như PaymentGateway) rồi mock interface đó. Bản implementation thật thì kiểm tra bằng integration test.
Stub thừa. Với MockitoExtension, Mockito mặc định dùng strict stubs. Nếu bạn stub một lời gọi mà code không bao giờ thực hiện, test sẽ fail:
org.mockito.exceptions.misusing.UnnecessaryStubbingException:
Unnecessary stubbings detected.
Clean & maintainable test code requires zero unnecessary code.
Đừng vội tắt cảnh báo này bằng lenient(). Thông báo này thường cho thấy test đang không đi vào nhánh bạn nghĩ, hoặc phần setup đã lỗi thời.
Dùng fake thay cho integration test. InMemoryOrderRepository không kiểm tra được câu SQL, constraint hay transaction. Fake giúp unit test nhanh, nhưng bạn vẫn cần vài integration test với database thật.
8. Kết luận
Stub, mock và fake đều là test double nhưng trả lời ba câu hỏi khác nhau. Stub trả lời “dependency trả về gì?”. Mock trả lời “code có gọi dependency đúng cách không?”. Fake trả lời “nếu có một bản chạy thật nhưng nhẹ thì kết quả cuối cùng là gì?”.
Hãy chọn loại test double theo vai trò của dependency, không theo công cụ: một đối tượng tạo bằng mock() hoàn toàn có thể chỉ là stub. Ưu tiên kiểm tra trạng thái, chỉ dùng verify cho những tác dụng phụ thực sự cần kiểm chứng. Test của bạn sẽ ngắn hơn, dễ đọc hơn và ít vỡ hơn khi refactor.