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.

πŸ€” Sound familiar?
  • 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

DoublePurposeExample
DummyRequired as parameter, never usednull logger, empty config
StubReturns canned valuesfindById returns a fixed user
SpyRecords calls for later inspectionvi.fn() with no return
MockHas expectations declared up frontexpect(send).toHaveBeenCalledWith(...)
FakeWorking but simplified implIn-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 expected

Fake 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

SmellLikely causeFix
Setup is longer than the assertionsToo many mocked dependenciesUse a fake; or push logic to a pure function
Test fails on refactor that didn't change behaviorAsserting on private interactionsAssert on outputs only
β€œMock returns undefined” errorMissing stub for an unexpected call pathAdd the stub; or use a fake that handles all methods
Production-only bug despite green testsMocked away the thing that actually brokeMove 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.