The previous lesson left 48 green tests in under a second… and half the application uncovered. local-repository.js at 31% because localStorage does not exist in Node; tasks-api.js without a single test because testing it would mean calling a server that does not exist; the debounce waiting a real 300 milliseconds per test; the exponential-backoff retries in http.js taking almost two seconds each. Network, clock, storage and randomness: the four dependencies that turn a fast, reliable suite into a slow, flaky one. This lesson teaches you to replace them with controlled pieces. You will learn the precise vocabulary —dummy, stub, spy, mock and fake are not synonyms—, master jest.fn() and jest.spyOn, mock whole modules, test tasks-api.js with no network while checking that ApiError comes out with the right status, freeze the clock so isOverdue does not depend on the day the tests run, and see why dependency injection is usually a better idea than aggressive mocking.
Contents
- The four dependencies that ruin a test
- What a test double is
- The precise vocabulary: dummy, stub, spy, mock and fake
jest.fn(): the spy function- Assertions about calls
- Programming what a double returns
jest.spyOn: observing without replacing- Mocking whole modules
- Mocking only part of a module
- Mocking
fetch: testingtasks-api.jswith no network - The four network scenarios you have to cover
- Mocking the clock: fake timers
- Testing
debouncewithout waiting - Testing exponential-backoff retries
- Freezing the date:
TODAYwith no surprises - Mocking
localStoragewith an in-memory double - Dependency injection versus aggressive mocking
- Tests coupled to the implementation
- Common Mistakes and Tips
- Exercises
- Conclusion
- The four dependencies that ruin a test
A good test has three properties: it is fast, it is deterministic (the same result every time) and it is isolated (it depends on nothing external). These four dependencies destroy all three:
| Dependency | What it breaks | Example in Nómada Tasks |
|---|---|---|
| The network | Speed, determinism, isolation | listTasks() calls api.tallernomada.example, which never resolves |
| The clock | Determinism and speed | isOverdue() with no argument; the sleep() inside withRetries |
| Storage | Isolation (state that survives between tests) | localStorage, which on top of everything does not exist in Node |
| Randomness | Determinism | The jitter with Math.random() in withRetries |
And the real cost, measured:
Without doubles: tasks-api.test.js ✗ fails: getaddrinfo ENOTFOUND api.tallernomada.example time.test.js ✓ 4 tests · 1.4 s (4 × 350 ms of real waiting) http.test.js ✓ 3 tests · 6.8 s (real backoff retries) repository.test.js ✗ fails: localStorage is not defined With doubles: tasks-api.test.js ✓ 12 tests · 0.09 s time.test.js ✓ 4 tests · 0.02 s http.test.js ✓ 8 tests · 0.05 s repository.test.js ✓ 11 tests · 0.03 s
Eight seconds and two broken suites versus two tenths and 35 tests. And the important part is not the speed: it is that tests using the network sometimes fail when nothing is wrong, and a suite that fails for no reason stops being looked at within two weeks.
- What a test double is
A test double is an object that stands in for a real dependency during a test. The term comes from cinema: the stunt double replaces the actor in the dangerous scene.
flowchart LR
subgraph P["In production"]
A1["listTasks"] --> B1["real fetch"] --> C1["Server<br/>api.tallernomada"]
end
subgraph T["In the test"]
A2["listTasks"] --> B2["DOUBLE fetch"] --> C2["Response<br/>fabricated here"]
end
style C2 fill:#bbf7d0,stroke:#15803d
style C1 fill:#fecaca,stroke:#b91c1c
The code under test —listTasks— has no idea: it calls fetch as always. What changes is what sits on the other side. That makes three things possible that the real dependency does not allow:
- Controlling the response: a 200 with six tasks now, a 500 next, a network failure after that. Provoking a 500 on a real server is hard; on a double it is one line.
- Observing the calls: was
fetchcalled with the right URL? With thePATCHmethod? How many times? - Going as fast as you like: no latency, no waiting, no real timers.
- The precise vocabulary: dummy, stub, spy, mock and fake
In practice almost everybody says "mock" for everything, and Jest makes it worse by calling jest.fn() an object that usually acts as a spy. But the five terms name different things, and knowing them helps you decide which one you need.
| Type | What it does | Does it return values? | Do you assert on it? | Example in Nómada Tasks |
|---|---|---|---|---|
| Dummy | Nothing. It just fills a mandatory slot | No | No | A signal passed because the signature demands it and this test does not cancel |
| Stub | Returns fixed responses | Yes | No | A fetch that always returns the canonical backlog |
| Spy | Records how it is called, letting the real call through | Optionally | Yes | Watching that repository.save is called on a status change |
| Mock | Stub + expectations about how it should be called | Yes | Yes | A fetch that returns 201 and whose method, URL and body get checked |
| Fake | A real but simplified implementation | Yes | No | An in-memory localStorage built on a Map |
One example of each, on the project:
// ── DUMMY · just fills a slot. Not used, not asserted on ───────────────
const dummySignal = new AbortController().signal;
await listTasks({ assignee: 'Iván' }, { signal: dummySignal });
// ── STUB · a fixed response; nobody looks at how it was called ─────────
const fetchStub = jest.fn().mockResolvedValue(
new Response(JSON.stringify(backlogData), { status: 200,
headers: { 'content-type': 'application/json' } })
);
// ── SPY · observes and (here) lets the real call through ───────────────
const spy = jest.spyOn(repository, 'save');
board.changeStatus(2, 'in-progress');
expect(spy).toHaveBeenCalledTimes(1); // ← the check IS the point
// ── MOCK · a programmed response AND expectations about the call ───────
const fetchMock = jest.fn().mockResolvedValue(
new Response(JSON.stringify({ id: 7 }), { status: 201,
headers: { 'content-type': 'application/json' } })
);
await createTask(newData);
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining('/tasks'),
expect.objectContaining({ method: 'POST' })
);
// ── FAKE · a real implementation, simplified ───────────────────────────
function fakeStore(initial = {}) {
const map = new Map(Object.entries(initial));
return {
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => { map.set(k, String(v)); },
removeItem: (k) => { map.delete(k); },
clear: () => map.clear(),
get length() { return map.size; }
};
}The distinction that matters most in practice is the one separating stub from mock, because it determines where you put the assertion:
- With a stub, the assertion is about the result: "
listTasksreturned sixTaskinstances". - With a mock, the assertion is about the interaction: "
fetchwas called withPATCHand this body".
The first tests behavior; the second tests implementation. The first survives a refactor; the second does not always. That is the subject of section 18, and it is worth keeping in mind from now on: use stubs by default and mocks only when the interaction is itself the behavior you want to guarantee (that a DELETE gets sent and not a PATCH, for example, really is observable behavior).
jest.fn(): the spy function
jest.fn(): the spy functionjest.fn() creates a function that records everything that happens to it.
import { jest } from '@jest/globals'; // ← with ES modules, jest is IMPORTED
const onCreate = jest.fn();
onCreate({ id: 7, title: 'Check the fire extinguishers' });
onCreate({ id: 8, title: 'Buy black screen-printing ink' });
console.log(onCreate.mock.calls);
// [ [{ id: 7, title: 'Check the fire extinguishers' }],
// [{ id: 8, title: 'Buy black screen-printing ink' }] ]That import { jest } from '@jest/globals' is mandatory with the native ES module route you configured in 08-03. With CommonJS, jest is an injected global; with ESM it is not, and forgetting the import produces a ReferenceError: jest is not defined that is quite confusing the first time.
The .mock property is the complete record:
| Property | Contains |
|---|---|
.mock.calls |
An array of arrays: the arguments of every call |
.mock.results |
What each call returned (or threw) |
.mock.instances |
The this of each call, if it was used with new |
.mock.lastCall |
The arguments of the last call |
onCreate.mock.calls.length; // 2 → how many times it was called
onCreate.mock.calls[0][0]; // { id: 7, … } → first argument of the first call
onCreate.mock.lastCall[0]; // { id: 8, … } → first argument of the last oneYou can give it a body from the start:
const nextId = jest.fn(() => 7); // an initial implementation
const log = jest.fn((level, event) => `[${level}] ${event}`);
- Assertions about calls
Jest ships matchers specific to doubles, far more readable than inspecting .mock.calls by hand:
| Matcher | Checks |
|---|---|
toHaveBeenCalled() |
It was called at least once |
toHaveBeenCalledTimes(n) |
It was called exactly n times |
toHaveBeenCalledWith(...args) |
Some call had those arguments |
toHaveBeenLastCalledWith(...args) |
The last call had those arguments |
toHaveBeenNthCalledWith(n, ...args) |
Call number n had those arguments |
toHaveReturnedWith(v) |
It returned that value at some point |
not.toHaveBeenCalled() |
It was never called |
test('creating a task notifies the listener with the created task', () => {
const onCreate = jest.fn();
const form = { title: 'Check the fire extinguishers', estimatedHours: 2 };
createFromForm(form, { onCreate });
expect(onCreate).toHaveBeenCalledTimes(1);
expect(onCreate).toHaveBeenCalledWith(expect.objectContaining({
title: 'Check the fire extinguishers',
status: 'pending' // R5: it starts out pending
}));
});The asymmetric matchers in that example are essential when you do not want to tie yourself to the complete object:
expect.anything() // anything except null and undefined
expect.any(Number) // any number (or String, Function, Task…)
expect.objectContaining({ a: 1 }) // an object containing AT LEAST that property
expect.arrayContaining([1, 2]) // an array containing at least those elements
expect.stringContaining('/tasks') // a string containing that text
expect.stringMatching(/^https:/) // a string matching the regular expression
expect.closeTo(8.16, 2) // a floating-point number with toleranceWithout them, checking a fetch call would force you to write the exact URL with all its encoded parameters and the complete options object; with them, you check only what matters to this test:
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/tasks/3'),
expect.objectContaining({ method: 'PATCH' })
);And an important warning: toHaveBeenCalledWith compares arguments with toEqual semantics (structural, recursive). But if the argument is an object the code mutates afterwards, you will be comparing its current state, not the one it had at call time. It is the same trap as the console in 08-01: if you need the snapshot, clone it inside the double itself.
// A double that keeps a COPY of what it receives
const save = jest.fn((board) => savedStates.push(structuredClone(board.toJSON())));
- Programming what a double returns
A freshly created jest.fn() returns undefined. These methods give it behavior:
| Method | What it does |
|---|---|
mockReturnValue(v) |
Returns v every time |
mockReturnValueOnce(v) |
Returns v only next time (chainable) |
mockResolvedValue(v) |
Returns a promise resolved with v |
mockRejectedValue(e) |
Returns a promise rejected with e |
mockResolvedValueOnce(v) / mockRejectedValueOnce(e) |
The one-shot versions |
mockImplementation(fn) |
Runs fn as the body |
mockImplementationOnce(fn) |
Only next time |
The ...Once variants are what let you simulate sequences, and that is where 90% of their value lies. A real example: testing that withRetries keeps trying after two failures and eventually succeeds.
const request = jest.fn()
.mockRejectedValueOnce(new ApiError('503', { status: 503, code: 'server' }))
.mockRejectedValueOnce(new ApiError('503', { status: 503, code: 'server' }))
.mockResolvedValue({ tasks: backlogData }); // from the third one on, success
const result = await withRetries(request, { attempts: 3, baseMs: 0 });
expect(request).toHaveBeenCalledTimes(3);
expect(result.tasks).toHaveLength(6);Three programmed failures, one verified behavior, zero milliseconds of waiting. Reproducing that against a real server would be, quite simply, impossible.
mockImplementation is for when the response depends on the argument:
const find = jest.fn((id) => backlogData.find((t) => t.id === id) ?? null);
expect(find(3).title).toBe('Update the bookings website');
expect(find(99)).toBeNull();And three cleanup methods worth telling apart, because confusing them causes odd failures:
| Method | Clears the call record | Clears the implementation | Restores the original |
|---|---|---|---|
mockClear() |
✅ | ❌ | ❌ |
mockReset() |
✅ | ✅ | ❌ |
mockRestore() |
✅ | ✅ | ✅ (only with spyOn) |
And the way never to have to remember:
// jest.config.js
export default {
restoreMocks: true, // automatic mockRestore() after each test
clearMocks: true // automatic mockClear() before each test
};With those two lines, state shared between tests —the problem from section 11 of 08-03— stops being a risk for doubles too.
jest.spyOn: observing without replacing
jest.spyOn: observing without replacingjest.fn() creates a new function. jest.spyOn(object, 'method') wraps an existing method, and by default lets the real call through.
test('changing status causes exactly one save', () => {
const repository = new LocalRepository({ store: fakeStore() });
const board = aBoard();
connectPersistence(board, repository);
const spy = jest.spyOn(repository, 'save'); // observes, does NOT replace
board.changeStatus(2, 'in-progress');
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(board);
expect(repository.load().total).toBe(6); // ← the REAL save happened
});That last line is the essential difference: with a plain spyOn, the original method runs and its effects are real. You are observing, not replacing.
If you also want to replace the behavior, you chain it:
// Observe AND replace: we simulate the store being full
jest.spyOn(repository, 'save').mockReturnValue(false);
// Observe and silence: stop the console from cluttering the test output
const warnings = jest.spyOn(console, 'warn').mockImplementation(() => {});
repository.save(board);
expect(warnings).toHaveBeenCalledWith(expect.stringContaining('no space'));
warnings.mockRestore(); // ← essential if you do not use restoreMocksThat pattern over console.warn is one of the most useful day to day: it checks that the warning is emitted and keeps the suite output clean.
mockRestore() only works with spyOn, because only then is there an original to go back to. On a jest.fn() it does nothing more than mockReset(). And forgetting it has real consequences: a silenced console.warn that is never restored leaves every later test file mute.
jest.fn() |
jest.spyOn(obj, 'm') |
|
|---|---|---|
| Creates a function | New, from nothing | Wraps an existing one |
| Default behavior | Returns undefined |
Runs the original |
| Can it be restored | There is nothing to restore | Yes, with mockRestore() |
| Typical use | Callbacks, injected dependencies | Methods on objects that already exist |
- Mocking whole modules
Sometimes the dependency does not come in as a parameter: it is imported inside the module under test. tasks-api.js imports Task; app.js imports listTasks. To replace that you have to intercept the module system itself.
With CommonJS, the tool is jest.mock('path'), which is automatically hoisted above the require calls. With native ES modules —the route you chose in 08-03— there is no hoisting, so the API is different and a strict order has to be respected:
// test/view/controller.test.js
import { jest } from '@jest/globals';
// 1 · Declare the double BEFORE importing anything from the real module
jest.unstable_mockModule('../../js/data/tasks-api.js', () => ({
listTasks: jest.fn(),
createTask: jest.fn(),
updateTask: jest.fn(),
deleteTask: jest.fn()
}));
// 2 · Import AFTERWARDS, and dynamically (await import)
const { listTasks, createTask } = await import('../../js/data/tasks-api.js');
const { loadBoard } = await import('../../js/app-data.js');
describe('loadBoard', () => {
beforeEach(() => { jest.clearAllMocks(); });
test('builds the board from what the API returns', async () => {
listTasks.mockResolvedValue(backlogData.map((d) => new Task(d)));
const board = await loadBoard();
expect(board.total).toBe(6);
expect(listTasks).toHaveBeenCalledTimes(1);
});
});Three rules to make this work:
jest.unstable_mockModulegoes before anyimportof the real module, indirect ones included. If another module has already imported it, the double arrives too late.- Imports of the mocked module must be dynamic (
await import(...)), because staticimportstatements are resolved before a single line of the file runs. - The
unstable_name is alarming, but it is the documented API for ESM and it works; the prefix reflects that its shape may change, not that it fails.
And an alternative that avoids this whole dance, available whenever the module is well designed:
// Instead of mocking the module, INJECT the dependency
export async function loadBoard({ list = listTasks } = {}) {
return new Board('Taller Nómada', await list());
}
// The test, with no module magic at all:
test('builds the board from what the API returns', async () => {
const list = jest.fn().mockResolvedValue(createBacklog());
const board = await loadBoard({ list });
expect(board.total).toBe(6);
});Four lines instead of fifteen, and with no dependency on the runner. The subject of section 17 is coming.
- Mocking only part of a module
You often want to replace one function from a module and keep the rest. It is solved by importing the real module inside the double's factory:
import { jest } from '@jest/globals';
// We replace ONLY `sleep`; fetchJson and withRetries stay the real ones
jest.unstable_mockModule('../../js/util/time.js', async () => {
const real = await import('../../js/util/time.js');
return {
...real, // everything original…
sleep: jest.fn().mockResolvedValue() // …except this
};
});
const { debounce, sleep } = await import('../../js/util/time.js');The pattern is called partial mocking, and it is almost always preferable to replacing a whole module: the less surface you replace, the closer the test stays to the real code.
A warning about js/util/format.js and other utility modules: do not mock them. They are pure, fast and deterministic. Replacing a dependency that already has the three properties of a good test only adds a layer that can drift out of sync with reality. You mock what gets in the way, not everything you could mock.
- Mocking
fetch: testing tasks-api.js with no network
fetch: testing tasks-api.js with no networkThis is the central case of the lesson. fetch is a global, so the most direct way to replace it is to assign it:
// test/helpers/fake-network.js
import { jest } from '@jest/globals';
/** Builds a real Response, with its status and its headers. */
export function jsonResponse(body, { status = 200, headers = {} } = {}) {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json', ...headers }
});
}
/** A Response with no body, for 204 No Content. */
export const emptyResponse = () => new Response(null, { status: 204 });
/** A Response that is NOT JSON: the proxy returning a login page in HTML. */
export const htmlResponse = (status = 200) =>
new Response('<!doctype html><h1>Sign in</h1>', {
status, headers: { 'content-type': 'text/html' }
});
/** Installs a fake fetch and returns the double so you can program it. */
export function installFakeFetch() {
const fake = jest.fn();
globalThis.fetch = fake;
return fake;
}Using a real Response —available natively in modern Node— rather than a made-up object like { ok: true, json: () => … } is an important decision: the real Response derives ok from status, has headers you query exactly as in the browser, and a body that can only be read once. A home-made object behaves differently at the edges and gives false greens.
And the tests:
// test/data/tasks-api.test.js
import { jest } from '@jest/globals';
import { listTasks, createTask, updateTask, deleteTask } from '../../js/data/tasks-api.js';
import { Task } from '../../js/model/task.js';
import { ApiError } from '../../js/model/errors.js';
import { backlogData } from '../../js/data/backlog.js';
import { jsonResponse, emptyResponse, installFakeFetch } from '../helpers/fake-network.js';
describe('tasks-api', () => {
let network;
const originalFetch = globalThis.fetch;
beforeEach(() => { network = installFakeFetch(); });
afterEach(() => { globalThis.fetch = originalFetch; }); // ← put the world back as you found it
describe('listTasks', () => {
test('returns Task instances, not plain objects', async () => {
network.mockResolvedValue(jsonResponse(backlogData));
const tasks = await listTasks();
expect(tasks).toHaveLength(6);
expect(tasks[0]).toBeInstanceOf(Task); // ← the boundary does its job
expect(tasks[0].title).toBe('Redesign the multipurpose room');
});
test('a board built from the response gives the canonical numbers', async () => {
network.mockResolvedValue(jsonResponse(backlogData));
const board = new Board('Taller Nómada', await listTasks());
expect(board.summary('2026-09-20')).toMatchObject({ openHours: 45, effort: 124 });
});
test('adds the filters as query parameters and omits the empty ones', async () => {
network.mockResolvedValue(jsonResponse([]));
await listTasks({ assignee: 'Iván', status: 'pending', text: '' });
const url = new URL(network.mock.calls[0][0]);
expect(url.pathname).toBe('/v1/tasks');
expect(url.searchParams.get('assignee')).toBe('Iván');
expect(url.searchParams.get('status')).toBe('pending');
expect(url.searchParams.has('text')).toBe(false); // empty ones are NOT sent
});
});
describe('createTask', () => {
test('sends POST with the serialized body and returns the Task with its id', async () => {
const newTask = { title: 'Check the fire extinguishers', assignee: 'Marta',
priority: 'medium', estimatedHours: 2,
dueDate: '2026-10-20', tags: ['safety'] };
network.mockResolvedValue(jsonResponse({ ...newTask, id: 7, status: 'pending' }, { status: 201 }));
const created = await createTask(newTask);
// Behavior: it returns a Task with the id the server assigned (R1)
expect(created).toBeInstanceOf(Task);
expect(created.id).toBe(7);
expect(created.status).toBe('pending'); // R5
// Interaction: the method and the body ARE observable behavior
const [, options] = network.mock.calls[0];
expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toMatchObject({ title: 'Check the fire extinguishers' });
expect(options.headers['Content-Type']).toBe('application/json');
});
});
describe('deleteTask', () => {
test('sends DELETE and accepts a 204 response with no body', async () => {
network.mockResolvedValue(emptyResponse());
await expect(deleteTask(3)).resolves.toBe(true);
expect(network).toHaveBeenCalledWith(
expect.stringContaining('/tasks/3'),
expect.objectContaining({ method: 'DELETE' })
);
});
});
});Notice the balance: most assertions are about the result (stub), and the interaction (mock) is only checked when that interaction is the behavior —that a delete uses DELETE and not PATCH genuinely matters.
- The four network scenarios you have to cover
With the network mocked, provoking failures is trivial. And these are the ones to test, because these are the ones that happen:
describe('tasks-api · error handling (07-03)', () => {
let network;
beforeEach(() => { network = installFakeFetch(); });
test('a 404 produces an ApiError with status 404 and a client code', async () => {
network.mockResolvedValue(jsonResponse({ message: 'Task 99 does not exist' }, { status: 404 }));
const error = await captureAsync(() => getTask(99));
expect(error).toBeInstanceOf(ApiError);
expect(error.status).toBe(404);
expect(error.code).toBe('client');
expect(error.retryable).toBe(false); // ← a 404 is NOT retried
expect(error.message).toBe('Task 99 does not exist');
});
test('a 500 produces a retryable ApiError with a server code', async () => {
network.mockResolvedValue(jsonResponse({ message: 'Internal error' }, { status: 500 }));
const error = await captureAsync(() => listTasks());
expect(error.status).toBe(500);
expect(error.code).toBe('server');
expect(error.retryable).toBe(true); // ← a 500 IS
});
test('a transport failure produces a network ApiError, with no status', async () => {
network.mockRejectedValue(new TypeError('Failed to fetch')); // dead network, DNS or CORS
const error = await captureAsync(() => listTasks());
expect(error).toBeInstanceOf(ApiError);
expect(error.code).toBe('network');
expect(error.status).toBe(0);
expect(error.cause).toBeInstanceOf(TypeError); // the original cause is preserved
});
test('an HTML response instead of JSON produces a format ApiError', async () => {
network.mockResolvedValue(htmlResponse(200)); // the proxy returning the login page
const error = await captureAsync(() => listTasks());
expect(error.code).toBe('format');
expect(error.message).toContain('text/html');
});
test('a cancellation produces an ApiError with the canceled code', async () => {
const abortError = new DOMException('The operation was aborted.', 'AbortError');
network.mockRejectedValue(abortError);
const error = await captureAsync(() => listTasks());
expect(error.code).toBe('canceled');
});
});With the corresponding helper:
// test/helpers/test-backlog.js — the asynchronous version of capture()
export async function captureAsync(fn) {
try { await fn(); return null; } catch (error) { return error; }
}These five tests cover the table of seven failures that opened 07-03, and each one takes less than a millisecond. Provoking a real 500, an unexpected HTML page from a proxy and a DNS outage at will and in the same suite is only possible with doubles.
- Mocking the clock: fake timers
The second great enemy. jest.useFakeTimers() replaces setTimeout, setInterval, clearTimeout, Date and performance.now with versions you control.
jest.useFakeTimers(); // from here on, time does not pass by itself
jest.advanceTimersByTime(300); // advance 300 virtual ms, instantly
jest.runAllTimers(); // run ALL pending timers
jest.runOnlyPendingTimers(); // only the current ones (avoids loops with setInterval)
jest.advanceTimersToNextTimer(); // jump straight to the next one
jest.useRealTimers(); // give the real clock back| Method | When to use it |
|---|---|
advanceTimersByTime(ms) |
Fine control: checking what happens before and after the threshold |
runAllTimers() |
When only the final state matters |
runOnlyPendingTimers() |
With setInterval or timers that reschedule themselves |
advanceTimersByTimeAsync(ms) |
When there are await calls in between: the one you will need with promises |
That last one deserves attention. Advancing the clock runs the timer callbacks, but the microtasks of the promises those callbacks queue are not processed until control is yielded (05-07). The ...Async version yields, and it is what avoids the classic "I advanced the clock and the promise is still pending".
- Testing
debounce without waiting
debounce without waitingWith fake timers, js/util/time.js is tested in microseconds:
// test/util/time.test.js
import { jest } from '@jest/globals';
import { debounce } from '../../js/util/time.js';
describe('debounce', () => {
beforeEach(() => { jest.useFakeTimers(); });
afterEach(() => { jest.useRealTimers(); }); // ← essential: do not leave the clock stopped
test('does not run the function before the wait expires', () => {
const search = jest.fn();
const debounced = debounce(search, 300);
debounced('car');
jest.advanceTimersByTime(299);
expect(search).not.toHaveBeenCalled();
});
test('runs once after the full wait', () => {
const search = jest.fn();
const debounced = debounce(search, 300);
debounced('car');
jest.advanceTimersByTime(300);
expect(search).toHaveBeenCalledTimes(1);
expect(search).toHaveBeenCalledWith('car');
});
test('eight quick keystrokes produce ONE single call, with the last value', () => {
const search = jest.fn();
const debounced = debounce(search, 300);
// Iván types "carpentry" at 50 ms per letter
for (const text of ['c', 'ca', 'car', 'carp', 'carpe', 'carpen', 'carpent', 'carpentr']) {
debounced(text);
jest.advanceTimersByTime(50);
}
expect(search).not.toHaveBeenCalled(); // not yet: every key reset the countdown
jest.advanceTimersByTime(300); // Iván stops typing
expect(search).toHaveBeenCalledTimes(1);
expect(search).toHaveBeenCalledWith('carpentr'); // ← the LAST value, not the first
});
test('a long pause between keystrokes produces two calls', () => {
const search = jest.fn();
const debounced = debounce(search, 300);
debounced('car');
jest.advanceTimersByTime(400); // past the threshold: first call
debounced('ink');
jest.advanceTimersByTime(400); // second one
expect(search).toHaveBeenCalledTimes(2);
expect(search).toHaveBeenNthCalledWith(1, 'car');
expect(search).toHaveBeenNthCalledWith(2, 'ink');
});
});Four tests that document exactly what a debounce does —including the edge case of millisecond 299— and that in real time would have taken 1.4 seconds. Here they take 20 milliseconds.
Notice the afterEach with useRealTimers(). Without it, the fake clock leaks into the following files and any time-dependent test hangs. It is the same hygiene principle as the beforeEach from 08-03: leave the world as you found it.
- Testing exponential-backoff retries
withRetries combines all three difficulties at once: network, timers and randomness (the jitter with Math.random()). All three get solved.
// test/data/http.test.js
import { jest } from '@jest/globals';
import { withRetries } from '../../js/data/http.js';
import { ApiError } from '../../js/model/errors.js';
const serverError = () => new ApiError('Service down', { status: 503, code: 'server' });
const clientError = () => new ApiError('Not found', { status: 404, code: 'client' });
describe('withRetries', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.spyOn(Math, 'random').mockReturnValue(0.5); // DETERMINISTIC jitter
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks(); // gives Math.random back
});
test('does not retry when the operation succeeds first time', async () => {
const operation = jest.fn().mockResolvedValue('ok');
await expect(withRetries(operation)).resolves.toBe('ok');
expect(operation).toHaveBeenCalledTimes(1);
});
test('retries a 503 and returns the result of the third attempt', async () => {
const operation = jest.fn()
.mockRejectedValueOnce(serverError())
.mockRejectedValueOnce(serverError())
.mockResolvedValue('recovered');
const promise = withRetries(operation, { attempts: 3, baseMs: 300 });
await jest.advanceTimersByTimeAsync(300 + 150); // 1st wait: 300 + jitter(0.5 × 300 × 0.3)
await jest.advanceTimersByTimeAsync(600 + 270); // 2nd wait: exponential
await expect(promise).resolves.toBe('recovered');
expect(operation).toHaveBeenCalledTimes(3);
});
test('does NOT retry a 404: there is no point', async () => {
const operation = jest.fn().mockRejectedValue(clientError());
await expect(withRetries(operation, { attempts: 3 })).rejects.toMatchObject({ status: 404 });
expect(operation).toHaveBeenCalledTimes(1); // ← one single time
});
test('gives up after exhausting the attempts and propagates the last error', async () => {
const operation = jest.fn().mockRejectedValue(serverError());
const promise = withRetries(operation, { attempts: 3, baseMs: 300 });
const result = promise.catch((e) => e); // capture now, so it is not left dangling
await jest.runAllTimersAsync();
await expect(result).resolves.toMatchObject({ status: 503 });
expect(operation).toHaveBeenCalledTimes(3);
});
test('the wait grows exponentially between attempts', async () => {
const waits = [];
jest.spyOn(globalThis, 'setTimeout').mockImplementation((fn, ms) => { waits.push(ms); fn(); });
const operation = jest.fn().mockRejectedValue(serverError());
await withRetries(operation, { attempts: 4, baseMs: 100 }).catch(() => {});
// 100·1 + jitter, 100·2 + jitter, 100·4 + jitter — each one larger than the last
expect(waits).toHaveLength(3);
expect(waits[1]).toBeGreaterThan(waits[0]);
expect(waits[2]).toBeGreaterThan(waits[1]);
});
});Three techniques together in this block, and all three get reused constantly:
jest.spyOn(Math, 'random').mockReturnValue(0.5)makes the jitter deterministic. Any randomness in the code under test is tamed this way.advanceTimersByTimeAsyncadvances the clock and processes the pending microtasks; with the synchronous version, the promise behindawait sleep(...)would never resolve.- Capturing the rejection before advancing the clock (
promise.catch((e) => e)) stops Node warning about an unhandled rejection during the interval when the promise has not been awaited yet.
The last test deserves a comment: it checks that the waits grow, not that they are exactly 100, 200 and 400. Asserting the exact values would tie the test to the specific jitter formula, which is an implementation detail. Checking the property —"every wait is larger than the last"— captures the real intent of exponential backoff and survives a tweak to the formula. That distinction, property versus exact value, is one of the most valuable decisions when writing tests.
- Freezing the date:
TODAY with no surprises
TODAY with no surprisesThe project had the foresight to make today come in as a parameter, so most tests do not need to touch the clock. But there is code that does not allow it —any function that calls new Date() internally— and that is where you freeze the calendar:
describe('overdue status with the clock frozen', () => {
beforeEach(() => {
// The whole system believes it is 20 September 2026, at 9:00
jest.useFakeTimers({ now: new Date('2026-09-20T09:00:00Z') });
});
afterEach(() => { jest.useRealTimers(); });
test('isOverdue uses TODAY by default and gives the same result on any day', () => {
const task = aTask({ dueDate: '2026-09-05', status: 'pending' });
expect(task.isOverdue()).toBe(true); // with no date passed
});
test('the current date is the frozen one', () => {
expect(new Date().toISOString().slice(0, 10)).toBe('2026-09-20');
});
test('the summary with no explicit date gives a single overdue task', () => {
expect(aBoard().summary()).toMatchObject({ overdue: 1 });
});
});And a design note worth underlining: if your code can take the date as a parameter, prefer that to freezing the clock. Freezing the clock is a global intervention that affects everything that runs, library code included. Passing today as data is explicit, local and has no side effects. The fake clock is plan B for when you cannot change the code; the parameter is plan A, and it is the reason isOverdue(dueDate, status, today = TODAY) was written that way back in 05-04.
- Mocking
localStorage with an in-memory double
localStorage with an in-memory doublelocalStorage does not exist in Node. That is why local-repository.js was at 31% coverage. And here another design decision from 07-01 pays off: the constructor accepts a store.
// test/helpers/fake-store.js
/**
* A Web Storage FAKE: a real implementation, simplified, in memory.
* It satisfies the interface LocalRepository uses, plus a "full" mode so you
* can provoke the QuotaExceededError without filling a real disk.
*/
export function fakeStore({ initial = {}, full = false } = {}) {
const map = new Map(Object.entries(initial));
return {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => {
if (full) {
const error = new Error('Quota exceeded');
error.name = 'QuotaExceededError'; // ← what the repository checks
throw error;
}
map.set(key, String(value)); // Web Storage ONLY stores strings
},
removeItem: (key) => { map.delete(key); },
clear: () => map.clear(),
key: (i) => [...map.keys()][i] ?? null,
get length() { return map.size; },
// Utilities for the tests only
_dump: () => Object.fromEntries(map)
};
}That String(value) is what makes the double faithful: if the fake accepted objects, a test would pass with setItem('x', {a: 1}) while in the browser the string "[object Object]" would be stored. A double more permissive than the original produces false greens. A fake's fidelity is its only virtue; the moment it drifts from the original, it lies.
The tests:
// test/data/local-repository.test.js
import { jest } from '@jest/globals';
import { LocalRepository } from '../../js/data/local-repository.js';
import { fakeStore } from '../helpers/fake-store.js';
import { aBoard } from '../helpers/test-backlog.js';
describe('LocalRepository', () => {
test('saves and retrieves the whole board', () => {
const repository = new LocalRepository({ store: fakeStore() });
const board = aBoard();
expect(repository.save(board)).toBe(true);
const retrieved = repository.load();
expect(retrieved.total).toBe(6);
expect(retrieved.summary('2026-09-20')).toMatchObject({ openHours: 45, effort: 124 });
});
test('saves under the project versioned key', () => {
const store = fakeStore();
new LocalRepository({ store }).save(aBoard());
expect(Object.keys(store._dump())).toEqual(['nomada:board:v1']);
});
test('returns null if nothing has been saved', () => {
expect(new LocalRepository({ store: fakeStore() }).load()).toBeNull();
});
test('discards corrupt data without throwing and warns on the console', () => {
const warnings = jest.spyOn(console, 'warn').mockImplementation(() => {});
const store = fakeStore({ initial: { 'nomada:board:v1': '{{{ not JSON' } });
expect(new LocalRepository({ store }).load()).toBeNull();
expect(warnings).toHaveBeenCalledWith(expect.stringContaining('corrupt'));
warnings.mockRestore();
});
test('with no space it returns false rather than throwing: losing persistence does not kill the app', () => {
const warnings = jest.spyOn(console, 'warn').mockImplementation(() => {});
const repository = new LocalRepository({ store: fakeStore({ full: true }) });
expect(repository.save(aBoard())).toBe(false); // ← it degrades, it does not explode
warnings.mockRestore();
});
test('clear removes the key and leaves the store empty', () => {
const store = fakeStore();
const repository = new LocalRepository({ store });
repository.save(aBoard());
repository.clear();
expect(repository.load()).toBeNull();
expect(store.length).toBe(0);
});
});From 31% to almost complete coverage, including the two defensive branches —corrupt data and exhausted quota— that in a real browser are extremely hard to provoke by hand.
The jest-environment-jsdom alternative. If you set testEnvironment: 'jsdom', you get a global localStorage implemented by jsdom, and the code works without injecting anything. That is the route you will use in 08-05 for the view tests. For unit tests of the data layer, the injected double is preferable: it is explicit, it does not drag in a whole DOM you do not need, and above all it lets you provoke the QuotaExceededError, which jsdom does not offer.
- Dependency injection versus aggressive mocking
You have spent the whole lesson seeing two routes to the same problem, and they deserve a head-to-head comparison.
// ── Route A · Aggressive mocking: the dependency is imported inside ────
// js/data/synchronizer.js
import { listTasks } from './tasks-api.js';
export async function sync(board) {
const remote = await listTasks();
return board.importFrom(remote);
}
// The test has to intercept the module system
jest.unstable_mockModule('../../js/data/tasks-api.js', () => ({ listTasks: jest.fn() }));
const { listTasks } = await import('../../js/data/tasks-api.js');
const { sync } = await import('../../js/data/synchronizer.js');
listTasks.mockResolvedValue(createBacklog());// ── Route B · Injection: the dependency comes in as a parameter ────────
// js/data/synchronizer.js
import { listTasks } from './tasks-api.js';
export async function sync(board, { list = listTasks } = {}) {
const remote = await list();
return board.importFrom(remote);
}
// The test, with no magic
const list = jest.fn().mockResolvedValue(createBacklog());
await sync(board, { list });Notice the detail in route B: the default value means the production code does not change a single line (sync(board) still works exactly as before), while at the same time opening the door for the test. It is the same technique as the today = TODAY in isOverdue and the store in LocalRepository.
| Dependency injection | Module mocking | |
|---|---|---|
| Changes to production code | Yes: one more parameter | None |
| Test complexity | Low: it is ordinary JavaScript | Medium to high: ordering and dynamic imports |
| Coupling to the runner | None | High: it depends on Jest's API |
| Readability of the dependency | Explicit in the signature | Hidden among the imports |
| Usefulness outside tests | High: it allows real variants | None |
When the dependency is global (fetch) |
Does not apply directly | Necessary |
| When the module is third-party | Difficult | Necessary |
Practical recommendation, in order of preference:
- Make the function pure if you can. With no dependency, there is nothing to mock.
- Inject the dependency with a default value. Cost: one parameter. Benefit: trivial tests and a more flexible design.
- Replace the global (
globalThis.fetch) when the dependency belongs to the environment. - Mock the module only when you do not control the code or the dependency is deep.
And a warning sign worth recognizing: if a test needs four jest.mock calls just to start, the problem is not the test, it is the module's design. Four mocked dependencies mean four coupled responsibilities. Difficulty in testing is still the bad-design detector 08-03 talked about.
- Tests coupled to the implementation
The final danger, and the one that ruins the most suites in the medium term.
// ❌ This test checks NOTHING useful
test('changeStatus works', () => {
const board = aBoard();
const find = jest.spyOn(board, 'findById');
const change = jest.spyOn(Task.prototype, 'changeStatus');
board.changeStatus(2, 'in-progress');
expect(find).toHaveBeenCalledWith(2);
expect(change).toHaveBeenCalledWith('in-progress');
});That test asserts that changeStatus calls other methods. At no point does it check that task 2 ends up in 'in-progress'. Consequences:
- It passes even when the result is wrong. If
changeStatuscalled both methods and then reverted the change, it would still be green. - It fails when nothing is broken. If tomorrow
changeStatususes an internalMapinstead offindById, the test goes red with the behavior intact. - It prevents refactoring, which was precisely benefit number one of having tests. A suite like that turns the safety net into a straitjacket.
// ✅ It checks the observable behavior
test('changeStatus leaves the task in the given status and recalculates the hours', () => {
const board = aBoard();
board.changeStatus(1, 'done'); // task 1: 12 h of Iván's
expect(board.findById(1).status).toBe('done');
expect(board.openHours).toBe(33); // 45 − 12
});The rule: assert on what the module promises, not on how it delivers.
And the exception, which also matters: there are interactions that are the observable behavior, and those are checked with mocks.
| Checking the interaction is justified | Checking the interaction is a mistake |
|---|---|
That a delete sends DELETE and not PATCH |
That summary() internally calls filter |
| That the API is not called when validation fails | That a getter uses reduce rather than a for |
That an AbortController is canceled on teardown |
That a private method gets called |
That EVENTS.TASK_CHANGED is emitted with its detail |
How many times an internal helper is called |
| That nothing is persisted more than once per change | The internal order of two operations with no observable effect |
The criterion for telling them apart: would anybody outside the module notice the difference? If the server receives PATCH instead of DELETE, yes. If summary() swaps a reduce for a loop, no.
Common Mistakes and Tips
- Forgetting
import { jest } from '@jest/globals'with ES modules. It givesReferenceError: jest is not definedand is very confusing, because in every CommonJS tutorial it is not needed. - Not restoring doubles between tests. A silenced
console.warnor a replacedfetchthat survives contaminates the whole suite. UserestoreMocks: trueandclearMocks: truein the configuration. - Forgetting
jest.useRealTimers()in theafterEach. The fake clock leaks into the next file and a test hangs with no explanation. - Using
advanceTimersByTimewhen there are promises involved. The timers run but the microtasks do not; the promise stays pending. Use the...Asyncvariant. - Making home-made responses in the style of
{ ok: true, json: () => data }. They behave differently from a realResponseat the edges (status, derivedok, a body you can consume only once) and produce false greens. Usenew Response(...). - Mocking what does not get in the way. A pure, fast module like
util/format.jsshould not be mocked: you are only adding a layer that can drift out of sync with reality. - Writing a fake more permissive than the original. A fake
localStoragethat accepts objects lets code through that in the browser would store"[object Object]". - Only checking that a mock was called. A test that asserts nothing about the result does not protect the behavior: it passes even when the result is wrong, and it fails as soon as you refactor.
- Leaving a rejected promise uncaught while advancing the clock. Node warns about
unhandledRejectionand clutters (or breaks) the suite. Capture it before advancing. - Tip: if the double is complicated, look at the design. Four
jest.mockcalls just to start a test point at four coupled responsibilities. - Tip: put the doubles in
test/helpers/.fake-network.js,fake-store.jsandtest-backlog.jsare shared across files, written once, and they prevent ten divergent copies. - Tip: prefer asserting properties over exact values when there is randomness or a tunable formula: "the wait grows" survives a formula change; "the wait is 430 ms" does not.
Exercises
Exercise 1 — Testing fetchJson with the seven failures from 07-03.
Write test/data/http.test.js with the complete test battery for fetchJson, covering the seven scenarios from the table that opened 07-03: dead network (TypeError), timeout (TimeoutError), cancellation (AbortError), 4xx, 5xx, a non-JSON response and a 204 with no body. For each one check the code, the status and whether it is retryable. Create a reusable test/helpers/fake-network.js helper and explain why you use new Response(...) rather than an object literal.
Exercise 2 — A double for BoardChannel.
js/data/realtime.js exposes BoardChannel extends EventTarget, which opens a WebSocket, reconnects with backoff and emits events. Write a fake FakeChannel extends EventTarget that lets the test provoke incoming messages (simulateMessage(data)), drops (simulateDrop()) and reconnections, without opening any socket. With it, test that: (a) a task:updated message with task 2 in 'in-progress' updates the board; (b) a message with an unknown id is ignored without throwing; (c) after a drop, local changes are queued and sent on reconnection. Use fake timers for the reconnection.
Exercise 3 — Rewriting a suite coupled to the implementation. This suite passes green and protects nothing. Identify the four problems, explain what real bug each one would let through, and rewrite it so that it checks behavior.
test('createTask works', async () => {
const network = installFakeFetch();
network.mockResolvedValue(jsonResponse({ id: 7 }, { status: 201 }));
const build = jest.spyOn(Task, 'fromJSON');
const serialize = jest.spyOn(JSON, 'stringify');
await createTask({ title: 'Check the fire extinguishers', estimatedHours: 2 });
expect(network).toHaveBeenCalled();
expect(serialize).toHaveBeenCalled();
expect(build).toHaveBeenCalled();
});Solutions
Solution 1
// test/data/http.test.js
import { jest } from '@jest/globals';
import { fetchJson } from '../../js/data/http.js';
import { ApiError } from '../../js/model/errors.js';
import { installFakeFetch, jsonResponse, emptyResponse, htmlResponse } from '../helpers/fake-network.js';
import { captureAsync } from '../helpers/test-backlog.js';
// We use `new Response(...)` and not an object literal because the real Response
// derives `ok` from `status`, exposes `headers` through the Headers API and lets
// the body be read ONE single time. A home-made object is more permissive than
// the original: it would let through code that would fail in the browser.
describe('fetchJson', () => {
let network;
const originalFetch = globalThis.fetch;
beforeEach(() => { network = installFakeFetch(); });
afterEach(() => { globalThis.fetch = originalFetch; });
test('returns the JSON on a correct 200 response', async () => {
network.mockResolvedValue(jsonResponse({ tasks: 6 }));
await expect(fetchJson('/tasks')).resolves.toEqual({ tasks: 6 });
});
test.each`
scenario | error | code | retryable
${'dead network'} | ${new TypeError('Failed to fetch')} | ${'network'} | ${true}
${'timeout'} | ${Object.assign(new Error('t'), { name: 'TimeoutError' })} | ${'timeout'} | ${true}
${'cancellation'} | ${Object.assign(new Error('a'), { name: 'AbortError' })} | ${'canceled'} | ${false}
`('$scenario → ApiError with code $code', async ({ error, code, retryable }) => {
network.mockRejectedValue(error);
const captured = await captureAsync(() => fetchJson('/tasks'));
expect(captured).toBeInstanceOf(ApiError);
expect(captured.code).toBe(code);
expect(captured.status).toBe(0);
expect(captured.retryable).toBe(retryable);
expect(captured.cause).toBe(error); // the original cause is preserved
});
test.each`
status | code | retryable | reason
${400} | ${'client'} | ${false} | ${'badly sent data'}
${401} | ${'client'} | ${false} | ${'expired session'}
${404} | ${'client'} | ${false} | ${'does not exist'}
${408} | ${'client'} | ${true} | ${'server timeout: it IS retried'}
${429} | ${'client'} | ${true} | ${'too many requests: it IS retried'}
${500} | ${'server'} | ${true} | ${'internal error'}
${503} | ${'server'} | ${true} | ${'service unavailable'}
`('a $status gives code $code, retryable: $retryable ($reason)',
async ({ status, code, retryable }) => {
network.mockResolvedValue(jsonResponse({ message: `Error ${status}` }, { status }));
const error = await captureAsync(() => fetchJson('/tasks'));
expect(error.status).toBe(status);
expect(error.code).toBe(code);
expect(error.retryable).toBe(retryable);
expect(error.message).toBe(`Error ${status}`); // it uses the server's message
});
test('an HTML response instead of JSON gives the format code', async () => {
network.mockResolvedValue(htmlResponse(200));
const error = await captureAsync(() => fetchJson('/tasks'));
expect(error.code).toBe('format');
expect(error.message).toContain('text/html');
expect(error.retryable).toBe(false);
});
test('a 204 with no body returns null instead of throwing', async () => {
network.mockResolvedValue(emptyResponse());
await expect(fetchJson('/tasks/3', { method: 'DELETE' })).resolves.toBeNull();
});
test('a malformed JSON body gives the format code, not a loose SyntaxError', async () => {
network.mockResolvedValue(new Response('{{{ broken', {
status: 200, headers: { 'content-type': 'application/json' }
}));
const error = await captureAsync(() => fetchJson('/tasks'));
expect(error).toBeInstanceOf(ApiError); // a raw SyntaxError never escapes
expect(error.code).toBe('format');
});
});Solution 2
// test/helpers/fake-channel.js
/**
* A FAKE of BoardChannel: the same interface (EventTarget + send/close), zero sockets.
* It adds simulate* utilities that exist only for the tests.
*/
export class FakeChannel extends EventTarget {
sent = [];
queue = [];
connected = true;
reconnectAttempts = 0;
send(type, data) {
if (!this.connected) { this.queue.push({ type, data }); return false; }
this.sent.push({ type, data });
return true;
}
close() { this.connected = false; }
// ── Test utilities ──────────────────────────────────────────────────
simulateMessage(type, data) {
this.dispatchEvent(new CustomEvent(type, { detail: data }));
}
simulateDrop() {
this.connected = false;
this.dispatchEvent(new CustomEvent('channel:closed'));
}
simulateReconnect() {
this.connected = true;
this.reconnectAttempts += 1;
for (const pending of this.queue.splice(0)) this.send(pending.type, pending.data);
this.dispatchEvent(new CustomEvent('channel:open'));
}
}// test/data/realtime.test.js
import { jest } from '@jest/globals';
import { connectRealtime } from '../../js/data/realtime.js';
import { FakeChannel } from '../helpers/fake-channel.js';
import { aBoard } from '../helpers/test-backlog.js';
describe('real-time synchronization', () => {
let channel, board;
beforeEach(() => {
jest.useFakeTimers();
channel = new FakeChannel();
board = aBoard();
connectRealtime(board, { channel }); // ← injection, not module mocking
});
afterEach(() => { jest.useRealTimers(); });
test('(a) a task:updated message applies the change to the board', () => {
channel.simulateMessage('task:updated', { id: 2, status: 'in-progress' });
expect(board.findById(2).status).toBe('in-progress');
expect(board.openHours).toBe(45); // the open state does not change
});
test('(b) a message with an unknown id is ignored without throwing', () => {
expect(() => channel.simulateMessage('task:updated', { id: 999, status: 'done' }))
.not.toThrow();
expect(board.total).toBe(6); // nothing added, nothing broken
});
test('(c) changes made during a drop are queued and sent on reconnection', () => {
channel.simulateDrop();
board.changeStatus(2, 'in-progress'); // a local change while there is no network
expect(channel.sent).toHaveLength(0);
expect(channel.queue).toHaveLength(1);
channel.simulateReconnect();
expect(channel.queue).toHaveLength(0);
expect(channel.sent).toHaveLength(1);
expect(channel.sent[0]).toMatchObject({ type: 'task:changed', data: { id: 2 } });
});
test('reconnection respects the exponential backoff', async () => {
channel.simulateDrop();
await jest.advanceTimersByTimeAsync(1000);
expect(channel.reconnectAttempts).toBe(0); // not yet due
channel.simulateReconnect();
expect(channel.reconnectAttempts).toBe(1);
});
});Solution 3
The four problems:
| # | Problem | What real bug it would let through |
|---|---|---|
| 1 | expect(network).toHaveBeenCalled() without checking with what |
That a GET gets sent instead of a POST, or to the wrong URL |
| 2 | Spying on JSON.stringify |
An absolute internal detail. It would pass even if the body sent was empty or malformed |
| 3 | Spying on Task.fromJSON |
Same thing: it checks how it is built, not that a Task with id 7 is returned |
| 4 | No assertion at all about the returned value | That createTask returns undefined, or the raw response instead of a Task |
Taken together: the test would pass with an implementation that called fetch, serialized something and built a task… and returned null. And it would fail if a refactor swapped JSON.stringify for another way of serializing, without anything being broken.
// Rewritten: it checks behavior, and only the interaction that is observable
test('createTask sends POST with the right body and returns the Task with its id', async () => {
const network = installFakeFetch();
const newTask = { title: 'Check the fire extinguishers', assignee: 'Marta', priority: 'medium',
estimatedHours: 2, dueDate: '2026-10-20', tags: ['safety'] };
network.mockResolvedValue(jsonResponse({ ...newTask, id: 7, status: 'pending' }, { status: 201 }));
const created = await createTask(newTask);
// 1 · The result: what the function promises
expect(created).toBeInstanceOf(Task);
expect(created.id).toBe(7); // R1: the id is assigned by the server
expect(created.status).toBe('pending'); // R5
expect(created.title).toBe('Check the fire extinguishers');
// 2 · The interaction, ONLY where it is behavior the server observes
const [url, options] = network.mock.calls[0];
expect(String(url)).toContain('/tasks');
expect(options.method).toBe('POST');
expect(options.headers['Content-Type']).toBe('application/json');
expect(JSON.parse(options.body)).toMatchObject({
title: 'Check the fire extinguishers', estimatedHours: 2
});
});
test('createTask propagates a 422 validation failure as a client ApiError', async () => {
const network = installFakeFetch();
network.mockResolvedValue(jsonResponse({ message: 'Hours are missing' }, { status: 422 }));
const error = await captureAsync(() => createTask({ title: 'No hours' }));
expect(error).toBeInstanceOf(ApiError);
expect(error.status).toBe(422);
expect(error.retryable).toBe(false);
expect(error.message).toBe('Hours are missing');
});Conclusion
The four dependencies that ruin a test —network, clock, storage and randomness— have stopped being an obstacle. You know what a test double is and you handle the vocabulary precisely: the dummy that only fills a slot, the stub that returns fixed responses, the spy that observes while letting the real call through, the mock that additionally imposes expectations about how it is called, and the fake that is a real implementation, simplified. And you are clear about the operational distinction that matters: with a stub you assert on the result, with a mock on the interaction, and by default the former is preferred.
You have jest.fn() mastered, with its complete record (mock.calls, mock.lastCall, mock.results), its matchers (toHaveBeenCalledWith, toHaveBeenCalledTimes, toHaveBeenNthCalledWith) and the asymmetric matchers —expect.objectContaining, expect.stringContaining, expect.any— that stop you tying a test to a complete object. You program responses with mockReturnValue, mockResolvedValue, mockRejectedValue, mockImplementation and, above all, with the ...Once variants that let you simulate sequences: two 503s followed by a success, something impossible to reproduce against a real server. You tell mockClear, mockReset and mockRestore apart, and you know that restoreMocks and clearMocks in the configuration eliminate a whole family of between-test failures. You use jest.spyOn to observe without replacing —checking that the real save happens— and to silence a console.warn without losing the assertion that it was emitted. And you know the mechanics of mocking modules with ESM, with unstable_mockModule before any import and await import afterwards, plus the partial mocking that leaves the rest of the module intact.
Nómada Tasks now covers what was missing. tasks-api.js is tested with no network, with real Response objects rather than home-made ones, verifying that it returns Task instances, that it omits empty filters from the URL, that it sends POST with the right body, and that the five failure scenarios —404, 500, a transport TypeError, unexpected HTML and cancellation— produce an ApiError with exactly the right status, code and retryable. The debounce is tested in microseconds with fake timers, including the edge case of millisecond 299 and Iván's eight keystrokes typing "carpentry", which must produce one call with the last value. The exponential-backoff retries are checked with advanceTimersByTimeAsync so that the await microtasks get processed, with Math.random pinned to 0.5 to tame the jitter, asserting the property ("every wait is larger than the last") rather than exact values that would break the moment the formula is tuned. The date gets frozen when it has to be, although plan A remains the today = TODAY parameter that 05-04 had the foresight to leave in place. And localStorage has its in-memory fake, faithful right down to the String(value) that Web Storage imposes, capable of provoking the QuotaExceededError that is extremely hard to reproduce in a real browser: local-repository.js goes from 31% to almost complete coverage, its two defensive branches included.
And you have the two warnings that separate a useful suite from one that gets in the way. The first: dependency injection is usually better than aggressive mocking. A parameter with a default value —{ list = listTasks } = {}, { store }, today = TODAY— leaves the production code untouched, makes the dependency explicit in the signature and turns the test into ordinary JavaScript, without coupling it to the runner's API; mocking a module is reserved for environment globals and third-party code. The second: tests that only check that a mock was called protect nothing. They pass even when the result is wrong and fail when you refactor, turning the safety net into a straitjacket. Assert on what the module promises; check the interaction only when somebody outside would notice the difference —that a delete uses DELETE, that the API is not called when validation fails, that TASK_CHANGED is emitted with its detail.
With this, the four layers of Nómada Tasks have unit tests: model, utilities, data and network. Each one isolated from all the others, which is precisely the definition of a unit test… and also its blind spot. Because all these tests assume a contract: that LocalRepository saves exactly what Board.importFrom knows how to read; that the JSON produced by toJSON() is what fromJSON expects to receive; that what BoardView renders is what the delegated controller from 06-04 knows how to interpret through its data-id. Each piece keeps its side of the contract in its own test, with doubles that answer exactly what the test told them to answer… and nobody has ever checked that the real pieces understand each other. That gap —the bugs that only appear when you put things together: date formats that do not match, schema versions badly migrated, errors nobody catches at the seam— is the territory of Integration Testing, where you will assemble Board with a real LocalRepository, render the complete view in a DOM with no browser, and test the whole journey from form to render.
JavaScript Course: From Beginner to Advanced
Module 1: Introduction to JavaScript
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
