The Right Test Double for the Job
Most test-suite pain comes from reaching for the wrong kind of fake. Mock when you should fake. Stub when you should use the real thing. Spy when you should assert on the result. Knowing the vocabulary β stub, spy, fake, mock β and when each is appropriate is the difference between tests that survive refactors and tests that calcify the implementation.
- Every test starts with 30 lines of mock setup before the first assert
- A refactor of internals breaks tests that pass identical inputs and outputs
- You mock the entire DB driver, then half your bugs are SQL bugs
- Tests use real HTTP and rely on a flaky third-party sandbox
The fix is: mock at the architectural boundary, not at every collaborator; prefer fakes; assert on outputs whenever possible; never mock types you don't own.
The Five Test Doubles
| Double | Purpose | Example |
|---|---|---|
| Dummy | Required as parameter, never used | null logger, empty config |
| Stub | Returns canned values | findById returns a fixed user |
| Spy | Records calls for later inspection | vi.fn() with no return |
| Mock | Has expectations declared up front | expect(send).toHaveBeenCalledWith(...) |
| Fake | Working but simplified impl | In-memory repo; sqlite-in-memory |
Stub Pattern
import { vi } from 'vitest';
// Stub returns whatever the test needs the collaborator to say
const userRepo = {
findById: vi.fn().mockResolvedValue({ id: '1', name: 'Ada' }),
};
const greeter = new UserGreeter(userRepo);
expect(await greeter.greet('1')).toBe('Hello, Ada');Spy Pattern
// Spy records β assertion is after the fact
const notifier = { send: vi.fn() };
await new Welcomer(notifier).welcome({ id: '1', email: 'ada@x' });
expect(notifier.send).toHaveBeenCalledOnce();
expect(notifier.send).toHaveBeenCalledWith('ada@x', expect.stringContaining('Welcome'));Mock Pattern (Strict Expectations)
Mocks set expectations up-front and fail when they aren't met. Useful in rare cases where the callis the whole behavior (firing an event, charging a card). Don't default to this β strict mocks are brittle.
// With a library that supports strict mocks (Sinon / Moq / Mockito)
const payments = mock<PaymentsApi>();
payments.expect(p => p.charge('cus_1', 1999)).returns(Promise.resolve({ ok: true }));
await new Checkout(payments.object).pay({ customerId: 'cus_1', amount: 1999 });
payments.verify(); // throws if not called exactly as expectedFake Pattern (Often the Best Choice)
A fake is a real, working implementation suited for tests. In-memory repos, in-memory queues, sqlite in place of Postgres for simple cases. Fakes give you behavior, not just hardcoded answers, so tests that exercise more than one method don't need a setup avalanche.
class InMemoryOrderRepo implements OrderRepo {
private rows = new Map<string, Order>();
async save(o: Order) { this.rows.set(o.id, o); }
async findById(id: string) { return this.rows.get(id) ?? null; }
async byCustomer(c: string) { return [...this.rows.values()].filter(o => o.customerId === c); }
}
// Tests now read like prose, no stub setup per test
const repo = new InMemoryOrderRepo();
const svc = new OrderService(repo);
await svc.place({ customerId: 'c1', sku: 'X', qty: 1 });
expect(await svc.lookup('c1')).toHaveLength(1);Module Mocking
Vitest and Jest let you mock entire modules β useful for replacing infrastructure libraries without DI plumbing. Use sparingly; it makes tests harder to find dependencies for.
import { vi } from 'vitest';
vi.mock('@/lib/email', () => ({
sendEmail: vi.fn().mockResolvedValue({ id: 'msg_1' }),
}));
// Later
import { sendEmail } from '@/lib/email';
await myService.notify(user);
expect(sendEmail).toHaveBeenCalledWith({ to: user.email, subject: 'Hi' });HTTP Mocking with MSW
For outbound HTTP, intercept at the network layer rather than mocking the HTTP client. MSW (Mock Service Worker) does this in both Node and browser, so the same handlers serve unit tests, integration tests, and local dev.
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
const server = setupServer(
http.get('https://api.example/users/:id', ({ params }) =>
HttpResponse.json({ id: params.id, name: 'Ada' })),
);
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());The Rule: Mock the Boundary, Not the Field
flowchart LR
APP[App code<br/>(real)] --> PORT[Port interface]
PORT -.->|in tests| FAKE[Fake / Stub adapter]
PORT -->|in prod| ADAPTER[Real adapter<br/>(DB, HTTP, queue)]
style FAKE fill:#dfd
Mock at the seam where your code talks to the outside world (a repository interface, an HTTP client wrapper, a clock). Don't mock individual collaborators inside your domain β those should be real, and your test should set up real inputs.
Don't Mock What You Don't Own
Mocking the AWS SDK or Stripe's client directly bakes their interface into your tests. The next SDK upgrade breaks your tests instead of your code. Wrap third-party clients in a small interface you do own, mock that interface, and integration-test the wrapper against the real thing (or a record/replay fixture).
Time, Randomness, IDs, Filesystem
These are infrastructure, even if your language doesn't treat them as such. Inject a Clock, an IdGenerator, a filesystem abstraction β or use library support (Vitest fake timers, process.chdir to a temp dir). Otherwise tests pass on Tuesday and fail on Wednesday.
interface Clock { now(): Date }
class SystemClock implements Clock { now() { return new Date(); } }
class FixedClock implements Clock {
constructor(private d: Date) {}
now() { return this.d; }
}
// Production: new TokenService(new SystemClock())
// Tests: new TokenService(new FixedClock(new Date('2026-05-17T10:00:00Z')))When NOT to Mock
- Pure functions β no I/O, no dependencies, no need to mock anything. Just call them.
- Trivial collaborators β a value object or simple DTO doesn't need a mock.
- Internal helpers β mocking your own helper means you're testing the parent in isolation from itself.
- The system under test β sounds obvious, but spying on private methods of the SUT happens more than people admit.
Smells
| Smell | Likely cause | Fix |
|---|---|---|
| Setup is longer than the assertions | Too many mocked dependencies | Use a fake; or push logic to a pure function |
| Test fails on refactor that didn't change behavior | Asserting on private interactions | Assert on outputs only |
| βMock returns undefinedβ error | Missing stub for an unexpected call path | Add the stub; or use a fake that handles all methods |
| Production-only bug despite green tests | Mocked away the thing that actually broke | Move that boundary to integration test |
Pitfalls
Mocking everything for βtrue isolationβ
A test that mocks every collaborator tests only that you wired up the mocks correctly. Use real domain objects, fake the boundaries, and you get isolation and meaningful coverage.
Strict mocks by default
Strict mocks fail when called more or differently than expected. They feel safe and become brittle β any code change in the SUT changes the call pattern. Use stubs by default; reserve strict mocks for the few behaviors where the call IS the behavior.
Forgetting to reset mocks between tests
Call counts leak across tests, leading to spooky failures depending on test order. Configure your runner to restoreAllMocks/ clearAllMocks in afterEach globally.

