Skip to main content
Testing, Quality & Craft
🧪 Testing & CraftLesson 5 of 13

Test Doubles: Mocks, Stubs & Fakes

Mocks, stubs, and fakes: isolate what you're testing without faking your way into lies.

Test Doubles: Mocks, Stubs & Fakes

Stage 3 · Testing, Quality & Craft · B.U.I.L.D. letter: D

You can vibe-code a payment flow in an afternoon. But the moment your test suite actually charges a card, emails a real user, or fails because the Wi-Fi dropped — your tests stopped being tests. They became a liability. Test doubles are how you take the chaos of the real world and replace it with something you control completely.


⚠️ The vibe trap

You discovered mocking and it felt like a superpower — jest.fn() everywhere, every dependency swapped out, all tests green. The problem is that when you mock too aggressively, your tests only verify that your code calls the mocks you wrote — not that the whole system actually works. You can have 100% coverage and ship a completely broken feature. Mocks lie when they stop resembling the real thing, and you won't find out until production.


🧱 The family: four kinds of test double

Every test double is a stand-in for a real dependency. The four types differ in how much they do and what they assert.

// --- STUB: returns canned data, no questions asked ---
// Real use-case: you need getUser() to return SOMETHING so the function under
// test can proceed. You don't care how many times it's called.

function makeUserStub(overrides = {}) {
  return {
    getUser: async (id) => ({
      id,
      name: "Alex",
      email: "alex@example.com",
      plan: "free",
      ...overrides,
    }),
  };
}

// In your test:
it("formats a greeting for any user", async () => {
  const userRepo = makeUserStub({ name: "Jordan" });
  const result = await buildGreeting("user-42", userRepo);
  expect(result).toBe("Welcome back, Jordan!");
});

Mental model: A stub is a recorded voice saying exactly what you need to hear. It never improvises.

Why: The function under test doesn't care where user data comes from — it just needs a user. A stub gives you that without a database, network, or seed file.

Common mistake: Using a stub when you actually need to verify the call happened. Stubs don't record anything — if you need proof the code called getUser, reach for a spy or mock instead.


🕵️ Mock: the double that holds you accountable

// --- MOCK: a stub with built-in assertions ---
// Real use-case: verify that your checkout function calls the email service
// exactly once, with the right order ID, after a successful payment.

it("sends exactly one confirmation email after checkout", async () => {
  const emailService = {
    sendOrderConfirmation: jest.fn().mockResolvedValue({ delivered: true }),
  };

  await processCheckout({ orderId: "ord-99", userId: "u-1" }, emailService);

  // The assertion IS the test — the mock enforces the contract.
  expect(emailService.sendOrderConfirmation).toHaveBeenCalledTimes(1);
  expect(emailService.sendOrderConfirmation).toHaveBeenCalledWith(
    expect.objectContaining({ orderId: "ord-99" })
  );
});

Mental model: A mock is a stub with a checklist. It remembers every call and will fail the test if reality doesn't match what you declared up front.

Why: Sending a duplicate email to a customer is a bug your unit tests should catch. Mocks are the only doubles that catch "the code forgot to call this."

Common mistake: Mocking the exact argument shape so tightly that any refactor (even a safe one) breaks the test. Keep expect.objectContaining(...) — test the meaningful fields, not the entire payload.


🏗️ Fake: a real implementation, just smaller

// --- FAKE: a working lightweight implementation ---
// Real use-case: an in-memory repository so you can run full domain logic
// (save, find, update, delete) without a real database.

class FakeOrderRepository {
  constructor() {
    this._store = new Map();
  }

  async save(order) {
    this._store.set(order.id, { ...order });
    return order;
  }

  async findById(id) {
    return this._store.get(id) ?? null;
  }

  async findByUserId(userId) {
    return [...this._store.values()].filter((o) => o.userId === userId);
  }

  // Test helper — not part of the production interface
  _reset() {
    this._store.clear();
  }
}

// In your test:
it("marks an order as shipped and persists the state", async () => {
  const repo = new FakeOrderRepository();
  await repo.save({ id: "ord-1", userId: "u-1", status: "paid" });

  await shipOrder("ord-1", repo);

  const updated = await repo.findById("ord-1");
  expect(updated.status).toBe("shipped");
});

Mental model: A fake is a toy version of the real thing — it follows the same rules, just without the weight. The difference from a stub is that it has real behavior: saving to the fake actually makes the item findable later.

Why: Fakes are perfect for domain logic that touches persistence. You get test confidence close to integration-test quality at unit-test speed. They also make your production interface explicit — if a method doesn't exist on the fake, you notice immediately.

Common mistake: Letting the fake drift from the real implementation. If your real OrderRepository adds a findByStatus method and you forget to add it to the fake, you'll have a gap. Keep the fake's interface in sync with your production interface (or use TypeScript to enforce it).


🕶️ Spy: observe without replacing

// --- SPY: wraps the real thing, records calls ---
// Real use-case: you want the real logger to run, but you also want to assert
// that a warning was logged when input is invalid.

import * as logger from "../lib/logger";

it("logs a warning when amount is negative", () => {
  // Wrap the real function — it still executes
  const warnSpy = jest.spyOn(logger, "warn");

  applyDiscount(-50, "cart-7");

  expect(warnSpy).toHaveBeenCalledWith(
    expect.stringContaining("negative amount")
  );

  warnSpy.mockRestore(); // always clean up spies
});

Mental model: A spy is a wire-tap — the original call goes through, but you get a record of everything that happened.

Why: Use a spy when replacing the whole dependency would be overkill, or when you specifically want to verify side effects without blocking real behavior.

Common mistake: Forgetting mockRestore(). A leaked spy will contaminate every test that runs after it, producing baffling false failures on unrelated tests.


🔗 Why dependency injection makes this easy

All of the examples above pass the dependency in as an argument. That is dependency injection (DI) — and it is the reason doubling is effortless here. Compare:

// ❌ Hard-coded dependency — impossible to double
async function processCheckout(orderId) {
  const emailService = new SendGridEmailService(); // locked in
  await emailService.sendOrderConfirmation({ orderId });
}

// ✅ Injected dependency — trivially replaceable in tests
async function processCheckout(orderId, emailService) {
  await emailService.sendOrderConfirmation({ orderId });
}

If you took the SOLID module (D4), you recognized this as the Dependency Inversion Principle in action. Production code passes the real service; tests pass a double. The function under test never knows the difference.


⚠️ Over-mocking: when your green tests are lying

The most dangerous test suite is one where everything is mocked and all tests pass — but the real integration is broken.

Signs you are over-mocking:

  • Your mock returns a shape that the real service would never return.
  • You mocked away the exact part of the code you should have been testing.
  • A test breaks every time you rename a method, even when behavior hasn't changed.

The rule of thumb: mock at the boundary, test real logic. Use doubles to replace things outside your control (payment providers, email APIs, third-party SDKs, the clock). Keep your own domain logic — calculations, state transitions, validation — running against real objects.


🛠️ Your mission

Take an existing test in your project that hits a real external dependency — a payment API, an email service, a weather endpoint, a database that requires a live connection, or even Date.now().

  1. Identify which type of double fits best: are you checking it was called (mock), just need data back (stub), need realistic behavior across multiple calls (fake), or want to observe without replacing (spy)?
  2. Implement the double using the pattern that matches your choice.
  3. Run the test: it should be deterministic now — passing every time, no network required.
  4. Write a two-sentence comment above the double explaining why you chose that type.

Bonus: if you have a function that directly instantiates a dependency (new RealService()), refactor it to accept the dependency as a parameter first. Notice how the test instantly becomes writable.


✅ You're done when…

  • Your test file passes offline — no network, no credentials, no live database required
  • You can name which double type you used and explain why in one sentence
  • You checked your work against the Code Review Rubric — mocks are documented, fakes match the real interface, spies are restored
  • No production code changed to make the test pass (only the test and the injection point)
  • Your double does not return a shape that could never come from the real dependency

➡️ Next: Coverage That Means Something.

Build It Right, Or Don't Build It At All. 🏛️

Always-on rigor toolkit

🏛️ Build It Right, Or Don't Build It At All.