Quick Answer

Test doubles are stand-ins for a real dependency during a test. A stub returns canned values to steer a code path, and a spy records how it was called so you can assert on it afterwards. A mock is preloaded with expectations about the calls it should receive, while a fake is a lightweight working implementation such as an in-memory database. Reach for the simplest one the test needs.

Why test doubles exist

A unit test should be fast, deterministic, and aimed at one piece of code. Real collaborators fight all three: a database is slow, a payment API costs money, Date.now() changes every run, an email client sends real email.

A test double swaps one collaborator for something you control. The vocabulary comes from Gerard Meszaros's xUnit Test Patterns, which names five kinds: dummy, stub, spy, mock, and fake. People say mock for all of them, but the distinctions steer you toward the right tool and away from brittle tests.

Two schools frame how far to take this. The classicist (Chicago) style uses real objects wherever they are cheap and reserves doubles for awkward dependencies. The mockist (London) style doubles every collaborator to test one unit in true isolation. Most teams land in between.

The examples below use Vitest. The code under test is a SignupService that checks a user repository, saves a user, then sends a welcome email. Every test shown here was run, and all five pass.

Stub: canned answers

A stub returns hard-coded values so your code takes a particular path. You do not assert on the stub itself; it exists to make the test runnable.

const stubRepo = {
  findByEmail: async () => ({ id: 99, email: 'taken@example.com' }),
  save: async () => { throw new Error('should not be called'); },
};
const svc = new SignupService(stubRepo, { send: async () => {} }, clock);

await expect(svc.register('taken@example.com'))
  .rejects.toThrow('already registered');

The stub's findByEmail always reports the user exists, which forces the already-registered branch. The assertion is about the service, not the stub. Use a stub when a collaborator's return value feeds the logic you actually care about and you just need it to be predictable.

Stubs also model failure. Have one throw a timeout error and you can test that your retry logic and user-facing messages behave. The defining trait stays the same: you never verify how the stub was called, only that the code reached the right outcome.

Spy: recording calls

A spy records how it was called, how many times and with which arguments, and you inspect that record after the code runs. In Vitest vi.fn() creates a standalone spy and vi.spyOn wraps an existing method.

const sendSpy = vi.fn().mockResolvedValue(undefined);
const svc = new SignupService(new FakeUserRepo(), { send: sendSpy }, clock);

await svc.register('ravi@example.com');

expect(sendSpy).toHaveBeenCalledTimes(1);
expect(sendSpy).toHaveBeenCalledWith('ravi@example.com', 'Welcome to Priodemy!');

Both assertions pass on a real run. vi.spyOn(obj, 'method') is the choice when you want the real method to keep running but still record calls; spy.mockRestore() puts the original back afterwards, so it is the gentlest double.

Beyond counts and arguments, sendSpy.mock.calls is an array in call order, so you can assert that the audit-log write happened before the email. Spies fit the question: did my code notify the outside world correctly, such as an email sent, an event published, or an analytics call fired.

Mock: preset expectations

A mock is set up in advance with the calls it expects, and the test fails if reality does not match. In Vitest, vi.mock replaces an entire module for the test file.

vi.mock('./mailer.js', () => ({
  send: vi.fn().mockResolvedValue({ id: 'msg_1' }),
}));
import * as mailer from './mailer.js';
import { placeOrder } from './order.js';

it('order flow never touches the real mailer', async () => {
  await placeOrder('ord_7', 'buyer@example.com');
  expect(mailer.send).toHaveBeenCalledWith(
    'buyer@example.com', 'Order ord_7 confirmed');
});

The real mailer.js throws if it is ever called, which proves the mock is in effect. vi.mock is hoisted above the imports, so its factory cannot reference variables declared outside.

Partial mocks are common: call vi.importActual inside the factory to keep most of a module real and replace only one export. In TypeScript, vi.mocked(mailer.send) restores the typed mock surface for assertions. Keep module mocks at true boundaries: third-party SDKs, network clients, the file system.

Fake: a working stand-in

A fake has real, working behaviour, just simplified. The classic example is an in-memory repository implementing the same interface as your database layer:

class FakeUserRepo {
  constructor() { this.rows = []; this.seq = 1; }
  async findByEmail(email) {
    return this.rows.find(r => r.email === email) || null;
  }
  async save(user) {
    const row = { id: this.seq++, ...user };
    this.rows.push(row);
    return row;
  }
}

It genuinely stores and retrieves, so one test can save a user and then read it back:

const u = await svc.register('asha@example.com');   // u.id is 1
await svc.register('asha@example.com');             // rejects: already registered

Fakes shine when several tests need a stateful collaborator and stubbing each call would be tedious and fragile. Write the fake once and reuse it across the suite. One discipline: a fake needs its own tests, ideally a shared contract suite that runs against both the fake and the real implementation so they cannot silently diverge.

The over-mocking trap

The common failure is mocking everything. When every collaborator is a mock with preset call expectations, the test stops checking behaviour and starts checking your current implementation. Rename a method or reorder two calls and green tests turn red though nothing actually broke. Worse, mocks drift from how the real dependency behaves, so the suite stays green while production fails.

Guidelines that hold up:

  • Prefer a fake for code you own, especially repositories and services.
  • Use stubs and spies for the one or two collaborators a given test truly needs to steer or observe.
  • Reserve module mocks for real external boundaries: network, time, randomness, third-party SDKs.
  • Do not mock what you do not own. Wrap it in a thin interface of your own and fake that instead.

A test that fails only when the code is genuinely wrong is the target. Over-mocking trades that away for the illusion of isolation.

Frequently Asked Questions

What is the difference between a mock and a stub? A stub only supplies canned return values and you never assert on it. A mock is preloaded with expectations about which calls it should receive and fails the test if they do not happen.
Is vi.fn() a mock or a spy? Primarily a spy: it records calls. It becomes stub-like when you give it a return value with mockReturnValue or mockResolvedValue. The library calls all of these mock functions loosely.
When should I use a fake instead of a mock? When multiple tests need the same stateful dependency, or when setting up call-by-call stubs would be fragile. In-memory repositories are the usual case.
Does vi.mock affect other test files? No. Module mocks are scoped to the test file that declares them, and Vitest resets them between files.
What is a dummy? An object passed only to satisfy a function signature, never actually used, such as a placeholder logger handed to a constructor when the test path never logs.