Mocking and Stubbing Strategies in Tests
Pick the right test double for the job. Compare mocks, stubs, fakes, and spies, and learn when to skip them entirely.
What you'll learn
- ✓The difference between mocks, stubs, fakes, and spies
- ✓When to mock and when to use the real thing
- ✓How to mock HTTP boundaries cleanly
- ✓Why overmocking hurts
- ✓How to evolve your test doubles over time
Prerequisites
- •Comfortable writing basic unit tests
What and Why
Tests need to control inputs and isolate the unit under test. Test doubles let us swap real collaborators for controllable stand-ins. Picking the right kind keeps tests fast and meaningful, while choosing poorly leads to brittle, low-value test suites.
Mental Model
Test doubles come in four flavors that get confused often.
- Stub: returns canned data. No assertions.
- Mock: records calls and asserts on them.
- Fake: a working but simplified implementation, like an in-memory database.
- Spy: a real object wrapped so you can observe calls without changing behavior.
Need controlled input? --> Stub
Need to verify interaction? --> Mock or Spy
Need realistic behavior offline? --> Fake
Don't actually need isolation? --> Use the real collaborator Hands-on Example
A function that emails users about new orders.
// notifier.js
export class Notifier {
constructor({ emailClient, db }) {
this.emailClient = emailClient;
this.db = db;
}
async notifyOrder(orderId) {
const order = await this.db.orders.get(orderId);
if (!order) return false;
await this.emailClient.send(order.userEmail, "Your order", `Total: ${order.total}`);
return true;
}
}
Stub the database, mock the email client.
import { test, expect, vi } from "vitest";
import { Notifier } from "./notifier.js";
test("notifies user via email", async () => {
const db = { orders: { get: async () => ({ id: 1, userEmail: "a@x.io", total: 42 }) } };
const emailClient = { send: vi.fn().mockResolvedValue(true) };
const n = new Notifier({ db, emailClient });
const ok = await n.notifyOrder(1);
expect(ok).toBe(true);
expect(emailClient.send).toHaveBeenCalledWith("a@x.io", "Your order", "Total: 42");
});
The db is a stub, no assertions on it. The emailClient is a mock; we verify how it was called.
For HTTP calls, mock at the network boundary, not the client library.
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
const server = setupServer(
http.get("https://api.example.com/user/1", () =>
HttpResponse.json({ id: 1, name: "Ada" })),
);
beforeAll(() => server.listen());
afterAll(() => server.close());
A fake makes integration tests realistic without hitting the network.
class InMemoryUserRepo {
constructor() { this.rows = new Map(); }
async save(u) { this.rows.set(u.id, u); return u; }
async byId(id) { return this.rows.get(id) ?? null; }
}
Common Pitfalls
- Mocking everything. Tests pass even when the real code is broken because the doubles never reflected reality.
- Asserting too much about call shape. Hard-coding argument order makes refactors painful for no real safety.
- Mocking what you do not own. If you mock a third-party library’s internals, every upgrade breaks tests.
- Replacing the database in integration tests. Use a real DB or an in-memory fake instead of a half-mock.
- Forgetting cleanup. Stale mocks leak between tests and cause flakes that surface days later.
Practical Tips
- Mock at architectural seams: HTTP, database, time. Avoid mocking your own pure functions.
- Use
vi.fn()orjest.fn()to combine stub and mock when you need both control and observation. - Wrap external services behind a small interface in your code. Tests can swap the implementation easily.
- Lean on tools like
mswfor HTTP,nockfor Node, andhttpxmocking orrespxfor Python. - Promote stubs to fakes when they grow logic. Fakes are easier to maintain than ever-expanding stub maps.
Wrap-up
Mocks, stubs, fakes, and spies are different tools for different jobs. Stubs feed inputs, mocks verify interactions, fakes mimic behavior, and spies watch without interfering. Use them where they earn their keep, prefer real collaborators when speed and isolation are not at risk, and your test suite will grow into a confident safety net instead of a maintenance burden.
Related articles
- Testing Property-Based Testing: An Introduction
Stop writing one example per test. Property-based testing generates inputs for you and finds the edge cases you would never think to write.
- Testing Contract Tests Explained: Catching Integration Bugs Early
Understand consumer-driven contract testing, how it differs from integration tests, and how tools like Pact prevent breaking API changes between services.
- Testing Test Coverage Metrics and Their Pitfalls
Line, branch, and mutation coverage explained. Learn what each metric tells you, what it hides, and how to use coverage without gaming it.
- Testing End-to-End Testing with Playwright: A Practical Tutorial
Learn how to write reliable end-to-end tests with Playwright, including selectors, fixtures, auto-waiting, and patterns that avoid flakiness.