Mocks, spies & fakes
Learn how spies, stubs, fakes, fake timers, module mocks, and dependency injection isolate JavaScript tests from time and side effects.
- 01Name each test doubleChoose dummy, stub, spy, mock, or fake by the job it performs in a test.
- 02Control side effectsReplace time, network, storage, and randomness with deterministic seams.
- 03Mock without brittlenessPrefer dependency injection and useful fakes before patching globals or asserting every call.
Isolation, plainly
A good unit test should fail because your code changed, not because the network is slow, the clock crossed midnight, Math.random() picked a different branch, browser storage leaked from another test, or an analytics call reached the real service.
Mocks, spies, stubs, dummies, and fakes are test doubles: small replacements you use so a test can control or observe a collaborator. The goal is isolation: keep the code under test real while replacing time, the network, randomness, storage, or slow side-effecting systems.
The previous Unit testing lesson focuses on runners, assertions, Arrange-Act-Assert, and test-driven development. Here we focus on the seams those tests use. You will reuse ideas from closures, higher-order functions, timers, fetch, web storage, and pure functions.
| Outside force | Why it hurts tests | Typical seam |
|---|---|---|
| Network | Slow, flaky, expensive, and outside your control. | Inject fetcher or return a fake Response. |
| Clock | Real waiting makes tests slow and order-dependent. | Use a fake clock for synchronous timer callbacks. |
| Randomness | Different results hide regressions. | Inject random or use a seeded PRNG. |
| Storage | Browser state leaks across tests. | Use a fresh fake storage object per test. |
| Side effects | Emails, payments, logs, and analytics should not fire in tests. | Pass a stub or spy collaborator. |
The five doubles
VOCABULARYPeople often say “mock” for every replacement. In precise test language, a mock is only one member of a family. The names matter because they tell you whether the double supplies data, records calls, enforces expectations, or behaves like a small real system.
Tests use doubles the way a production team uses stunt doubles and simulators: avoid danger and cost while keeping the scene meaningful. The mistake is filming only the stunt coordinator’s checklist and forgetting the story the audience sees.
- In real life: A mannequin fills a seat
- In JavaScript: A dummy fills an unused parameter
- In real life: A stunt double performs a planned move
- In JavaScript: A stub returns a planned value
- In real life: A camera records the stunt
- In JavaScript: A spy records calls and returns
- In real life: A director demands an exact cue
- In JavaScript: A mock expects a specific interaction
- In real life: A simulator behaves like the cockpit
- In JavaScript: A fake has lightweight working behavior
Where the analogy stops: A film crew can watch every angle without changing the scene. A test double changes the system boundary, so use the smallest double that keeps the important behavior honest.
| Double | Job | Example |
|---|---|---|
| Dummy | Fills a required parameter but is not used. | sendEmail(user, nullLogger) when the logger is ignored. |
| Stub | Returns canned data so the path is predictable. | stub("id-1") replaces an ID service. |
| Spy | Records calls while usually keeping behavior real. | spy(add).calls[0].args proves the callback received 2, 3. |
| Mock | Sets an expectation about an interaction. | Expecting analytics.track("signup") once. |
| Fake | A working lightweight implementation. | An in-memory repository or fake localStorage. |
The table gives you a quick smell test. If the double is never inspected, it is probably a dummy. If it returns a fixed value, it is a stub. If you inspect calls afterward, it is a spy. If the test declares an expected call as the behavior, it is a mock. If it implements a smaller working system, it is a fake.
Spies and stubs: observe or answer
STEP THROUGHA spy answers “what happened?” A stub answers “what should happen next?” The tiny helpers below are real JavaScript functions, not a test-library shortcut. They make the mechanics clear before you reach for mock.fn, vi.fn, or jest.fn.
A spy observes real behavior. A stub supplies planned behavior. Step through the tiny helpers before using library mocks.
script
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)));Line by line, the spy creates an array, calls the original function, pushes the arguments and return value, then returns the original result. The stub is intentionally simpler: it chooses a canned return value from a list. In a real test you would assert the visible result first, then use the call history only when the interaction is part of the behavior.
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)));5[{"args":[2,3],"returned":5}]id-1id-2["id-1","id-2"]
The spy forwards to the real add function and records one call. The stub returns the planned IDs and records its own returns.
Spies are helpful for callbacks, event handlers, and boundaries like analytics. They become brittle when you assert every private helper call. Prefer checking the output or state change unless the call itself is the product requirement.
Fake timers: make the clock a collaborator
STEP THROUGHTimer code is hard to test if the test must really wait. A fake clock stores callbacks, keeps a fake now(), and runs due setTimeout callbacks when the test advances time. This section covers synchronous timer callbacks. The next lesson, Testing async code, adds promises, microtasks, rejections, and flaky async timing.
Fake timers let tests run synchronous timer callbacks immediately and deterministically. Promise timers add extra microtask rules, which the next lesson covers.
script
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) => clock.setTimeout(callback, 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(", "));The helper has two seams. Calling clock.setTimeout avoids globals entirely. Calling clock.install() temporarily replaces setTimeout, clearTimeout, and Date.now on a target object and returns a restore function. Prefer the explicit clock object; install only around code that cannot accept one.
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) => clock.setTimeout(callback, 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(", "));fast@110fast@110, slow@130
The 10ms callback runs when fake time reaches 110. The 30ms callback waits until fake time reaches 130.
Dependency injection is the simplest seam
CHANGE INPUTSDependency injection means passing the outside thing in: fetcher, now, random, a repository, or a storage object. It can be a function parameter, an options object, or a constructor argument. The code under test stays ordinary, and the test chooses safe replacements.
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);Adanow()1000random()0.25Ada @ 1000 #AThe source passes fetcher, now, and random as values. Tests can choose time and randomness without touching globals.
fetcher returns a browser Response, so the snippet runs in Chrome's editor without a server.Notice that the fake network still returns a real browser Response. That keeps the consuming code honest: it must still call response.json(). The test controls the body without opening a socket. The same pattern works for Date.now and Math.random because both are just functions when you pass them in.
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));0.787, 0.179
Injecting random is usually simplest. When a larger test needs many random-looking values, a seeded PRNG gives repeatable numbers.
A seeded pseudo-random generator is useful when a test needs a stream of random-looking values. For one branch, injected random: () => 0.25 is clearer. For a property test or simulation, a seed lets you reproduce the exact failing sequence.
const realRandom = Math.random;try { Math.random = () => 0.1; console.log(Math.random());} finally { Math.random = realRandom;}console.log(Math.random === realRandom);0.1trueMonkey-patching can be useful around legacy code, but one missing restore can poison every later test in the file.
random function. When you must patch, keep the patch tiny and always restore.Fakes vs mocks: behavior beats choreography
SORT ITA fake implements enough behavior to test through a boundary. An in-memory repository can save and find records. A fake localStorage can keep strings in a Map. A mock, in the narrow sense, makes exact calls the requirement. Both are useful, but they protect different things.
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"));darkdark
The fake storage behaves enough like localStorage for this test, but it never touches the browser's real shared storage.
Prefer a fake when callers should not care how many lower-level methods were used. Prefer a mock when the interaction is the contract: “send exactly one welcome email” or “record this analytics event with these fields.” Over-mocking happens when a test asserts internal call order that users and callers cannot observe.
checkout(cart, nullLogger)and the logger is never readnextId()always returns"id-1"- A wrapper keeps
callswhile forwarding to the real function - The test fails unless
track("signup")happens exactly once - A
Map-backed repository implementssaveandfind - A fresh object implements
getItemandsetItemfor each test
Sort each card by the closest double. The first bucket combines dummies and stubs because the component supports up to four buckets; the explanation tells them apart.
Mocking modules and tools
NODE + TOOLINGModule mocking is a stronger seam than dependency injection. It changes what an import resolves to, so it can help legacy code but also couples tests to file structure. ESM imports are live bindings and are evaluated before your test body, so test runners provide special APIs rather than plain assignment. Browser import maps choose module URLs up front; they are not a per-test mocking system.
| Runner | APIs | Notes |
|---|---|---|
| node:test | t.mock.fn, t.mock.method, t.mock.timers | Built into Node 22. MockTimers and mock.module are still Stability 1; module mocks need --experimental-test-module-mocks. |
| Vitest | vi.fn, vi.spyOn, vi.mock, vi.useFakeTimers | Works with Vite projects; vi.mock is hoisted and can mock modules with a factory. |
| Jest | jest.fn, jest.spyOn, jest.mock, jest.useFakeTimers | Mature API; restore timers and module mocks between tests to avoid leaks. |
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(","));});1ok42timer@150
Node's test runner prints TAP. The console lines from the test are 1, ok, 42, and timer@150.
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 Stability 1 APIs. mock.module is only available with --experimental-test-module-mocks, so treat it as a last resort and document the flag in your test command.
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.mjsimport { 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()); test("Vitest doubles", () => { vi.useFakeTimers(); const callback = vi.fn(); setTimeout(callback, 100); vi.advanceTimersByTime(100); expect(callback).toHaveBeenCalledOnce();});jest.mock("./api", () => ({ fetchUser: jest.fn(() => ({ id: 7, name: "Ada" })),})); afterEach(() => jest.useRealTimers()); test("Jest doubles", () => { jest.useFakeTimers(); const callback = jest.fn(); setTimeout(callback, 100); jest.advanceTimersByTime(100); expect(callback).toHaveBeenCalledTimes(1);});Vitest and Jest examples are shown as tooling references only because this project does not install those runners. Their docs use the same pattern: create mock functions with vi.fn or jest.fn, spy on object methods, mock modules through the runner, use fake timers, then restore real timers after each test.
Common misconceptions
- “Mock everything outside the function.” Replace only the boundary that makes the test slow, flaky, unsafe, or nondeterministic. Too many mocks test wiring, not behavior.
- “A spy is harmless because it observes.” A spy can still make a test brittle if you assert internal calls instead of the public result.
- “Fake timers are only for async tests.” They are also useful for synchronous
setTimeoutcallbacks. Promise timing adds microtasks, covered next. - “Monkey-patching globals is the same as dependency injection.” Patching changes shared state and must be restored. Injection keeps the replacement local.
- “Fakes are less real than mocks.” A fake can be more behavior-focused than a mock because callers exercise it through the same public API.
- “Module mocks are always better seams.” They often depend on import order, runner transforms, and file paths. Passing a collaborator is usually simpler.
| Question | Prefer | Why |
|---|---|---|
| Do I just need a branch to return known data? | Stub | It is direct and keeps assertions on the result. |
| Do I need to know a callback fired? | Spy | The call record is the relevant behavior. |
| Do I need storage-like behavior? | Fake | A small implementation lets callers use the real API shape. |
| Is the exact external interaction the requirement? | Mock | Then an expectation on the call is the behavior. |
| Can I pass the dependency as a value? | Dependency injection | It avoids global and module patching. |
Practice exercises
5 EXERCISESRead the code and type the exact text printed by the final line.
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);The spy forwards to the real multiply function, then stores one call, so the output is 6|1.
What comma-separated text prints after three calls to the stub?
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(","));The stub returns red, then blue, then repeats the last value blue.
Predict the order a fake clock should run these two callbacks.
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(","));The fake queue runs the 5ms timer before the 20ms timer, so it prints fast,slow.
Type the two buckets printed by the injected-random example.
const bucket = (random = Math.random) => (random() < 0.5 ? "A" : "B");
console.log(bucket(() => 0.1) + "|" + bucket(() => 0.9));Injected random functions make both branches deterministic: 0.1 chooses A and 0.9 chooses B.
You need to test code that calls fetch. What seam should you add before reaching for a module mock or global monkey patch?
async function loadUser(id, fetcher = fetch) {
const response = await fetcher('/users/' + id);
return response.json();
}Add a dependency-injection seam first: pass fetcher as a parameter or option. Module mocking is a fallback for code you cannot change.
Check your understanding
7 QUESTIONSQuestion 1 of 7Which double returns canned values but does not care how many times it was called?
Choose an answer to see the explanation.
Question 2 of 7What does this spy print?
Read the code, then predictfunction 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.
Question 3 of 7What does the fake timer queue print?
Read the code, then predictconst 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.
Question 4 of 7Why is dependency injection usually the first seam to try?
Choose an answer to see the explanation.
Question 5 of 7What should a Node 22 test remember about
mock.module?Choose an answer to see the explanation.
Question 6 of 7Which test is over-mocked?
Choose an answer to see the explanation.
Question 7 of 7What does the injected-random snippet print?
Read the code, then predictconst 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 isolate code from time, network, storage, randomness, and side effects.
- Dummies fill unused parameters; stubs answer; spies record; mocks expect; fakes work.
- Dependency injection is usually simpler and safer than monkey-patching globals or modules.
- Fake clocks make synchronous timer callbacks deterministic; promises need extra care next.
- Over-mocking makes tests brittle when they assert private implementation details.
Remember the one-liner.
Replace only the outside force that makes a test nondeterministic, then assert the behavior your users or callers actually depend on.
Up next: Testing async code, where fake timers meet promises, rejections, and flaky scheduling.