completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Mocks, spies & fakes

Learn how spies, stubs, fakes, fake timers, module mocks, and dependency injection isolate JavaScript tests from time and side effects.

By the end, you can
  • 01
    Name each test doubleTell a dummy, stub, spy, mock, and fake apart by the job each one does in a test.
  • 02
    Take control of outside thingsSwap the network, the clock, storage, and random numbers for stand-ins that give the same result on every run.
  • 03
    Write tests that break only for real bugsPass stand-ins in and use small working fakes, before you replace globals or check every call.

Why tests use stand-ins

A unit test checks one small piece of your code. It should fail only when that code is wrong. But code often uses outside things that the test can't control:

  • the network, which can be slow or down;
  • the clock, which keeps moving;
  • Math.random(), which gives a different number each time;
  • browser storage, which can still hold data from an earlier test;
  • outside services, like email or analytics, which should never get real messages from a test.
Definition

A test double is a stand-in that a test uses in place of a real function or object. The name comes from a stunt double in a film. There are five kinds: dummies, stubs, spies, mocks, and fakes. The test keeps your own code real and swaps out only the outside thing. This is called isolation: the test cuts your code off from the things it can't control.

The Unit testing lesson showed how to write and run tests. This lesson shows how to swap the outside things those tests touch. It uses ideas from closures, higher-order functions, timers, fetch, web storage, and pure functions.

Each row of the table names one outside thing, says why it causes trouble in a test, and shows how a test can take control of it.

Outside things a test should control
Outside thingWhy it causes troubleHow the test takes control
NetworkIt can be slow, fail at random, or cost money.Pass in a fetcher function, or give back a fake Response.
ClockReally waiting makes tests slow, and the result can depend on the time of day.Use a fake clock that runs timer callbacks when the test says so.
Random numbersA different number on each run can hide a bug.Pass in a random function, or use a seeded generator.
Browser storageData saved by one test can still be there in the next test.Make a fresh fake storage object for each test.
Outside servicesA test should never send real emails, payments, or analytics events.Pass in a stub or a spy instead of the real service.

The five kinds of test double

VOCABULARY

People often call every stand-in a "mock". But a mock is only one of five kinds. Each kind has its own job. The name tells you what the stand-in does in the test.

Real-life analogyThe classroom attendance register

A teacher writes each student's name in the attendance register. Later, anyone can read the register to see who came. But a name in the register does not prove the student learned anything. A spy works the same way. It writes down each call, but a call in the record does not prove the job worked.

In real life: The register gets a name
In JavaScript: A spy writes down a call
In real life: You read the register later
In JavaScript: The test reads the call record
In real life: A name doesn't prove the student learned
In JavaScript: A recorded call doesn't prove the job worked

Read each row as: this kind of double does this job, for example like this.

What each test double does
DoubleWhat it doesExample
DummyFills a parameter that the code never uses.sendEmail(user, nullLogger) when the code never touches the logger.
StubGives back a planned answer, so the code takes a known path.stub("id-1") stands in for a service that makes IDs.
SpyWrites down each call, and usually still runs the real code.spy(add).calls[0].args shows that the callback got 2, 3.
MockMakes the test fail unless a certain call happens.The test expects analytics.track("signup") exactly once.
FakeIs a small version of the real thing that really works.A repository that keeps records in memory, or a fake localStorage.

To name a double, ask what the test does with it:

  • The test never uses it: it is a dummy.
  • It gives back a fixed answer: it is a stub.
  • The test reads its call record afterward: it is a spy.
  • The test fails if an expected call doesn't happen: it is a mock.
  • It is a smaller working version of the real thing: it is a fake.

Spies record calls, stubs give answers

STEP THROUGH

A spy answers the question "what happened?" A stub answers the question "what should happen next?" The small helpers below are plain JavaScript functions, not part of a test library. Once you know them, the library versions are easier to read: mock.fn in Node, vi.fn in Vitest, and jest.fn in Jest.

Record a saved todoPop out in the code editor (opens in a new tab)JavaScript
const calls = [];
const onSave = (todo) => calls.push(todo);
onSave("tea");
console.log(calls.join(","));

Line 1 makes an empty array, calls. Line 2 makes a callback that pushes each todo into it. Line 3 calls it with "tea", so line 4 prints tea. This is a tiny spy. A record like this helps when the code you test gives nothing back, so there is no return value to check.

Step through a spy and a stub
Step 0 of 10Ready
Your turn: follow the blue line

A spy runs the real code and records each call. A stub gives back planned answers. Once you know these small helpers, library mocks are easier to read.

Running in
  1. script
Next: line 1
  const calls = [];  function wrapper(...args) {    try {      const returned = fn(...args);      calls.push({ args, returned });      return returned;    } catch (error) {      calls.push({ args, threw: error.message });      throw error;    }  }  wrapper.calls = calls;  return wrapper;} function stub(...values) {  let index = 0;  return spy(() => values[Math.min(index++, values.length - 1)]);} const add = spy((a, b) => a + b);console.log(add(2, 3));console.log(JSON.stringify(add.calls)); const nextId = stub("id-1", "id-2");console.log(nextId());console.log(nextId());console.log(JSON.stringify(nextId.calls.map((call) => call.returned)));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

Step through the helpers above. The spy does four things:

  1. It makes an empty calls array.
  2. It calls the real function with the same arguments.
  3. It pushes the arguments and the returned value into calls.
  4. It gives back the real result, so the code works the same as before.

The stub is simpler. It keeps a list of planned answers and hands back the next one on each call. In a real test, check the visible result first. Check the call record only when the call itself is part of what the code must do.

Left, a spy: calling add(2, 3) gives back the real result 5 and also writes a record, 2, 3 gave 5, into a box called add.calls. Right, a stub: calling nextId() takes the next planned answer from its list of id-1 and id-2, and gives back id-1.spy: recordsadd(2, 3)2, 3 → 5add.calls5returnedstub: answersnextId()id-1id-2plannedid-1returned
The spy runs the real add, gives back 5, and keeps a record of the call. The stub runs no real code; it hands back the next answer from its list.
What the spy and stub print
The spy and stub helpersPop out in the code editor (opens in a new tab)JavaScript
function spy(fn = () => undefined) {  const calls = [];  function wrapper(...args) {    try {      const returned = fn(...args);      calls.push({ args, returned });      return returned;    } catch (error) {      calls.push({ args, threw: error.message });      throw error;    }  }  wrapper.calls = calls;  return wrapper;} function stub(...values) {  let index = 0;  return spy(() => values[Math.min(index++, values.length - 1)]);} const add = spy((a, b) => a + b);console.log(add(2, 3));console.log(JSON.stringify(add.calls)); const nextId = stub("id-1", "id-2");console.log(nextId());console.log(nextId());console.log(JSON.stringify(nextId.calls.map((call) => call.returned)));
Console outputreal values
  1. 5
  2. [{"args":[2,3],"returned":5}]
  3. id-1
  4. id-2
  5. ["id-1","id-2"]
Try it yourself
Output of the code on the left

The spy runs the real add function and records one call. The stub gives back the planned IDs and records what it gave back.

Compare this output with the step-through above.
A recorded call can still failPop out in the code editor (opens in a new tab)JavaScript
const calls = [];
function save(todo) {
  calls.push(todo);
  throw new Error("offline");
}
try {
  save("tea");
} catch (error) {
  console.log(error.message + "|" + calls.join(","));
}

Line 3 records the save before line 4 throws an error. The catch block prints offline|tea. So a call record proves the code tried, not that it worked. Check the saved data or the returned value too. The bigger spy helper above also records calls that throw.

Use spies only where calls matter

Spies help with callbacks, event handlers, and outside services like analytics. But if a test checks every call to a private helper, it breaks each time you rearrange the code, even when the result is still right. Check the output or the changed data, unless the call itself is what the code must do.

Fake timers: the test controls the clock

STEP THROUGH

Timer code is hard to test when the test must really wait. A fake clock is a stand-in for the real clock. It does three things:

  • It stores each setTimeout callback instead of starting a real timer.
  • It keeps its own time, which you read with now().
  • It runs a callback only when the test moves time forward past that callback's due time.

This section uses only plain timer callbacks, with no promises. The next lesson, Testing async code, adds promises.

Cancel a save before it runsPop out in the code editor (opens in a new tab)JavaScript
const events = [];
const timer = setTimeout(() => events.push("saved"), 10);
clearTimeout(timer);
console.log(events.length);

Line 2 schedules a save in 10 milliseconds and keeps the timer ID that setTimeout gives back. Line 3 cancels that timer at once, so line 4 prints 0. Apps cancel waiting work like this when a page closes or a search box changes. With a fake clock, a test can move time forward and prove the save never runs.

A fake clock runs timers in due order
Step 0 of 8Ready
Your turn: follow the blue line

A fake clock runs timer callbacks right away, in due order, every time. Timers mixed with promises follow extra rules, which the next lesson covers.

Running in
  1. script
Next: line 1
  let current = start;  let nextId = 1;  const timers = [];  const sortTimers = () => timers.sort((a, b) => a.time - b.time || a.id - b.id);   const clock = {    now: () => current,    setTimeout(callback, delay = 0) {      const id = nextId++;      timers.push({ id, time: current + Math.max(0, delay), callback });      sortTimers();      return id;    },    clearTimeout(id) {      const index = timers.findIndex((timer) => timer.id === id);      if (index >= 0) timers.splice(index, 1);    },    advance(ms) {      const target = current + Math.max(0, ms);      sortTimers();      while (timers[0] && timers[0].time <= target) {        const timer = timers.shift();        current = timer.time;        timer.callback();        sortTimers();      }      current = target;    },    tick(ms) {      clock.advance(ms);    },    install(target = globalThis) {      const realSetTimeout = target.setTimeout;      const realClearTimeout = target.clearTimeout;      const realNow = target.Date?.now;      target.setTimeout = (callback, delay = 0, ...args) => clock.setTimeout(() => callback(...args), delay);      target.clearTimeout = (id) => clock.clearTimeout(id);      if (target.Date && realNow) target.Date.now = clock.now;      return () => {        target.setTimeout = realSetTimeout;        target.clearTimeout = realClearTimeout;        if (target.Date && realNow) target.Date.now = realNow;      };    },  };  return clock;} const clock = createFakeClock(100);const log = [];clock.setTimeout(() => log.push("slow@" + clock.now()), 30);clock.setTimeout(() => log.push("fast@" + clock.now()), 10);clock.advance(10);console.log(log.join(", "));clock.tick(20);console.log(log.join(", "));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
clock.advance(10). A box called now holds 110; it was 100. A cart called timers holds two timers sorted by due time: fast@110, then slow@130. An arrow leads to a console window that prints fast@110. The slow timer still waits.clock.advance(10)110nowwas 100fast@110slow@130timerssorted by due timeconsolefast@110slow waits
advance(10) moves fake time from 100 to 110. Only the timer due by 110 runs, so the console prints fast@110; the slow one waits for 130.

A test can use the fake clock in two ways:

  • Call clock.setTimeout directly. Nothing global changes.
  • Call clock.install(). It puts a fake setTimeout, clearTimeout, and Date.now on a target object for a while. It gives back a restore function that puts the real ones back.

Prefer the first way. Install the clock only around code that can't take a clock as a value.

What the fake clock prints
The fake clock codePop out in the code editor (opens in a new tab)JavaScript
function createFakeClock(start = 0) {  let current = start;  let nextId = 1;  const timers = [];  const sortTimers = () => timers.sort((a, b) => a.time - b.time || a.id - b.id);   const clock = {    now: () => current,    setTimeout(callback, delay = 0) {      const id = nextId++;      timers.push({ id, time: current + Math.max(0, delay), callback });      sortTimers();      return id;    },    clearTimeout(id) {      const index = timers.findIndex((timer) => timer.id === id);      if (index >= 0) timers.splice(index, 1);    },    advance(ms) {      const target = current + Math.max(0, ms);      sortTimers();      while (timers[0] && timers[0].time <= target) {        const timer = timers.shift();        current = timer.time;        timer.callback();        sortTimers();      }      current = target;    },    tick(ms) {      clock.advance(ms);    },    install(target = globalThis) {      const realSetTimeout = target.setTimeout;      const realClearTimeout = target.clearTimeout;      const realNow = target.Date?.now;      target.setTimeout = (callback, delay = 0, ...args) => clock.setTimeout(() => callback(...args), delay);      target.clearTimeout = (id) => clock.clearTimeout(id);      if (target.Date && realNow) target.Date.now = clock.now;      return () => {        target.setTimeout = realSetTimeout;        target.clearTimeout = realClearTimeout;        if (target.Date && realNow) target.Date.now = realNow;      };    },  };  return clock;} const clock = createFakeClock(100);const log = [];clock.setTimeout(() => log.push("slow@" + clock.now()), 30);clock.setTimeout(() => log.push("fast@" + clock.now()), 10);clock.advance(10);console.log(log.join(", "));clock.tick(20);console.log(log.join(", "));
Console outputdue order
  1. fast@110
  2. fast@110, slow@130
Try it yourself
No real waiting

The 10 ms timer runs when fake time reaches 110. The 30 ms timer waits until fake time reaches 130.

These timers use no promises. The next lesson adds promises to fake timers.
Pass a timer argument, then restore the real timersPop out in the code editor (opens in a new tab)JavaScript
const clock = createFakeClock(100);
const host = { setTimeout, clearTimeout, Date: { now: Date.now } };
const restore = clock.install(host);
try {
  host.setTimeout((todo) => console.log(todo + "@" + host.Date.now()), 10, "tea");
  clock.advance(10);
} finally {
  restore();
}
console.log(host.Date.now === Date.now);

Line 2 makes a host object that holds the real timer functions. Line 3 installs the fake clock on it. Line 5 passes "tea" as an extra argument, and the fake clock hands it to the callback. Line 6 moves time forward, so the callback prints tea@110.

The finally block runs even if a check throws an error. Line 8 inside it puts the real functions back, so line 10 prints true. This small clock handles only timer callbacks. It does not handle promises, setInterval, or every rule a browser follows for timers.

Dependency injection: pass the outside thing in

CHANGE INPUTS

A dependency is something your code needs from outside, like fetch or Date.now. Dependency injection means the code gets that thing passed in, instead of reaching for it by itself. You can pass it in three ways:

  • as a function parameter;
  • as a property of an options object;
  • as a constructor argument.

Your code stays ordinary. The real app passes the real thing, and a test passes a safe stand-in.

Load a todo without the networkPop out in the code editor (opens in a new tab)JavaScript
async function loadTodo(fetcher) {
  const response = await fetcher("/api/todo");
  return response.json();
}
loadTodo(async () => new Response('{"todo":"tea"}'))
  .then((data) => console.log(data.todo));

Line 1 takes a fetcher function as a parameter. Line 2 calls it to get the todo. Line 5 passes a stand-in that hands back a Response right away, with no network request. A Response is the object that fetch normally gives back. Line 3 reads its JSON body, so line 6 prints tea. The real app would pass the real fetch instead.

Change what the test passes in
Pass in fetch, the time, and randomPop out in the code editor (opens in a new tab)JavaScript
async function loadGreeting(userId, {  fetcher = fetch,  now = Date.now,  random = Math.random,} = {}) {  const response = await fetcher("/api/users/" + userId);  const user = await response.json();  const bucket = random() < 0.5 ? "A" : "B";  return user.name + " @ " + now() + " #" + bucket;} const fakeFetch = async () =>  new Response(JSON.stringify({ name: "Ada" }), {    headers: { "content-type": "application/json" },  }); loadGreeting(7, {  fetcher: fakeFetch,  now: () => 1000,  random: () => 0.25,}).then(console.log);
Resultsame on every run
fake userAda
now()1000
random()0.25
resultAda @ 1000 #A
Try it yourself

The code gets fetcher, now, and random passed in as values. So the test picks the time and the random number without changing anything global.

The fake fetcher gives back a real browser Response, so this code runs in the browser editor with no server.
The test passes three stand-in functions into loadGreeting: fakeFetch as fetcher, a function that returns 1000 as now, and a function that returns 0.25 as random. loadGreeting gives back Ada @ 1000 #A, the same text every run.the test passes infakeFetch() => 1000() => 0.25loadGreetingAda @ 1000 #Asame every run
The test passes in fetcher, now, and random. Nothing comes from the real network, clock, or dice, so loadGreeting gives back the same text every run.

The fake fetcher still gives back a real browser Response. So the code must still call response.json(), just as it would with the real network. The test picks the body, and no request leaves the page. The same idea works for Date.now and Math.random. Both are just functions, so you can pass in your own.

A 404 error still gives back a responsePop out in the code editor (opens in a new tab)JavaScript
async function loadTodo(fetcher) {
  const response = await fetcher("/api/todo");
  if (!response.ok) return "try again";
  return (await response.json()).todo;
}
const fakeFetch = async (url) => {
  console.log(url);
  return new Response("missing", { status: 404 });
};
loadTodo(fakeFetch).then(console.log);

Line 7 prints the address the code asked for, /api/todo. So the stand-in also shows which address was requested. Line 8 gives back a response with status 404, which means "not found". The promise does not fail. Line 3 sees that response.ok is false and returns try again.

So always check response.ok. The promise from fetch fails only for problems like a lost connection.

A seeded generator repeats its numbers
A seeded random number generatorPop out in the code editor (opens in a new tab)JavaScript
function mulberry32(seed) {  return function random() {    seed |= 0;    seed = (seed + 0x6D2B79F5) | 0;    let value = Math.imul(seed ^ (seed >>> 15), 1 | seed);    value = (value + Math.imul(value ^ (value >>> 7), 61 | value)) ^ value;    return ((value ^ (value >>> 14)) >>> 0) / 4294967296;  };} const random = mulberry32(123);console.log(random().toFixed(3));console.log(random().toFixed(3));
Console outputseed 123

0.787, 0.179

Try it yourself
Same seed, same numbers

Passing in random is usually simplest. When a bigger test needs many random-looking numbers, a seeded generator gives the same numbers on every run.

The exact numbers don't matter. What matters is that seed 123 always starts with these two.

A seeded generator is a function that makes numbers that look random, starting from a number called the seed. The same seed always gives the same list of numbers. To test just one branch, passing random: () => 0.25 is clearer. When a test needs many random-looking numbers, a seed lets you replay the exact numbers that made a test fail.

Sometimes you can't change the code to take a value. Then a test can replace a global, like Math.random, for a short time. This is called monkey-patching. Every test shares that global, so always put the real one back.

If you replace a global, put it back in finally
Replace Math.random, then put it backPop out in the code editor (opens in a new tab)JavaScript
const realRandom = Math.random;try {  Math.random = () => 0.1;  console.log(Math.random());} finally {  Math.random = realRandom;}console.log(Math.random === realRandom);
What it printsput back
inside try0.1
real one backtrue
Try it yourself
Use only as a last choice

Replacing a global can help with old code you can't change. But if you forget to put it back, every later test in the file gets the stand-in.

Passing in a random function is usually better. If you must replace the global, keep the change small and always put the real one back.

Fakes check results, mocks check calls

SORT IT

A fake is a small version of the real thing that really works. For example:

  • an in-memory repository that can save and find records;
  • a fake localStorage that keeps strings in a Map.

A mock, in the strict sense, makes the test fail unless certain calls happen. Both are useful, but they protect different things.

Remove a saved themePop out in the code editor (opens in a new tab)JavaScript
const data = new Map();
const storage = {
  getItem: (key) => data.get(key) ?? null,
  setItem: (key, value) => data.set(key, String(value)),
  removeItem: (key) => data.delete(key),
};
storage.setItem("theme", "dark");
storage.removeItem("theme");
console.log(storage.getItem("theme"));

Line 1 makes a Map to hold the data. Lines 3 to 5 give the fake the three storage methods the app uses. Line 7 saves the theme and line 8 removes it, so line 9 prints null. Make a fresh fake for each test. A shared Map can carry data from one test into the next, just like real storage.

A fake is a tiny working version
Fake storage kept in memoryPop out in the code editor (opens in a new tab)JavaScript
function createMemoryStorage() {  const data = new Map();  return {    getItem: (key) => data.get(key) ?? null,    setItem: (key, value) => data.set(key, String(value)),    removeItem: (key) => data.delete(key),  };} function saveTheme(storage, theme) {  storage.setItem("theme", theme);  return storage.getItem("theme");} const fakeStorage = createMemoryStorage();console.log(saveTheme(fakeStorage, "dark"));console.log(fakeStorage.getItem("theme"));
Console outputfake storage
  1. dark
  2. dark
Try it yourself
Fresh storage kept in memory

This fake works enough like localStorage for the test, but it never touches the browser's real, shared storage.

Use a fake when the result matters more than which calls ran. Use a mock when the call itself is what the code must do.

Use a fake when the caller shouldn't care which storage methods ran, or how many times. Use a mock when the call itself is the requirement, for example:

  • "send exactly one welcome email";
  • "record this analytics event with these fields".

A test is over-mocked when it checks calls that users and callers can't see, such as the order of private helper calls.

A passing call check can hide a broken screenPop out in the code editor (opens in a new tab)JavaScript
function showTodo(fetcher, track) {
  track("loaded");
  return "Loading...";
}
const calls = [];
const track = (event) => calls.push(event);
console.log(showTodo(() => ({ todo: "tea" }), track));
console.log(calls.includes("loaded"));

Line 2 records "loaded", but line 3 always returns Loading.... So line 7 prints Loading... and line 8 prints true. A test that checks only the call passes, even though the todo "tea" never shows. Check the shown todo, as well as any call the code must make.

Left: checking the call. A box called calls holds loaded, so calls.includes of loaded is true and the check passes. Right: checking the screen. A window called screen still shows Loading..., so the todo tea never shows.check the callloadedcallstrue: passescheck the screenscreenLoading...no tea: broken
The call check finds "loaded" and passes. But the screen still says Loading..., so the todo "tea" never shows. Check the result too.

Also keep one test that uses the real service. A fake with the wrong methods could let every small test pass while the real app fails.

Dummy, stub, spy, mock, or fake?
  • checkout(cart, nullLogger) and the logger is never read
  • nextId() always returns "id-1"
  • A wrapper keeps a calls list and still runs the real function
  • The test fails unless track("signup") happens exactly once
  • A repository built on a Map has working save and find methods
  • A fresh object with working getItem and setItem for each test
Try it yourself
0 of 6 correct

Put each card in the group of the double it describes. Dummies and stubs share the first group; the explanation for each card says which one it is.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Mocking modules with test tools

NODE + TOOLING

A module mock changes what an import gives your code. The test runner does the swap, so you don't change the code itself. That helps with old code you can't change. But it ties the test to file names and to the order of imports.

A test can't do this swap with a plain assignment, for two reasons:

  • An imported name is a live binding: a read-only link to a variable inside the module. Your test can't assign a new value to it.
  • Imports run before the test body starts.

So test runners give you special functions for module mocks. Browser import maps don't help either. An import map picks module addresses once, when the page loads, not once per test.

Each row of the table lists one test runner, the functions it gives you for doubles, and what to watch out for.

Mock functions in popular JavaScript test runners (2026)
Test runnerFunctions for doublesThings to know
node:testt.mock.fn, t.mock.method, t.mock.timersBuilt into Node 22. MockTimers and mock.module are still marked Stability 1, which means experimental. Module mocks need the --experimental-test-module-mocks flag.
Vitestvi.fn, vi.spyOn, vi.mock, vi.useFakeTimersMade for Vite projects. Vitest moves vi.mock calls to the top of the file, and a factory function can build the fake module.
Jestjest.fn, jest.spyOn, jest.mock, jest.useFakeTimersAn older, widely used tool. Turn fake timers off and undo module mocks between tests, so one test can't change the next.
Restore a method after replacing itPop out in the code editor (opens in a new tab)JavaScript
const api = { send: () => "real" };
const original = api.send;
try {
  api.send = () => "fake";
  console.log(api.send());
} finally {
  api.send = original;
}
console.log(api.send === original);

Line 2 saves the real method. Line 4 puts in a stand-in, so line 5 prints fake. Line 7 is inside finally, so it always puts the real method back. Line 9 prints true.

In a test file, put this cleanup for timers and spies in afterEach, which runs after every test. Then a failing check can't leave a stand-in behind for the next test. Restoring spies does not undo a module mock. Each runner has its own way to reset modules.

What the node:test mocks print
Mocks in node:testJavaScript
import assert from "node:assert/strict";import { test } from "node:test"; test("mock.fn, mock.method, and mock.timers", (t) => {  const send = t.mock.fn((value) => "sent:" + value);  assert.equal(send("ok"), "sent:ok");  console.log(send.mock.callCount());  console.log(send.mock.calls[0].arguments.join(","));   const api = { now: () => 0 };  t.mock.method(api, "now", () => 42);  console.log(api.now());   t.mock.timers.enable({ apis: ["Date", "setTimeout"], now: 100 });  const events = [];  setTimeout(() => events.push("timer@" + Date.now()), 50);  t.mock.timers.tick(50);  console.log(events.join(","));});
Lines the test loggedrun in Node
  1. 1
  2. ok
  3. 42
  4. timer@150
Try it yourself
Runs only in Node; the lesson test checks it

Node's test runner prints a report in a text format called TAP. The lines the test itself logs are 1, ok, 42, and timer@150.

This code can't run in the browser editor, because it imports parts of Node.

In Node 22.23.1, t.mock.fn and t.mock.method are stable parts of the test runner. MockTimers and mock.module are still marked Stability 1, which means experimental. mock.module only works with the --experimental-test-module-mocks flag. So use it as a last choice, and write the flag in your test command.

A node:test module mock needs a flagJavaScript
import assert from "node:assert/strict";import { test, mock } from "node:test"; test("mock.module replaces an ESM import", async () => {  mock.module("node:readline", {    namedExports: {      createInterface: () => ({ question: () => "fake" }),    },  });  const readline = await import("node:readline");  assert.equal(readline.createInterface().question(), "fake");  console.log(readline.createInterface().question());}); // Run with: node --experimental-test-module-mocks --test module-mock.test.mjs
Vitest: module and timer functionsJavaScript
import { afterEach, expect, test, vi } from "vitest";import { loadUser } from "./load-user.js"; vi.mock("./api.js", () => ({  fetchUser: vi.fn(() => ({ id: 7, name: "Ada" })),})); afterEach(() => {  vi.useRealTimers();  vi.restoreAllMocks();}); test("Vitest doubles", () => {  vi.useFakeTimers();  const callback = vi.fn();  setTimeout(callback, 100);  vi.advanceTimersByTime(100);  expect(callback).toHaveBeenCalledOnce();});
Jest: module and timer functionsJavaScript
jest.mock("./api", () => ({  fetchUser: jest.fn(() => ({ id: 7, name: "Ada" })),})); afterEach(() => {  jest.useRealTimers();  jest.restoreAllMocks();}); test("Jest doubles", () => {  jest.useFakeTimers();  const callback = jest.fn();  setTimeout(callback, 100);  jest.advanceTimersByTime(100);  expect(callback).toHaveBeenCalledTimes(1);});

The Vitest and Jest examples are only for reading, because this project doesn't install those tools. Both tools follow the same steps:

  • make mock functions with vi.fn or jest.fn;
  • spy on object methods;
  • mock modules through the runner;
  • use fake timers;
  • after each test, turn the real timers back on and restore every spy.

Common misconceptions

  • "Mock everything outside the function." Replace only the outside thing that makes the test slow, unreliable, unsafe, or different on each run. With too many mocks, the test checks the wiring, not what the code does.
  • "A spy is harmless because it only watches." A spy can still make a test break for no good reason, if you check private calls instead of the public result.
  • "Fake timers are only for async tests." They also help with plain setTimeout callbacks. Promises add their own timing rules; the next lesson covers them.
  • "Monkey-patching a global is the same as dependency injection." Patching changes something every test shares, so you must put it back. Injection keeps the stand-in inside one call.
  • "Fakes are less real than mocks." A fake often tests more real behavior than a mock, because callers use it through the same methods as the real thing.
  • "Module mocks are always the best way to swap things." They often depend on import order, the runner's settings, and file paths. Passing the stand-in in as a value is usually simpler.

Use this table to pick the simplest double. Find the question your test asks, then read which double to use and why.

Choosing the simplest double that works
What does the test need?UseWhy
Do I just need a branch to get known data?A stub.It is direct, and the test still checks the result.
Do I need to know that a callback ran?A spy.Here the call record is the behavior you care about.
Do I need something that saves and loads like storage?A fake.A small working version lets callers use the same methods as the real thing.
Is the exact call to an outside service the requirement?A mock.Then checking that call is checking the behavior.
Can I pass the outside thing in as a value?Dependency injection.Nothing global or shared has to change.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upPredict a spy record

Read the code. Type the exact text the last line prints.

Starter codePop out in the code editor (opens in a new tab)JavaScript
function spy(fn) {
  const calls = [];
  const wrapper = (...args) => {
    const value = fn(...args);
    calls.push({ args, value });
    return value;
  };
  wrapper.calls = calls;
  return wrapper;
}
const multiply = spy((a, b) => a * b);
console.log(multiply(2, 3) + "|" + multiply.calls.length);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upPredict a stub sequence

    What text prints after three calls to the stub? The values are separated by commas.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    function stub(...values) {
      let index = 0;
      return () => values[Math.min(index++, values.length - 1)];
    }
    const color = stub("red", "blue");
    console.log([color(), color(), color()].join(","));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeRun timers in due order

      Predict the order in which a fake clock should run these two callbacks.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const events = [];
      const timers = [
        { time: 20, run: () => events.push("slow") },
        { time: 5, run: () => events.push("fast") },
      ];
      timers.toSorted((a, b) => a.time - b.time).forEach((timer) => timer.run());
      console.log(events.join(","));

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeControl randomness

        Type the two groups this code prints.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const bucket = (random = Math.random) => (random() < 0.5 ? "A" : "B");
        console.log(bucket(() => 0.1) + "|" + bucket(() => 0.9));

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengePass fetch in before you mock a module

          You need to test code that calls fetch. What should you change in the code before you reach for a module mock, or replace the global fetch?

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            7 QUESTIONS
            Lesson quiz · 7 questionsScore: first tries count
            1. Question 1 of 7Which double gives back planned answers and doesn't care how many times it is called?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does this spy print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              function spy(fn) {
                const calls = [];
                const wrapper = (...args) => {
                  const value = fn(...args);
                  calls.push({ args, value });
                  return value;
                };
                wrapper.calls = calls;
                return wrapper;
              }
              const add = spy((a, b) => a + b);
              console.log(add(1, 4) + "|" + add.calls.length);

              Choose an answer to see the explanation.

            3. Question 3 of 7What does the fake timer queue print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const log = [];
              const timers = [
                { time: 30, run: () => log.push("slow") },
                { time: 10, run: () => log.push("fast") },
              ];
              timers.toSorted((a, b) => a.time - b.time).forEach((timer) => timer.run());
              console.log(log.join(","));

              Choose an answer to see the explanation.

            4. Question 4 of 7Why is dependency injection usually the first thing to try?

              Choose an answer to see the explanation.

            5. Question 5 of 7What should you know about mock.module in Node 22?

              Choose an answer to see the explanation.

            6. Question 6 of 7Which test is over-mocked?

              Choose an answer to see the explanation.

            7. Question 7 of 7What does the injected-random snippet print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const bucket = (random = Math.random) => (random() < 0.5 ? "A" : "B");
              console.log(bucket(() => 0.1) + "|" + bucket(() => 0.9));

              Choose an answer to see the explanation.

            Key takeaways

            • Test doubles cut your code off from time, the network, storage, random numbers, and outside services.
            • A dummy fills a parameter. A stub answers. A spy records. A mock expects a call. A fake really works.
            • Passing the outside thing in (dependency injection) is usually simpler and safer than replacing globals or modules.
            • A fake clock runs plain timer callbacks in due order, with no real waiting. Promises need extra care, in the next lesson.
            • Over-mocked tests check private calls, so they break when you rearrange code that still works.

            Remember the one-liner.
            Swap only the outside thing that makes a test change from run to run, then check the result your users or callers really depend on.

            Up next: Testing async code, where fake timers meet promises, errors in promises, and tests that pass only some of the time.

            CompleteFrontend Clear concepts. Working examples.