Guide

A test double stands in for a real dependency. Doubles let you test when the real thing is slow, non-deterministic, or billed. They become a liability when every collaborator is mocked: tests pass while the SQL is wrong.

Concept

A stub returns canned data. A fake is a simplified working implementation, such as an in-memory DB. A mock is a stub plus expectations about how it was called. Over-mocking couples tests to call graphs instead of behavior.

The preference ladder

  1. Pure functions — no doubles needed.
  2. Fakes — an in-memory repository that obeys the same interface.
  3. Real dependencies in CI — Postgres via Testcontainers.
  4. Mocks — for clock, random, third-party HTTP when a fake is unavailable.

Mocks shine when the real dependency is a payment gateway or email. They fail when you mock the database in every service test and discover SQL bugs in production.

Contract tests (Pact or schema checks against OpenAPI) catch "service A renamed a field" without a full E2E environment.

Walking through a fake

TypeScript
// Fake beats mock for a repository interface
class InMemoryOrders implements OrderRepository {
  private rows = new Map<string, Order>()
  async save(order: Order) { this.rows.set(order.id, order) }
  async find(id: string) { return this.rows.get(id) ?? null }
}

This is a sketch of the shape, not a full test file. The fake obeys the same interface as production. Tests can save and find without asserting that repo.save was called with a partial object.

Fragile version: mock clock.now and repo.save and mailer.send, then assert call order. A refactor to a batch insert breaks it though product behavior is identical. Stronger: real DB or fake repo, inject a fixed clock, assert the stored expiresAt and that mailer received one message with template trial_started. Internals can move; the contract holds.

Common mistakes

  • Mocking the database in every service test. SQL bugs wait for production.
  • Asserting call order on three collaborators. You locked the implementation.
  • A mock web so thick the test cannot say what the user saw. Assert the outcome.
  • Skipping contract tests, then running full E2E to catch a renamed field.

Check yourself

  1. When is a mock the right tool? When the real dependency is slow, non-deterministic, or billed, and you have no fake.

  2. Why does an in-memory OrderRepository beat toHaveBeenCalledWith on repo.save? The fake checks you can save and find. The mock only checks you called a method.

Key takeaways

  • Stub, fake, and mock are different tools. Name which one you used.
  • Prefer pure functions, then fakes, then real CI dependencies, then mocks.
  • Contract tests catch schema drift without a full E2E environment.
  • If a refactor that preserves behavior breaks the test, you tested the call graph.

Further learning

Next, assert the right thing in testing behavior.