EduFlow
Testing JavaScript Apps
0%

Section 2 · Unit Tests That Matter

Test Doubles and When Mocking Hurts

20 min3 min readCode playground

Lesson at a glance

Replace collaborators with vi.fn and vi.spyOn, control time with fake timers, and recognise when mocking makes a test pass while the real code is broken.

  1. 1The vocabulary, briefly
  2. 2vi.fn: a function that remembers
  3. 3vi.spyOn: watching a real method
  4. 4Fake timers: skipping the wait
  5. 5When mocking hurts

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

In the last lesson you wrote a stub API by hand, with a counter. It worked, and it was about fifteen lines. Vitest’s vi helpers do the same in one line, and they add a lot of power. That power is the problem: mocking is the fastest way to write a test that passes while the real code is broken. This lesson covers the tools and the judgement.

The vocabulary, briefly

All of these are test doubles, stand-ins for a real collaborator:

Four kinds of test double in a row: a stub returns canned answers, a spy records calls, a mock is a spy you assert on, a fake is a working lightweight implementation. Stub canned answers returns {id} Spy records calls .mock.calls Mock spy you assert on CalledWith Fake working, simpler in-memory DB
Test doubles, from simplest to most behaviour

In Vitest, vi.fn() gives you a stub and a spy in one object, and people call all of them “mocks”. The names matter less than the question each answers: what does this collaborator return? (stub) and how was it called? (spy).

vi.fn: a function that remembers

Splitwise-lite sends a reminder to everyone who owes money. The sender is passed in, so a test can replace it:

src/remind.test.js
import { test, expect, vi } from 'vitest';
import { remindDebtors } from './remind.js';

test('reminds people who owe money, with the amount', () => {
  const sendReminder = vi.fn();

  const sent = remindDebtors({ Ana: 1500, Ben: -500, Cai: 0 }, sendReminder);

  expect(sent).toBe(1);
  expect(sendReminder).toHaveBeenCalledTimes(1);
  expect(sendReminder).toHaveBeenCalledWith('Ben', 'You owe $5.00 to the group');
});

vi.fn() returns undefined by default. Give it behaviour when the code under test needs an answer:

JavaScript
const post = vi.fn().mockResolvedValue({ id: 'e1' });          // async success
const failing = vi.fn().mockRejectedValue(new Error('Offline')); // async failure
const send = vi.fn().mockImplementation((name) => {
  if (name === 'Ben') throw new Error('Mailbox full');
});

Every call is recorded in send.mock.calls, an array of argument arrays, for the rare case the matchers don’t cover.

vi.spyOn: watching a real method

Sometimes the collaborator is a method on an existing object, like console.warn. vi.spyOn wraps it: by default the real method still runs, and the spy records calls. Chain mockImplementation to silence or replace it:

JavaScript
import { afterEach, test, expect, vi } from 'vitest';

afterEach(() => {
  vi.restoreAllMocks(); // put console.warn back
});

test('warns and carries on when one reminder fails', () => {
  const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
  const send = vi.fn().mockImplementationOnce(() => { throw new Error('Mailbox full'); });

  const sent = remindDebtors({ Ben: -500, Dee: -300 }, send);

  expect(sent).toBe(1);
  expect(send).toHaveBeenCalledTimes(2);
  expect(warn).toHaveBeenCalledWith('Could not remind Ben: Mailbox full');
});

A spy replaces a method on a shared object. Forget to restore it and every later test in the file runs with a silent console.warn. vi.restoreAllMocks() in afterEach, or restoreMocks: true in the config, makes cleanup automatic.

Fake timers: skipping the wait

Code that waits (debounced autosave, retry back-off, “undo” toasts) is painful to test in real time. Fake timers replace setTimeout, setInterval and Date with a clock the test controls:

JavaScript
import { afterEach, test, expect, vi } from 'vitest';

afterEach(() => vi.useRealTimers());

test('autosaves the draft 2 seconds after the last change', () => {
  vi.useFakeTimers();
  const save = vi.fn();
  const draft = createDraft({ save, delayMs: 2000 }); // debounced

  draft.update({ description: 'Din' });
  draft.update({ description: 'Dinner' });
  vi.advanceTimersByTime(1999);
  expect(save).not.toHaveBeenCalled();

  vi.advanceTimersByTime(1);
  expect(save).toHaveBeenCalledWith({ description: 'Dinner' });
});

vi.advanceTimersByTime(ms) runs every timer due within that window, instantly. vi.setSystemTime(date) pins new Date(), which is how you test “reminders go out on the first of the month”. When timers schedule promises, use the async variants such as vi.advanceTimersByTimeAsync. (The playground runs real timers only, so this one stays on the page.)

When mocking hurts

Every mock is a claim about how the real thing behaves. When reality drifts from the claim, the test keeps passing. The patterns that bite:

  • Mocking your own modules. Replacing parseAmount inside a form test means the form and parser are never tested together, which was the point of the test. Your own pure code is fast; use it.
  • Mocking the thing under test. If you spy on remindDebtors while testing remindDebtors, you are testing the spy.
  • Asserting every call. Checking the exact sequence of fourteen internal calls freezes the implementation. Assert the calls a caller would care about, like “Ben got a reminder”.
  • Mocks that lie. Your mock API returns { id }; the real one returns { data: { id } }. Unit tests green, app broken. The next section’s network mocking and the end-to-end tests in section 4 exist to catch exactly this.

A quick sanity check for any mock you write: delete one line of the real code it stands in for. If no test fails anywhere in the suite, that mock was hiding the behaviour instead of testing around it, and somewhere you need a test that runs the real thing.

My rule: mock at the boundaries of your system (network, email, payment, time, randomness) and run the real code everywhere inside.

In the exercise, use vi.fn and vi.spyOn to catch four bugs in remindDebtors. Section 3 moves from functions to the DOM.

Key takeaways

  • vi.fn() creates a function that records every call, and you can tell it what to return with mockReturnValue, mockResolvedValue or mockImplementation.
  • vi.spyOn(object, 'method') watches a real method and can override it; always restore spies, for example with vi.restoreAllMocks() in afterEach.
  • Mock at the boundaries of your system (network, email, time, randomness) and use the real code for your own modules.
  • Fake timers (vi.useFakeTimers, vi.advanceTimersByTime) let a test skip ahead through delays instead of waiting for them.
  • Every mock is a claim about how the real thing behaves; when that claim drifts, the test stays green while production breaks.

Further reading

Check your understanding

4 questions

Question 1 of 4

You want to check that remindDebtors calls sendReminder('Ben', 'You owe $5.00 to the group'). Which assertion fits?

Question 2 of 4

A test for the expense form mocks the in-house parseAmount module so that it always returns 1250. The real parseAmount gets a bug. What happens to that form test?

Question 3 of 4

A draft autosaves 2 seconds after the last keystroke. With vi.useFakeTimers() on, how does the test get the save to happen?

Question 4 of 4

Why do you call vi.restoreAllMocks() (or set restoreMocks) after tests that use vi.spyOn?

Practice

Code playground

Mocks for remindDebtors

remindDebtors(balances, sendReminder) calls sendReminder(name, message) for each person who owes at least $1.00, warns with console.warn when a reminder fails, and returns how many it sent. Positive balances are owed money; negative balances owe. The rules are in the subject’s comment.

The starter already spies on console.warn (and restores it after each test). Use vi.fn() for sendReminder and add tests until your suite catches four hidden bugs: one reminds people who are owed money, one swaps the two arguments, one skips a debt of exactly $1.00, and one stops at the first failed reminder.

function formatMoney(cents) {
  const sign = cents < 0 ? '-' : '';
  const abs = Math.abs(cents);
  return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`;
}

// Sends a reminder to everyone who owes money (a negative balance).
// - Calls sendReminder(name, message) once per debtor, with a message
//   such as "You owe $5.00 to the group".
// - Skips debts smaller than minimumCents (default 100, i.e. $1.00);
//   a debt of exactly minimumCents IS reminded.
// - People who are owed money or are settled up get nothing.
// - If sendReminder throws for someone, logs
//   console.warn("Could not remind <name>: <error message>")
//   and carries on with the others.
// Returns the number of reminders that were sent successfully.
function remindDebtors(balances, sendReminder, { minimumCents = 100 } = {}) {
  let sent = 0;
  for (const [name, cents] of Object.entries(balances)) {
    if (cents >= 0 || -cents < minimumCents) continue;
    try {
      sendReminder(name, `You owe ${formatMoney(-cents)} to the group`);
      sent += 1;
    } catch (error) {
      console.warn(`Could not remind ${name}: ${error.message}`);
    }
  }
  return sent;
}
Loading the editor…

Ctrl/⌘ + Enter to run · Runs in a sandbox in your browser. Nothing leaves your device.

Results

(no output)

Checks

  • All your tests pass against the real remindDebtors
  • Catches the version that reminds people who are owed money
  • Catches the version that swaps name and message
  • Catches the version that skips a debt of exactly $1.00
  • Catches the version that stops at the first failed reminder