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 doubleTell a dummy, stub, spy, mock, and fake apart by the job each one does in a test.
- 02Take control of outside thingsSwap the network, the clock, storage, and random numbers for stand-ins that give the same result on every run.
- 03Write 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.
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 thing | Why it causes trouble | How the test takes control |
|---|---|---|
| Network | It can be slow, fail at random, or cost money. | Pass in a fetcher function, or give back a fake Response. |
| Clock | Really 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 numbers | A different number on each run can hide a bug. | Pass in a random function, or use a seeded generator. |
| Browser storage | Data saved by one test can still be there in the next test. | Make a fresh fake storage object for each test. |
| Outside services | A 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
VOCABULARYPeople 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.
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.
| Double | What it does | Example |
|---|---|---|
| Dummy | Fills a parameter that the code never uses. | sendEmail(user, nullLogger) when the code never touches the logger. |
| Stub | Gives back a planned answer, so the code takes a known path. | stub("id-1") stands in for a service that makes IDs. |
| Spy | Writes down each call, and usually still runs the real code. | spy(add).calls[0].args shows that the callback got 2, 3. |
| Mock | Makes the test fail unless a certain call happens. | The test expects analytics.track("signup") exactly once. |
| Fake | Is 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 THROUGHA 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.
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.
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.
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)));Step through the helpers above. The spy does four things:
- It makes an empty
callsarray. - It calls the real function with the same arguments.
- It pushes the arguments and the returned value into
calls. - 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.
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.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 runs the real add function and records one call. The stub gives back the planned IDs and records what it gave back.
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.
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 THROUGHTimer 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
setTimeoutcallback 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.
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 timer callbacks right away, in due order, every time. Timers mixed with promises follow extra 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, ...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(", "));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.setTimeoutdirectly. Nothing global changes. - Call
clock.install(). It puts a fakesetTimeout,clearTimeout, andDate.nowon a target object for a while. It gives back arestorefunction 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.
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(", "));fast@110fast@110, slow@130
The 10 ms timer runs when fake time reaches 110. The 30 ms timer waits until fake time reaches 130.
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 INPUTSA 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.
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.
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 code gets fetcher, now, and random passed in as values. So the test picks the time and the random number without changing anything global.
fetcher gives back a real browser Response, so this code runs in the browser editor with no server.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.
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.
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
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.
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.
const realRandom = Math.random;try { Math.random = () => 0.1; console.log(Math.random());} finally { Math.random = realRandom;}console.log(Math.random === realRandom);0.1trueReplacing 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.
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 ITA 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
localStoragethat keeps strings in aMap.
A mock, in the strict sense, makes the test fail unless certain calls happen. Both are useful, but they protect different things.
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.
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
This fake works enough like localStorage for the test, but it never touches the browser's real, shared storage.
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.
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.
"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.
checkout(cart, nullLogger)and the logger is never readnextId()always returns"id-1"- A wrapper keeps a
callslist and still runs the real function - The test fails unless
track("signup")happens exactly once - A repository built on a
Maphas workingsaveandfindmethods - A fresh object with working
getItemandsetItemfor each test
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.
Mocking modules with test tools
NODE + TOOLINGA 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.
| Test runner | Functions for doubles | Things to know |
|---|---|---|
| node:test | t.mock.fn, t.mock.method, t.mock.timers | Built into Node 22. MockTimers and mock.module are still marked Stability 1, which means experimental. Module mocks need the --experimental-test-module-mocks flag. |
| Vitest | vi.fn, vi.spyOn, vi.mock, vi.useFakeTimers | Made for Vite projects. Vitest moves vi.mock calls to the top of the file, and a factory function can build the fake module. |
| Jest | jest.fn, jest.spyOn, jest.mock, jest.useFakeTimers | An older, widely used tool. Turn fake timers off and undo module mocks between tests, so one test can't change the next. |
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.
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 a report in a text format called TAP. The lines the test itself logs 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 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.
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(); vi.restoreAllMocks();}); 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(); 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.fnorjest.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
setTimeoutcallbacks. 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.
| What does the test need? | Use | Why |
|---|---|---|
| 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 EXERCISESRead the code. Type the exact text the last line prints.
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 runs the real multiply function, which returns 6, and records one call. The left side of + runs first, so calls.length is already 1. The output is 6|1.
What text prints after three calls to the stub? The values are separated by commas.
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 blue again, because it repeats the last value. The output is red,blue,blue.
Predict the order in which 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 timers run sorted by time. The 5 ms timer runs before the 20 ms timer, so the code prints fast,slow.
Type the two groups this code prints.
const bucket = (random = Math.random) => (random() < 0.5 ? "A" : "B");
console.log(bucket(() => 0.1) + "|" + bucket(() => 0.9));Passing in the random function makes both branches the same on every run: 0.1 picks A and 0.9 picks B. The output is A|B.
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?
async function loadUser(id, fetcher = fetch) {
const response = await fetcher('/users/' + id);
return response.json();
}Use dependency injection: pass fetcher in as a parameter or an option, with the real fetch as the default. Keep module mocks for code you can't change.
Check your understanding
7 QUESTIONSQuestion 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.
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 thing to try?
Choose an answer to see the explanation.
Question 5 of 7What should you know about
mock.modulein Node 22?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 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.