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.
- 1The vocabulary, briefly
- 2vi.fn: a function that remembers
- 3vi.spyOn: watching a real method
- 4Fake timers: skipping the wait
- 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:
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:
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:
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:
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:
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
parseAmountinside 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
remindDebtorswhile testingremindDebtors, 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 withmockReturnValue,mockResolvedValueormockImplementation.vi.spyOn(object, 'method')watches a real method and can override it; always restore spies, for example withvi.restoreAllMocks()inafterEach.- 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.