Test doubles: stubs, spies, mocks and fakes in Vitest
- Name the job a double does in a test — stub, spy, mock or fake — and pick the one that asserts least about how the code works
- Use
vi.fn,vi.spyOnandmockResolvedValue/mockImplementation, and undo what you replaced (Vitest 4'srestoreAllMocksonly restoresvi.spyOnspies) - Explain why
vi.mockis hoisted above imports, and fix a factory that reads an outer variable (vi.hoisted) or drops exports (importOriginal) - Drive time with fake timers, and use
advanceTimersByTimeAsyncwhen the code awaits between timers - Spot over-mocking, and replace the network with MSW instead of stubbing your own client
Read
Four jobs a test double can do
FlagA test double stands in for something the code under test talks to, so the test controls it or can see what happened to it. Here's code with three collaborators:
async function checkout(order: Order, { rates, orders, mailer }: Deps) {
const rate = await rates.get(order.currency); // needs an answer
const saved = await orders.save({ ...order, total: Math.round(order.subtotal * rate) });
await mailer.send(order.email, `Receipt #${saved.id}`); // a side effect
return saved;
}
Each collaborator gets a double doing a different job:
// order = { subtotal: 1000, currency: "EUR", email: "ada@example.com" }
const rates = { get: async () => 1.1 }; // STUB: feeds a canned answer in
const orders = new InMemoryOrders(); // FAKE: a real, simpler implementation
// (a Map instead of Postgres)
const mailer = { send: vi.fn() }; // SPY: records every call
const saved = await checkout(order, { rates, orders, mailer });
expect(await orders.find(saved.id)).toMatchObject({ total: 1100 });
expect(mailer.send).toHaveBeenCalledWith("ada@example.com", `Receipt #${saved.id}`);
| Job | What it does | What you assert on |
|---|---|---|
| Stub | returns a fixed answer | nothing about the stub — it just steers the code |
| Spy | records calls (and may still call the real thing) | how it was called |
| Mock | a double whose expected calls are the assertion | that the expected interaction happened |
| Fake | works for real, with a shortcut (in-memory, fake clock) | the state it ends up in |
These words describe the role a double plays in one test, not a type in Vitest. Vitest calls
nearly everything a mock: vi.fn() gives you "a function that does nothing by default (returns
undefined), but tracks every call made to it". Give it a return value and it's acting as a stub;
assert on its calls and it's acting as a spy or a mock. vi.spyOn(obj, "method") "wraps an existing
method", and "the original implementation still works by default".
Prefer the job that asserts least about how your code works. A fake you check the state of survives refactors; an exact list of expected calls breaks on every reshuffle.
vi.mock is hoisted above your imports
FlagWhen the code under test imports its collaborator, you can't pass a double in — you replace the module:
// profile.ts
import { fetchUser } from "./api";
export async function displayName(id: number) {
return (await fetchUser(id)).name.toUpperCase();
}
// profile.test.ts
import { displayName } from "./profile";
import { fetchUser } from "./api";
vi.mock("./api"); // written below the imports, but runs before them
it("uppercases the name", async () => {
vi.mocked(fetchUser).mockResolvedValue({ id: 1, name: "ada" });
expect(await displayName(1)).toBe("ADA");
});
profile.ts imports ./api before the test body ever runs, so Vitest hoists vi.mock to the
top of the file and turns static imports into dynamic ones behind it. That's what makes the
replacement visible to profile.ts. With no factory, Vitest automocks the module: every exported
function becomes a mock returning undefined, objects are deeply cloned (their methods mocked too),
arrays are emptied, and primitives stay as they are — so an enum's values survive.
The hoisting trap
const fetchUserMock = vi.fn();
vi.mock("./api", () => ({ fetchUser: fetchUserMock }));
Under Vitest 4 the whole file fails to load:
Error: [vitest] There was an error when mocking a module. If you are using "vi.mock" factory,
make sure there are no top level variables inside, since this call is hoisted to top of the file.
Caused by: ReferenceError: Cannot access 'fetchUserMock' before initialization
The factory now runs before const fetchUserMock exists. Hoist the variable with it:
const { fetchUserMock } = vi.hoisted(() => ({ fetchUserMock: vi.fn() }));
vi.mock("./api", () => ({ fetchUser: fetchUserMock }));
A factory replaces the whole module
Whatever the factory returns is the module. If ./api also exports formatDate and the factory
doesn't return it, the code under test gets nothing — Vitest 4 throws
No "formatDate" export is defined on the "./api" mock. Keep the rest of the real module with
importOriginal:
vi.mock(import("./api"), async (importOriginal) => ({
...(await importOriginal()),
fetchUser: vi.fn(), // replace only this
}));
One limit no factory fixes: Vitest's docs say "it is not possible to mock calls to methods that are
called inside other methods of the same file". If displayName and fetchUser live in one module,
the internal call always hits the real function. The designing-for-testability unit covers what to
do instead.
Over-mocking, the network boundary, and cleanup in Vitest 4
FlagA test that restates the implementation
it("checks out", async () => {
const rates = { get: vi.fn().mockResolvedValue(1.1) };
const orders = { save: vi.fn().mockResolvedValue({ id: 1 }) };
const mailer = { send: vi.fn() };
await checkout(order, { rates, orders, mailer });
expect(rates.get).toHaveBeenCalledWith("EUR");
expect(orders.save).toHaveBeenCalledWith({ ...order, total: 1100 });
expect(mailer.send).toHaveBeenCalledWith("ada@example.com", "Receipt #1");
expect(rates.get.mock.invocationCallOrder[0])
.toBeLessThan(orders.save.mock.invocationCallOrder[0]);
});
Every line mirrors a line of checkout. Cache the exchange rate, batch the save, send the email from
a queue — behaviour unchanged, test red. And because orders.save is a stub, nothing checks that the
saved order can actually be read back. This is over-mocking: doubles for your own modules,
verified call by call, so the test can only pass for this exact implementation.
What to do instead:
- Replace only what you don't own or can't run in a test — the network, the clock, email, a payment provider. Use your own modules for real.
- Prefer a fake to a call log for your own storage: an in-memory
ordersyou read back (expect(await orders.find(1)).toMatchObject({ total: 1100 })) checks the outcome, not the steps. - Assert calls only where the call is the behaviour — "the receipt email was sent once, to this address" is the feature; "rates were fetched before saving" is not.
Replace the network, not your client
Stubbing fetch (or mocking your api.ts) skips your own client code: the URL you build, the
headers, the res.ok check, the JSON parsing. Vitest's docs recommend Mock Service Worker for
requests: it intercepts at the network layer, so your real client runs.
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
const server = setupServer(
http.get("https://api.example.test/users/:id", ({ params }) =>
HttpResponse.json({ id: Number(params.id), name: "ada" })),
);
beforeAll(() => server.listen({ onUnhandledRequest: "error" })); // unknown calls fail loudly
afterEach(() => server.resetHandlers()); // drop per-test overrides
afterAll(() => server.close());
it("surfaces a 500", async () => {
server.use(http.get("https://api.example.test/users/:id", () =>
new HttpResponse(null, { status: 500 })));
await expect(displayName(1)).rejects.toThrow("HTTP 500");
});
(Checked running under Vitest 4 with MSW 2.15 and with MSW 3.0, which shipped in September 2026 —
the same code works on both.) onUnhandledRequest: "error" turns "the code called an
endpoint nobody planned for" into a failure instead of a real request.
Cleanup semantics changed in Vitest 4
vi.restoreAllMocks() now only puts back originals for spies made with vi.spyOn. It no longer
resets vi.fn() mocks or clears call history — checked on 4.1.12: after it, a vi.fn given
mockReturnValue("override") still returns "override", and a spy keeps its recorded calls
(Vitest 3.2 reset both). Use vi.clearAllMocks() (history) or
vi.resetAllMocks() (history and implementations) for those. The config switches clearMocks,
mockReset and restoreMocks all default to false in Vitest 4; Vitest 5 turns clearMocks on
by default, so call counts stop carrying over between tests after the upgrade.
Quiz
A test of checkout passes an InMemoryOrders class whose save and find really store and return orders in a Map, then reads the saved order back. What job is that double doing?
A test does const spy = vi.spyOn(console, "error") and then triggers an error path. What happens to the output?
A test file has const fetchUserMock = vi.fn(); followed by vi.mock("./api", () => ({ fetchUser: fetchUserMock }));, and fails to load with ReferenceError: Cannot access 'fetchUserMock' before initialization. Why?
vi.mock("./api", () => ({ fetchUser: vi.fn() })). The code under test also uses formatDate from ./api, and Vitest 4 throws No "formatDate" export is defined on the "./api" mock. The fix?
retry(fn) awaits fn(), and on failure awaits sleep(1000) before trying again. fn fails twice, then succeeds. With fake timers, the test calls vi.advanceTimersByTime(2000) and finds fn was called once. Why?
In Vitest 4, afterEach(() => vi.restoreAllMocks()). Which of these does it undo?
A checkout test replaces rates, orders and mailer with vi.fn()s and asserts each was called with exact arguments, in order. Someone caches the exchange rate and the test goes red, though every receipt is identical. What's the real problem?
Why intercept requests with MSW (setupServer) rather than vi.stubGlobal("fetch", vi.fn())?
Fill in the blank
Stub the async exchange rate, then assert the receipt with the matcher that checks arguments.
Flagconst getRate = vi.fn().(1.1); // resolves to 1.1 const send = vi.fn(); await checkout(order, { rates: { get: getRate }, mailer: { send } }); expect(send).("ada@example.com", "Receipt: 1100 cents");
Silence and record console.error for one test, then put the real one back.
const spy = vi.(console, "error").mockImplementation(() => {}); renderBrokenThing(); expect(spy).toHaveBeenCalledOnce(); spy.();
Create the mock in the hoisted region so the hoisted factory can use it.
Flagconst { fetchUserMock } = vi.(() => ({ fetchUserMock: vi.fn() })); vi.mock("./api", () => ({ fetchUser: fetchUserMock }));
Replace only fetchUser and keep every other export of the real module.
vi.mock(import("./api"), async (importOriginal) => ({ ...( importOriginal()), fetchUser: vi.fn(), }));
Install the fake clock and advance it in a way that lets the retry's awaited backoff timers fire.
Flagvi.(); const result = retry(flakyFn); await vi.(2000); expect(flakyFn).toHaveBeenCalledTimes(3);
Predict the output
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
async function retry(fn: () => Promise<string>) {
for (let attempt = 1; ; attempt++) {
try { return await fn(); }
catch (err) { if (attempt >= 3) throw err; await sleep(1000); }
}
}
vi.useFakeTimers();
const fn = vi.fn<() => Promise<string>>()
.mockRejectedValueOnce(new Error("503"))
.mockRejectedValueOnce(new Error("503"))
.mockResolvedValue("ok");
retry(fn);
vi.advanceTimersByTime(2000);
console.log(fn.mock.calls.length);const api = { now: () => "real" };
const spy = vi.spyOn(api, "now").mockReturnValue("fake");
const impl = vi.fn(() => "impl").mockReturnValue("override");
api.now();
vi.restoreAllMocks();
console.log(api.now(), spy.mock.calls.length, impl());Challenge
Build fn() and spyOn() by hand
FlagThe sandbox has no vi.fn or vi.spyOn, so build them. spy.ts holds a tiny test-double
library with two exports:
fn(impl?)— a new function that records the arguments of every call in.calls, and runsimpl(if given) to produce its result. This is a stub when you give it an answer to return, and a spy when you check.callsafterwards.spyOn(obj, "method")— swapsobj.methodfor a spy that still runs the original, and gives yourestore()to put the original back.
The grader also uses your fn as the doubles for checkout() in checkout.ts: a stub for the
exchange rate and a spy for the receipt email.
A spy has one hard rule: the code under test must not be able to tell it's there. Same
arguments in, same this, same return value out — it only watches. Every bug in the starter
breaks that rule, or breaks the cleanup that makes it safe to use in more than one test.
Your task
Edit spy.ts so the tests pass:
- A spy returns whatever its implementation returns
.callsholds every call's arguments, oldest firstspyOnstill runs the original method, with thethisit was called on — including a method spied on a class prototyperestore()puts the original method back on the object- Used as doubles for
checkout(), a stub's answer reaches the code and the spy records the one receipt sent