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

  1. The four dependencies that ruin a test
  2. What a test double is
  3. The precise vocabulary: dummy, stub, spy, mock and fake
  4. jest.fn(): the spy function
  5. Assertions about calls
  6. Programming what a double returns
  7. jest.spyOn: observing without replacing
  8. Mocking whole modules
  9. Mocking only part of a module
  10. Mocking fetch: testing tasks-api.js with no network
  11. The four network scenarios you have to cover
  12. Mocking the clock: fake timers
  13. Testing debounce without waiting
  14. Testing exponential-backoff retries
  15. Freezing the date: TODAY with no surprises
  16. Mocking localStorage with an in-memory double
  17. Dependency injection versus aggressive mocking
  18. Tests coupled to the implementation
  19. Common Mistakes and Tips
  20. Exercises
  21. Conclusion

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

  1. 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 —listTaskshas 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 fetch called with the right URL? With the PATCH method? How many times?
  • Going as fast as you like: no latency, no waiting, no real timers.

  1. 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: "listTasks returned six Task instances".
  • With a mock, the assertion is about the interaction: "fetch was called with PATCH and 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).

  1. jest.fn(): the spy function

jest.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 one

You can give it a body from the start:

const nextId = jest.fn(() => 7);              // an initial implementation
const log = jest.fn((level, event) => `[${level}] ${event}`);

  1. 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 tolerance

Without 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())));

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

  1. jest.spyOn: observing without replacing

jest.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 restoreMocks

That 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

  1. 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_mockModule goes before any import of 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 static import statements 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.

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

  1. Mocking fetch: testing tasks-api.js with no network

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

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

  1. 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".

  1. Testing debounce without waiting

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

  1. 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.
  • advanceTimersByTimeAsync advances the clock and processes the pending microtasks; with the synchronous version, the promise behind await 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.

  1. Freezing the date: TODAY with no surprises

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

  1. Mocking localStorage with an in-memory double

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

  1. 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:

  1. Make the function pure if you can. With no dependency, there is nothing to mock.
  2. Inject the dependency with a default value. Cost: one parameter. Benefit: trivial tests and a more flexible design.
  3. Replace the global (globalThis.fetch) when the dependency belongs to the environment.
  4. 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.

  1. 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 changeStatus called both methods and then reverted the change, it would still be green.
  • It fails when nothing is broken. If tomorrow changeStatus uses an internal Map instead of findById, 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 gives ReferenceError: jest is not defined and is very confusing, because in every CommonJS tutorial it is not needed.
  • Not restoring doubles between tests. A silenced console.warn or a replaced fetch that survives contaminates the whole suite. Use restoreMocks: true and clearMocks: true in the configuration.
  • Forgetting jest.useRealTimers() in the afterEach. The fake clock leaks into the next file and a test hangs with no explanation.
  • Using advanceTimersByTime when there are promises involved. The timers run but the microtasks do not; the promise stays pending. Use the ...Async variant.
  • Making home-made responses in the style of { ok: true, json: () => data }. They behave differently from a real Response at the edges (status, derived ok, a body you can consume only once) and produce false greens. Use new Response(...).
  • Mocking what does not get in the way. A pure, fast module like util/format.js should 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 localStorage that 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 unhandledRejection and clutters (or breaks) the suite. Capture it before advancing.
  • Tip: if the double is complicated, look at the design. Four jest.mock calls just to start a test point at four coupled responsibilities.
  • Tip: put the doubles in test/helpers/. fake-network.js, fake-store.js and test-backlog.js are 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

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved