Testing & Quality
Testing & Quality

Test doubles: stubs, spies, mocks and fakes in Vitest

Full · ~40 min
After this unit you can
  • 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.spyOn and mockResolvedValue/mockImplementation, and undo what you replaced (Vitest 4's restoreAllMocks only restores vi.spyOn spies)
  • Explain why vi.mock is 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 advanceTimersByTimeAsync when the code awaits between timers
  • Spot over-mocking, and replace the network with MSW instead of stubbing your own client

Read

Simple
1 / 2

Four jobs a test double can do

Flag

A 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}`);
JobWhat it doesWhat you assert on
Stubreturns a fixed answernothing about the stub — it just steers the code
Spyrecords calls (and may still call the real thing)how it was called
Mocka double whose expected calls are the assertionthat the expected interaction happened
Fakeworks 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.

Medium
1 / 2

vi.mock is hoisted above your imports

Flag

When 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.

Complex

Over-mocking, the network boundary, and cleanup in Vitest 4

Flag

A 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 orders you 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?

Flag

A test does const spy = vi.spyOn(console, "error") and then triggers an error path. What happens to the output?

Flag

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?

Flag

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?

Flag

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?

Flag

In Vitest 4, afterEach(() => vi.restoreAllMocks()). Which of these does it undo?

Flag

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?

Flag

Why intercept requests with MSW (setupServer) rather than vi.stubGlobal("fetch", vi.fn())?

Flag

Fill in the blank

Stub the async exchange rate, then assert the receipt with the matcher that checks arguments.

Flag
const 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.

Flag
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.

Flag
const { fetchUserMock } = vi.(() => ({ fetchUserMock: vi.fn() }));
vi.mock("./api", () => ({ fetchUser: fetchUserMock }));

Replace only fetchUser and keep every other export of the real module.

Flag
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.

Flag
vi.();
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);
Flag
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());
Flag

Challenge

Build fn() and spyOn() by hand

Flag

The 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 runs impl (if given) to produce its result. This is a stub when you give it an answer to return, and a spy when you check .calls afterwards.
  • spyOn(obj, "method") — swaps obj.method for a spy that still runs the original, and gives you restore() 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
  • .calls holds every call's arguments, oldest first
  • spyOn still runs the original method, with the this it was called on — including a method spied on a class prototype
  • restore() 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

Loading the in-browser editor…

Further reading