Stop Mocking and Start Stubbing

, 5 minute read

Most of what JavaScript developers call mocks are stubs, and most of the rest shouldn’t exist.

The names come from Gerard Meszaros’s xUnit Test Patterns, published in 2007. He called the whole family test doubles and split them into dummies, fakes, stubs, spies and mocks. Martin Fowler’s Mocks Aren’t Stubs has been explaining the difference since 2004.

A stub gives canned answers. Your code asks it for a customer, it hands back the customer you set up, and then you check what your code did with that customer. A mock comes loaded with expectations about which calls it should receive and with which arguments, and the test passes or fails on whether those calls happened.

Fowler calls the first one state verification and the second behaviour verification. One looks at what your code produced. The other looks at how it went about producing it.

Jest and Vitest don’t help. Every double in them is called a mock. vi.fn() makes a mock function, vi.mock() swaps out a module, and the way you give one a canned answer is mockResolvedValue. So vi.fn().mockResolvedValue(customer) is a stub with mock in its name twice. It only turns into a mock in Fowler’s sense when you write toHaveBeenCalledWith.

(Pedants will tell you a vi.fn() you assert on after the fact is technically a spy. Same problem, different badge.)

That assertion is the one I want you to stop writing.

Here’s an invoice function that looks up a customer, gets the tax rate for their country and works out the totals in cents.

type LineItem = { priceCents: number; qty: number };

type Deps = {
  customers: { findById(id: string): Promise<{ id: string; country: string }> };
  tax: { rateFor(country: string): Promise<number> };
};

export async function createInvoice(customerId: string, items: LineItem[], deps: Deps) {
  const customer = await deps.customers.findById(customerId);
  const rate = await deps.tax.rateFor(customer.country);
  const subtotal = items.reduce((sum, item) => sum + item.priceCents * item.qty, 0);
  const tax = Math.round(subtotal * rate);

  return { customerId, subtotal, tax, total: subtotal + tax };
}

And here’s the mock-heavy test for it. You’ve seen a hundred of these.

test('creates an invoice', async () => {
  const customers = { findById: vi.fn().mockResolvedValue({ id: 'c1', country: 'AU' }) };
  const tax = { rateFor: vi.fn().mockResolvedValue(0.1) };

  await createInvoice('c1', [{ priceCents: 5000, qty: 2 }], { customers, tax });

  expect(customers.findById).toHaveBeenCalledWith('c1');
  expect(tax.rateFor).toHaveBeenCalledWith('AU');
  expect(tax.rateFor).toHaveBeenCalledTimes(1);
});

It never looks at the invoice. Leave the tax off the total or add it twice and this test stays green.

It also goes red when nothing’s wrong. Say the tax service changes to take { country, date } so it can return whatever rate applied on the day of the invoice. Every invoice comes out identical to the cent, and the test fails because rateFor wasn’t called with 'AU' anymore. You fix it by copying the new call into the test, which means you’ve written the implementation twice and tested it zero times.

Here’s the stubbed version.

test('adds GST to the subtotal', async () => {
  const customers = { findById: async () => ({ id: 'c1', country: 'AU' }) };
  const tax = { rateFor: async () => 0.1 };

  const invoice = await createInvoice('c1', [{ priceCents: 5000, qty: 2 }], { customers, tax });

  expect(invoice).toEqual({ customerId: 'c1', subtotal: 10000, tax: 1000, total: 11000 });
});

Same inputs, and no vi.fn() anywhere. An object with an async function on it is a stub, and you don’t need a library to write one. Break the GST maths and this test catches it. Change how the tax service gets called and it doesn’t care, because all it ever looked at was the invoice.

Google’s Software Engineering at Google book has a whole chapter on test doubles, and it ends up in the same place. Its first choice is the real implementation when it’s fast and deterministic, then a fake, then stubbing. It says outright that state testing is preferred over interaction testing.

It’s harder on stubs than I am, too. The chapter warns that overusing stubbing makes tests brittle and unclear, and that’s fair. A test with fifteen canned responses wired three levels deep is its own kind of mess, and when you hit that point a small in-memory fake is usually less work.

Same goes for dates. Rather than stubbing Date.now or faking your date library one method at a time, vi.useFakeTimers() and vi.setSystemTime() hand you a fake clock and your real date code runs against it.

Mocks still have a job. When the call is the outcome, checking the call is the test. If sendReceipt exists to hand an email to your mail provider, there’s no return value worth looking at, and asserting the email went out to the right address is fine.

I’d still write that one with a fake that pushes each email into an outbox array, then check what’s in the outbox. It survives me reshuffling how the email gets built, and the assertion reads like something a customer would notice. I wouldn’t fight anyone over it, though.

This matters more now that agents write so many of the tests. A test that mirrors the implementation is the easiest test there is to generate, because it’s the implementation again with expect in front of every line.

Andre Hora and Romain Robbes went looking for this in a paper accepted at MSR 2026. Across 2,168 TypeScript, JavaScript and Python repos and more than 1.2 million commits from 2025, 36% of the agent commits that touched tests added mocks, against 26% for commits without an agent involved. (The paper counts stubs, spies and fakes as mocks too. Even the people studying this use the one word for everything.)

Their suggested fix is putting mocking guidance in your agent config, and it’s a two-line job. Something like this in your CLAUDE.md or AGENTS.md does it:

Prefer real implementations or in-memory fakes in tests. Where you need a double, stub the return value and assert on the result.
Don't assert on calls (toHaveBeenCalledWith and friends) unless the call is the side effect under test, like sending an email.

It won’t fix the tests already in your repo, but it stops the pile growing while you clean them up.

Comments