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().
- 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)?
- Implement the double using the pattern that matches your choice.
- Run the test: it should be deterministic now — passing every time, no network required.
- 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. 🏛️