PLAINFINCH learn by building

Mocks and fakes

You will swap a real email sender for a stand-in and check who got a reminder and what it said, without sending anything.

Why replace a function in a test at all?

remindMembers() sends emails. A test that sent real emails to real members before every walk would be slow, would depend on a mail service being up, and would annoy the Riverside Bird Club within a day. You want to know that the right messages would go out, without sending any.

The code makes that easy because it receives send as an argument. The real site passes the function that talks to the email service. A test can pass a stand-in that records what it was asked to do. Passing dependencies in like this is the single biggest thing that makes code easy to test. When a function reaches out to a global mail client on its own, you have to fight it.

What does vi.fn() give you?

vi.fn() returns a mock function: a function that does nothing, returns undefined, and keeps a record of every call. send.mock.calls is an array with one entry per call, and each entry is the list of arguments. Matchers read that record for you, so tests rarely touch mock.calls directly.

toHaveBeenCalledTimes(n) checks the number of calls. toHaveBeenCalledWith(a, b) passes if any call had exactly those arguments. toHaveBeenLastCalledWith and toHaveBeenNthCalledWith(2, ...) look at one specific call, which helps when order counts, such as the first reminder going to the walk leader. Jest has the same function as jest.fn(), and the practice runner accepts both names.

const send = vi.fn();
remindMembers(members, walk, send);

expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith(
  '[email protected]',
  'Reminder: Dawn chorus walk on 2026-04-18',
);
FigureA mock send made with vi.fn is passed to remindMembers. It sends nothing but records one call with priya@example.com and the Dawn chorus walk reminder, which toHaveBeenCalledTimes and toHaveBeenCalledWith then check.A mock send made with vi.fn is passed to remindMembers. It sends nothing but records one call with priya@example.com and the Dawn chorus walk reminder, which toHaveBeenCalledTimes and toHaveBeenCalledWith then check.
vi.fn() makes a stand-in send that does nothing except record each call. The matchers then check that record: one call, to [email protected], with the right reminder.

How do you read a failed mock assertion?

When toHaveBeenCalledWith fails, the Results view shows the arguments you expected and, under Received, the calls the mock got. If the code sent 'Reminder: Dawn chorus walk' with no date, the two strings sit side by side and the missing part is easy to spot. If the mock was never called, Received is an empty list, which usually means the code took a different branch than you thought.

A failed toHaveBeenCalledTimes(1) with Received 3 tells you the filter isn't working: members who opted out were sent reminders too. Read the number before you read the code. It often names the bug.

When should a mock return something?

When the code uses the answer. If the club's code calls lookupMember(id) and reads the result, a mock that returns undefined makes the code crash before you test anything useful. vi.fn().mockReturnValue({ name: 'Priya' }) returns the same value every time. mockReturnValueOnce sets the answer for the next call only, which lets one test walk through a sequence: first call finds a member, second finds nobody.

mockImplementation(fn) is the general form. It runs your function on every call, so the mock can work out its answer from the arguments. Keep it small. Once a mock implementation grows its own if statements, it is starting to become a fake, and it may deserve a name and a home of its own.

const lookupMember = vi.fn()
  .mockReturnValueOnce({ name: 'Priya' })
  .mockReturnValueOnce(undefined);

What is the difference between a mock and a fake?

A mock is a stand-in you ask questions of afterwards: was it called, how often, with what. A fake is a small working version of the real thing. A fake email sender might push each message into an array. A fake sightings store might keep sightings in a Map and really return them from findBySpecies().

Use a mock when the interesting part is the call itself, like sending a reminder. Use a fake when the code under test stores something and reads it back, because a fake lets the test check the outcome instead of the steps. Tests built on fakes break less when you reorganise the code, since they don't care which method was called in which order. The starting test in this demo uses the simplest fake of all, () => {}, which is fine when you don't care about the call.

function fakeStore() {
  const saved = [];
  return {
    save: (s) => saved.push(s),
    all: () => saved,
  };
}

How does vi.spyOn() differ from vi.fn()?

vi.spyOn(object, 'method') wraps a method that already exists. By default the original still runs, and the spy records each call. That suits checks like "the code logged a warning": spy on console.warn, run the code, and assert on the spy. Add .mockImplementation(() => {}) if you also want to silence the real output.

A spy replaces a method on a real object, so it has to be put back. Call spy.mockRestore(), or vi.restoreAllMocks() in an afterEach, or set restoreMocks: true in the config. A spy left in place carries its calls and its fake behaviour into later tests, which is one of the shared-state bugs from the setup lesson.

afterEach(() => {
  vi.restoreAllMocks();
});

test('warns about a member with no email', () => {
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
  checkMembers([{ name: 'June', email: '' }]);
  expect(warn).toHaveBeenCalledWith(expect.stringContaining('June'));
});

When is mocking a bad idea?

Mock the edges of your code: email, payment, the network, the clock. Don't mock the code you are trying to test, or small pure helpers it calls. If a test for checkout() mocks discount() and vat(), it only proves that checkout() calls two functions; it can't notice that the totals are wrong. Running the real helpers gives a far stronger test at no extra cost.

Watch for tests whose assertions are all about calls. If every line is toHaveBeenCalledWith, the test describes how the code works today, step by step, and breaks the moment you reorganise it, even when the behaviour stays correct. Prefer asserting on what the code returns or what changed, and keep call assertions for the things that really leave your program, like the email.

What do AI-written tests get wrong with mocks?

Two patterns come up again and again. The first is a mock that is created, passed in, and never asserted, so the test passes whatever the code sends. The second is over-mocking: every import replaced with vi.fn() returning a canned value, until the test checks only that the mocks return what they were told to. Both look thorough and catch nothing.

When reviewing, find each mock and ask two questions. Is there an expect() about it, or about something it affected? Would the test fail if the real code sent the wrong arguments? The Break It challenge shows a third trap: an assertion like toHaveBeenCalled() that was written to match buggy behaviour, saving bad data and calling it the rule.

Field notes

  • Passing a function in as an argument lets a test swap it for a stand-in.
  • vi.fn() makes a mock that records every call and its arguments.
  • toHaveBeenCalledWith and toHaveBeenCalledTimes check what the mock received.
  • Mock the edges like email and payment, and run your own helpers for real.

Easy to get wrong

A mock that is never checked lets the test pass whatever the code does with it. Every mock in a test should appear in an assertion.

toHaveBeenCalled() passes for one call or fifty, to anyone, with any text. Say how many calls and what they contained.

A spy stays on the real object until you restore it, so later tests inherit it and its recorded calls.

Questions people ask

What is the difference between `mockClear`, `mockReset` and `mockRestore`?

mockClear() forgets the recorded calls. mockReset() also removes any return value or implementation you set. mockRestore() does both and, for a spy, puts the original method back.

How do I mock a whole module, like an email library?

Real Vitest has vi.mock('./email.js'), and Jest has jest.mock(). Both replace a module's exports for the test file. The practice runner doesn't support module mocking, so these lessons pass dependencies in as arguments, which is the cleaner design anyway.

Is a hand-written fake allowed where the task says `vi.fn()`?

Yes. Check my work tests behaviour: if your fake records the calls and your assertions would catch each broken copy, it passes. vi.fn() saves you writing the recording yourself.

Can I check the order of calls to one mock?

Yes. toHaveBeenNthCalledWith(1, ...) and toHaveBeenNthCalledWith(2, ...) check the first and second calls, and send.mock.calls holds them in order if you need something more specific.

Keep going