Product Management
Đăng nhập
ESC

Nhập từ khóa để tìm kiếm

↑↓ Di chuyển
Enter Mở
ESC Đóng

Test Doubles — Dummy, Stub, Spy, Mock, Fake

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ụngmứ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())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ạiMục đích chínhCó logic thật?Kiểu kiểm tra
DummyĐiền chỗ trống tham sốKhôngKhông kiểm tra
StubCấp đầu vào gián tiếp cố địnhKhông (chỉ trả kịch bản)State
SpyGhi lại lời gọi để assert sauKhông / một phầnBehavior (thủ công)
MockKỳ vọng tương tác, tự failKhôngBehavior (tự động)
FakeCài đặt rút gọn nhưng chạy thậtState

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."

  • bankConnector bạn dùng stub: cấu hình verifyBalance() 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.
  • smsSender bạn dùng mock: bạn kỳ vọng send() đượ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.
Bài học: đừng gọi API ngân hàng thật hay gửi SMS thật trong test — vừa tốn tiền (mỗi SMS mất phí), vừa chậm, vừa không lặp lại được. Quan trọng hơn: hãy phân biệt "thứ tôi chỉ cần cho có" (stub bank) với "thứ tôi cần đảm bảo đã xảy ra" (mock SMS). Nhầm lẫn khiến test hoặc quá lỏng (bỏ sót lỗi thiếu SMS) hoặc quá chặt (fail vô cớ khi bank connector đổi cách gọi).

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, LoggerMetricsClient 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:
- Nó có được dùng trong nhánh code này không? Không → Dummy. - Nó chỉ cung cấp dữ liệu để luồng đi tiếp, và tôi kiểm kết quả cuối? → Stub. - Tôi cần đảm bảo nó đã bị gọi đúng cách như một phần của nghiệp vụ? → Mock (hoặc spy + assert). - Tôi cần ghi lại lời gọi rồi tự kiểm tra linh hoạt sau? → Spy. - Tôi cần hành vi gần-thật, có trạng thái (lưu/đọc)? → Fake.

  • 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ạy mock.charge.assert_called_once_with(500000) (mock/spy). Với Java Mockito: when(taxService.getVAT()).thenReturn(0.1)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 null gây NullPointerException khó 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ì null trầ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 UserRepository với save(User)findByEmail(String). Viết một InMemoryUserRepository dùng HashMap. Viết một test cho service Registration dùng fake này, kiểm tra rằng đăng ký hai lần cùng email thì ném lỗi DuplicateEmailException.
  • Stub vs Mock. Test hàm checkout() gọi inventory.reserve() rồi payment.charge(). Viết hai phiên bản: một bản dùng stub cho inventory (trả về "còn hàng") và kiểm tra kết quả trả về; một bản dùng mock cho payment để verify charge() đượ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.
Nguyên tắc vàng: mặc định 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ụ, và dùng fake khi cần hành vi gần-thật có trạng thái. Đa số dependency trong một test chỉ là dummy. Nắm chắc năm khái niệm này, test của bạn sẽ nhanh hơn, ổn định hơn, và không vỡ vụn mỗi lần refactor — đó chính là dấu hiệu của một người viết test có nghề.

Học xong bài này rồi? Tạo tài khoản miễn phí để lưu lại — lần sau vào là biết ngay đang dở ở đâu, và học hết khóa thì có chứng chỉ. Lưu tiến độ của tôi