Mở đầu — vì sao bài này quan trọng
Nếu bạn từng đọc code test của người khác và bắt gặp một mớ hỗn độn những từ như mockPaymentService, stubUserRepo, fakeDatabase, dummyLogger... rồi tự hỏi "rốt cuộc mấy cái này khác nhau chỗ nào, sao cùng là 'giả' mà đặt tên loạn xạ vậy?" — thì bài này chính là dành cho bạn.
Trong nghề Automation Testing, kỹ năng viết được test chưa đủ. Kỹ năng viết test đúng cách, cô lập được thành phần cần kiểm thử, mới là thứ phân biệt một QA junior với một SDET có nghề. Và nền tảng của kỹ năng cô lập đó chính là Test Doubles — thuật ngữ do Gerard Meszaros đặt ra trong cuốn xUnit Test Patterns, sau đó được Martin Fowler phổ biến rộng rãi qua bài viết kinh điển "Mocks Aren't Stubs".
"Test Double" (đóng thế test) là một khái niệm bao trùm, giống như "diễn viên đóng thế" (stunt double) trong điện ảnh. Khi quay một cảnh nguy hiểm, người ta không dùng diễn viên chính mà dùng người đóng thế trông tương tự. Trong test cũng vậy: khi thành phần bạn muốn kiểm thử phụ thuộc vào một thành phần khác (một API thanh toán thật, một database production, một service gửi SMS...), bạn không dùng thành phần thật đó — bạn thay bằng một "đóng thế".
Vấn đề là có tới 5 loại đóng thế, mỗi loại phục vụ mục đích riêng: Dummy, Stub, Spy, Mock, Fake. Rất nhiều người — kể cả người đi làm nhiều năm — gọi tất cả là "mock" một cách vô tội vạ. Điều đó không sai về mặt giao tiếp hằng ngày, nhưng khi bạn thiết kế test nghiêm túc, việc phân biệt rạch ròi giúp bạn chọn đúng công cụ, viết test gọn hơn, ổn định hơn, và tránh những test "giòn" (fragile) vỡ vụn mỗi khi refactor. Bài này sẽ giúp bạn nắm chắc năm khái niệm đó tới mức có thể giải thích cho đồng nghiệp trong buổi phỏng vấn hoặc code review.
Khái niệm cốt lõi
Trước khi đi vào từng loại, hãy nhớ một điều: tất cả năm loại đều là Test Double. Chúng khác nhau ở mục đích sử dụng và mức độ thông minh, chứ không phải ở cú pháp thư viện. Một thư viện như Mockito (Java), unittest.mock (Python) hay Jest (JavaScript) có thể tạo ra cả năm loại — chính bạn quyết định object đó đóng vai trò gì.
1. Dummy — kẻ đứng cho có
Dummy là object được truyền vào để thỏa mãn chữ ký hàm (method signature), nhưng không bao giờ được dùng thực sự. Nó chỉ đứng đó cho đủ tham số.
Ví dụ: hàm createOrder(Customer customer, Logger logger) yêu cầu một Logger, nhưng trong test case bạn đang kiểm thử luồng tạo đơn hàng thành công — nhánh code đó không ghi log gì cả. Bạn vẫn phải truyền cái gì đó vào chỗ logger, nên bạn truyền một dummy: new Logger() rỗng, hoặc null, hoặc Mockito.mock(Logger.class) mà không cấu hình gì. Nếu dummy đó bị gọi tới, thường nó sẽ ném lỗi hoặc chẳng làm gì — vì đúng ra nó không nên được gọi.
2. Stub — kẻ trả lời theo kịch bản
Stub là object cung cấp câu trả lời có sẵn cho các lời gọi trong test. Nó không quan tâm bạn gọi bao nhiêu lần hay gọi thế nào — cứ hỏi là nó trả lời theo kịch bản đã cấu hình sẵn.
Ví dụ: bạn test hàm tính giá cuối cùng, hàm này gọi taxService.getVATRate(). Bạn không muốn gọi service thuế thật, nên bạn stub nó: "hễ ai gọi getVATRate() thì cứ trả về 0.10". Stub giúp bạn điều khiển đầu vào gián tiếp (indirect input) cho thành phần đang test. Mục tiêu là state verification — bạn kiểm tra kết quả trả về của hàm, không quan tâm nội bộ nó tương tác với stub ra sao.
3. Spy — kẻ vừa trả lời vừa ghi chép
Spy là một stub "có trí nhớ": nó vừa trả lời theo kịch bản, vừa ghi lại thông tin về cách nó bị gọi để bạn kiểm tra sau. "Hàm này được gọi mấy lần? Gọi với tham số gì? Gọi theo thứ tự nào?"
Ví dụ kinh điển: bạn test một service gửi email. Bạn không muốn gửi email thật, nên bạn dùng một EmailSpy. Sau khi chạy hàm, bạn hỏi spy: assertEquals(1, emailSpy.getSentCount()) và assertEquals("welcome@shop.vn", emailSpy.getLastRecipient()). Spy thường được tự viết tay hoặc dựng bằng cơ chế "partial mock" — bọc một object thật lại và theo dõi vài phương thức.
4. Mock — kẻ kiểm tra kỳ vọng hành vi
Mock là object được lập trình sẵn với các kỳ vọng (expectations) về việc nó phải được gọi như thế nào. Điểm mấu chốt: mock tự nó thực hiện behavior verification — nếu kỳ vọng không được thỏa mãn, test thất bại.
Sự khác biệt tinh tế giữa spy và mock: với spy, bạn đi hỏi và tự assert sau khi chạy. Với mock đúng nghĩa (theo trường phái "mockist"), bạn khai báo kỳ vọng trước, mock tự kiểm tra và tự báo fail. Ví dụ: "Tôi kỳ vọng paymentGateway.charge(500000) được gọi đúng một lần." Nếu code chạy xong mà charge không được gọi, hoặc gọi với số tiền khác, mock sẽ khiến test đỏ.
Trong thực tế, các thư viện hiện đại như Mockito làm mờ ranh giới spy/mock — verify(paymentGateway).charge(500000) về bản chất là dùng object như một spy rồi assert. Nhưng về mặt khái niệm, mock gắn liền với behavior verification (kiểm tra tương tác), còn stub gắn với state verification (kiểm tra trạng thái).
5. Fake — kẻ có thân thật nhưng rút gọn
Fake là object có cài đặt hoạt động thực sự, đầy đủ logic — nhưng được rút gọn để không phù hợp cho production. Nó "chạy thật" chứ không chỉ trả lời theo kịch bản.
Ví dụ điển hình nhất: một in-memory database. Thay vì gọi MySQL thật, bạn dùng một repository lưu dữ liệu trong một HashMap. Nó thật sự lưu, thật sự truy vấn, thật sự trả về đúng dữ liệu bạn đã lưu — nhưng dữ liệu nằm trong RAM, mất khi test kết thúc, không có transaction, không có index. H2 database, SQLite in-memory, hay fakeredis trong Python đều là fake. Fake mạnh ở chỗ nó cho hành vi gần với thật nhất, nhưng đổi lại bạn phải viết và bảo trì logic của fake đó.
Bảng so sánh nhanh
| Loại | Mục đích chính | Có logic thật? | Kiểu kiểm tra |
|---|---|---|---|
| Dummy | Điền chỗ trống tham số | Không | Không kiểm tra |
| Stub | Cấp đầu vào gián tiếp cố định | Không (chỉ trả kịch bản) | State |
| Spy | Ghi lại lời gọi để assert sau | Không / một phần | Behavior (thủ công) |
| Mock | Kỳ vọng tương tác, tự fail | Không | Behavior (tự động) |
| Fake | Cài đặt rút gọn nhưng chạy thật | Có | State |
Tình huống thực tế
Ví dụ 1 — Fintech Việt Nam test luồng nạp tiền (stub + mock)
Hãy tưởng tượng bạn là SDET tại một ví điện tử giả định tên PayGo ở TP.HCM, xử lý khoảng 40.000 giao dịch nạp tiền mỗi ngày. Có một service TopUpService xử lý nạp tiền: nó gọi bankConnector.verifyBalance() để kiểm tra số dư ngân hàng, rồi gọi walletRepo.credit() để cộng tiền vào ví, rồi gọi smsSender.send() để báo cho khách.
Bạn cần test case: "Khi số dư ngân hàng đủ, nạp 500.000đ thành công thì ví phải được cộng đúng 500.000đ và một SMS xác nhận được gửi đi."
bankConnectorbạn dùng stub: cấu hìnhverifyBalance()luôn trả về "đủ tiền". Bạn không kiểm tra gì về bank connector, chỉ cần nó trả lời để luồng đi tiếp.smsSenderbạn dùng mock: bạn kỳ vọngsend()được gọi đúng một lần với đúng số điện thoại khách. Nếu code quên gửi SMS, test phải đỏ. Đây là behavior verification — hành vi "gửi SMS" là một phần bắt buộc của nghiệp vụ, nên bạn kiểm nó bằng mock.
Ví dụ 2 — Sàn TMĐT dùng in-memory fake thay cho database (fake)
Một đội QA tại sàn thương mại điện tử giả định ChoTot24 cần test lớp OrderRepository cùng các service phụ thuộc vào nó. Ban đầu họ cho test kết nối thẳng vào một MySQL staging. Kết quả: bộ 300 test integration chạy mất 8 phút, thỉnh thoảng lại đỏ ngẫu nhiên (flaky) vì hai test chạy song song đụng dữ liệu của nhau.
Họ chuyển sang fake: viết một InMemoryOrderRepository cài đặt cùng interface OrderRepository, lưu đơn hàng trong một ConcurrentHashMap. Nó hỗ trợ save, findById, findByCustomer thật sự — logic tìm kiếm chạy thật. Kết quả: bộ test rớt từ 8 phút xuống 25 giây, hết hẳn flaky vì mỗi test tạo repository mới hoàn toàn cô lập.
Bài học: fake cho bạn "cảm giác thật" mà không gánh chi phí hạ tầng thật. Nhưng lưu ý cái giá: fake phải được bảo trì đồng bộ với interface thật, và bạn vẫn cần một ít test integration chạy trên database thật để chắc rằng SQL/mapping đúng — vì fake không bao giờ bắt được lỗi cú pháp SQL. Fake thay thế phần lớn, không thay thế toàn bộ.
Ví dụ 3 — Startup logistics và cái bẫy "mock tất cả" (dummy + over-mocking)
Một startup giao hàng ở Hà Nội, đội 4 kỹ sư, có thói quen mock mọi thứ. Hàm assignDriver(Order, Logger, MetricsClient, FeatureFlagService) được test bằng cách... mock cả bốn tham số, và viết verify() cho từng cái. Kết quả: mỗi lần refactor nhỏ (đổi thứ tự log, thêm một dòng metric) là 15 test vỡ, dù nghiệp vụ chẳng thay đổi.
Khi review, một senior chỉ ra: trong test case này, Logger và MetricsClient chỉ nên là dummy — chúng không liên quan gì đến việc "gán tài xế đúng hay sai". Chỉ driverPool mới đáng được stub (trả về danh sách tài xế rảnh), và hành vi cần verify duy nhất là "một Assignment đúng được tạo ra". Sau khi dọn lại, số test giữ nguyên nhưng chỉ còn 2 test vỡ khi refactor.
Bài học: không phải thứ gì truyền vào cũng phải mock và verify. Phần lớn dependency trong một test chỉ là dummy. Verify những gì không thuộc mục tiêu của test chính là công thức tạo ra test giòn.
Hướng dẫn từng bước
Đây là quy trình bạn có thể áp dụng mỗi khi cần chọn và dùng test double:
- Xác định "system under test" (SUT). Trước tiên, chỉ rõ đúng một thành phần bạn đang kiểm thử. Mọi thứ còn lại nó phụ thuộc vào đều là ứng viên để thay bằng double.
- Liệt kê các dependency (collaborator). Với mỗi cái, hỏi: "Trong test case cụ thể này, nó đóng vai gì?"
- Với mỗi dependency, chọn loại double theo câu hỏi quyết định:
- Cấu hình double bằng thư viện phù hợp. Ví dụ với Python
unittest.mock:stub.get_rate.return_value = 0.1(stub), rồi sau khi chạymock.charge.assert_called_once_with(500000)(mock/spy). Với Java Mockito:when(taxService.getVAT()).thenReturn(0.1)vàverify(sms).send(any()).
- Ưu tiên state verification, dùng behavior verification tiết kiệm. Mặc định hãy nghiêng về stub + kiểm tra kết quả. Chỉ dùng mock/verify khi bản thân sự tương tác là hợp đồng nghiệp vụ (gửi SMS, ghi giao dịch, gọi thanh toán).
- Chạy test và cố tình phá. Sửa code cho sai (comment dòng gửi SMS) để chắc test thật sự bắt được lỗi. Một test không bao giờ đỏ là một test vô dụng.
Lỗi thường gặp & mẹo
- Gọi mọi thứ là "mock". Về giao tiếp thì tạm được, nhưng khi thiết kế test, hãy tự hỏi mình đang cần state hay behavior. Nhầm mục đích dẫn tới sai công cụ.
- Over-mocking (mock quá tay). Verify từng dependency khiến test bám chặt vào cách cài đặt thay vì hành vi. Refactor vô hại cũng làm test đỏ. Mẹo: nếu bạn phải sửa test mỗi lần đổi nội bộ mà output không đổi, bạn đang mock quá nhiều.
- Mock kiểu type bạn không sở hữu. Đừng mock trực tiếp thư viện bên thứ ba (SDK ngân hàng, driver HTTP). Hãy bọc nó sau một interface của bạn, rồi mock interface đó. Khi thư viện đổi API, bạn chỉ sửa một chỗ.
- Stub trả về giá trị "quá đẹp". Stub luôn trả thành công khiến bạn quên test nhánh lỗi. Hãy chủ động stub cả trường hợp ngân hàng từ chối, timeout, trả null.
- Fake bị lệch so với thật. In-memory fake dễ "quên" một ràng buộc (unique key, cascade delete) mà DB thật có. Luôn giữ một lớp mỏng integration test chạy trên hạ tầng thật.
- Dummy
nullgâyNullPointerExceptionkhó hiểu. Nếu bạn không chắc dependency có bị gọi hay không, dùng một mock rỗng (Mockito trả về giá trị mặc định an toàn) thay vìnulltrần trụi.
- Mẹo đặt tên: đặt tên biến theo đúng vai trò —
dummyLogger,stubTaxService,mockPaymentGateway,fakeUserRepo. Người đọc code sau bạn (và chính bạn 3 tháng sau) sẽ biết ngay ý định.
Bài tập thực hành
- Phân loại. Cho hàm
sendInvoice(Order order, PdfRenderer renderer, EmailClient email, AuditLog audit), trong test case "gửi hóa đơn thành công cho đơn hợp lệ". Hãy quyết định mỗi tham số nên là loại double nào và giải thích một câu vì sao. (Gợi ý: không phải cả bốn đều là mock.)
- Viết một fake. Định nghĩa interface
UserRepositoryvớisave(User)vàfindByEmail(String). Viết mộtInMemoryUserRepositorydùngHashMap. Viết một test cho serviceRegistrationdùng fake này, kiểm tra rằng đăng ký hai lần cùng email thì ném lỗiDuplicateEmailException.
- Stub vs Mock. Test hàm
checkout()gọiinventory.reserve()rồipayment.charge(). Viết hai phiên bản: một bản dùng stub choinventory(trả về "còn hàng") và kiểm tra kết quả trả về; một bản dùng mock chopaymentđể verifycharge()được gọi đúng một lần. Ghi chú lại bạn đang kiểm state hay behavior ở mỗi bản.
- Phá test. Lấy bài 3, comment dòng gọi
payment.charge()trong code sản phẩm. Bản test nào bắt được lỗi? Nếu không bản nào bắt được, hãy suy nghĩ vì sao và sửa lại.
Tóm tắt
Test Double là thuật ngữ bao trùm cho mọi object đóng thế trong test, gồm năm loại theo Meszaros và Fowler:
- Dummy — điền chỗ tham số, không được dùng.
- Stub — trả lời theo kịch bản cố định, phục vụ state verification.
- Spy — stub có trí nhớ, ghi lại lời gọi để bạn tự assert.
- Mock — mang kỳ vọng hành vi, tự làm test fail khi tương tác sai, phục vụ behavior verification.
- Fake — cài đặt thật nhưng rút gọn (như in-memory DB), chạy thật với trạng thái thật.