The previous lesson left the four layers of Nómada Tasks covered by unit tests, each one isolated from all the others. And that is the blind spot: all those tests assume a contract nobody has checked. That the JSON produced by toJSON() is exactly the one fromJSON knows how to read. That what LocalRepository saves is what Board.importFrom expects to receive. That the data-id written by paintCard is the one the delegated controller from 06-04 knows how to interpret. Every piece keeps its side of the bargain with doubles that answer exactly what the test told them, and the real pieces have never looked each other in the eye. This lesson builds the seams: Board with a real LocalRepository, the complete view rendered in a DOM without a browser using jsdom, queries by accessible role with Testing Library, real clicks and typing with user-event, and the whole journey from form → model → render. And you will finish knowing why integration tests find the most bugs per line written… and are also the ones that most easily turn flaky.

Contents

  1. What integrating means
  2. The five bugs that only appear when you put pieces together
  3. Where integration fits: the pyramid and the testing trophy
  4. Integrating model and data: Board + LocalRepository
  5. The round trip: toJSON → string → fromJSON
  6. Version migrations
  7. jsdom: a DOM without a browser
  8. Setting up the minimal HTML and rendering
  9. Testing Library: querying the way a person would
  10. The queries and when to use each one
  11. user-event: interacting for real
  12. Testing event delegation
  13. The complete flow: form → model → render
  14. Testing network errors in the interface
  15. MSW: mocking the server at the right level
  16. Combined coverage: what integration adds
  17. Flaky tests and how to make them deterministic
  18. The Nómada Tasks integration suite
  19. Common Mistakes and Tips
  20. Exercises
  21. Conclusion

  1. What integrating means

A unit test checks one isolated piece, replacing everything around it. An integration test checks several real pieces working together, replacing only what is outside the system: the network, the clock, the disk.

flowchart TD
    subgraph U["UNIT test of Board"]
        T1["real Board"] --> D1["real Task"]
        T1 -.-> R1["LocalRepository<br/>DOUBLE"]
        style R1 fill:#fecaca,stroke:#b91c1c
    end
    subgraph I["INTEGRATION test"]
        T2["real Board"] --> D2["real Task"]
        T2 --> R2["REAL LocalRepository"]
        R2 --> A2["in-memory store<br/>DOUBLE (it is outside)"]
        style R2 fill:#bbf7d0,stroke:#15803d
        style A2 fill:#fde68a,stroke:#b45309
    end

The boundary is drawn like this: inside the system, everything real; outside the system, doubles. LocalRepository is your code, so integration uses the real one. localStorage belongs to the browser, so it gets replaced. The tasks API lives on another server, so it gets mocked.

What you gain is exactly what unit tests cannot give: the confirmation that the contracts between your modules hold.

  1. The five bugs that only appear when you put pieces together

These are not hypothetical bugs: they are the five families that turn up over and over again in any project.

Family What happens Example in Nómada Tasks
Misunderstood contract A produces one shape, B expects another toJSON() emits estimatedHours; importFrom reads hours
Types at the seam The data crosses a boundary and changes type The id that comes out of dataset or the API as a string (case 1 of 08-01)
Date formats Every layer assumes its own The model uses '2026-09-20'; somebody stores a Date that serializes with time and zone
Errors nobody catches Each layer thinks the other handles it The repository throws and the view is not expecting it: a blank screen
Order and lifecycle Something is used before it exists The controller connects before the view has rendered the nodes

All five have something in common: each piece, on its own, works perfectly. Their unit tests are green. The bug lives in the space between them, and no unit test covers that space, because the double always answers what the test expects.

The date format example is particularly instructive:

// The view stores a Date because the <input type="date"> gave it one
task.dueDate = new Date('2026-09-30');

// The model compares ISO strings
isOverdue(dueDate, status, today) { return dueDate < today && status !== 'done'; }
// new Date(...) < '2026-09-20'  →  a comparison between an object and a string: always false

// And on serialization
JSON.stringify(task)   // "dueDate": "2026-09-30T00:00:00.000Z"   ← no longer matches 'yyyy-MM-dd'

No unit test detects it: the view's test checks that it stores what it receives, the model's test checks with strings, and the repository's test serializes whatever it is given. Only when they are put together do you see that the task never shows up as overdue and that the date changes format on reload.

  1. Where integration fits: the pyramid and the testing trophy

In the pyramid from 08-03, integration is the middle level: slower and less precise than unit tests, faster and more stable than end-to-end ones.

But there is an alternative model that fits interface applications better: the testing trophy.

flowchart TD
    E["E2E · few<br/>critical journeys"]
    I["INTEGRATION · most of them<br/>the best confidence/cost balance"]
    U["Unit · just enough<br/>logic with many cases"]
    S["Static · ESLint, @ts-check<br/>the base, free and always on"]
    E --> I --> U --> S
    style I fill:#bbf7d0,stroke:#15803d
    style S fill:#e0e7ff,stroke:#4338ca

Its argument: the base of the trophy is the static checks from 08-02, which cost almost nothing and run continuously; and the wide body is integration, because in an interface application most real bugs live in the seams, not inside a function.

The two shapes coexist well once you understand the rule underneath:

Kind of code Level that pays off most
Pure logic with many cases (validations, calculations, transitions) Unit: nine R6 combinations in nine lines
Collaboration between your own modules (model + data, view + model) Integration
Complete journeys that matter to the business E2E, few and well chosen

Nómada Tasks fits perfectly: the model, full of rules, was covered in 08-03 with 48 unit tests; the view and the persistence are covered here; and 08-06 will leave three E2E journeys.

  1. Integrating model and data: Board + LocalRepository

The first seam. Real pieces: Task, Board, LocalRepository. Double: only the store, because localStorage belongs to the browser.

// test/integration/persistence.test.js
import { Board } from '../../js/model/board.js';
import { LocalRepository } from '../../js/data/local-repository.js';
import { fakeStore } from '../helpers/fake-store.js';
import { aBoard, TODAY } from '../helpers/test-backlog.js';

describe('Integration · Board ↔ LocalRepository', () => {
  let store, repository;

  beforeEach(() => {
    store = fakeStore();
    repository = new LocalRepository({ store });      // ← the REAL repository
  });

  test('a saved and retrieved board keeps the canonical numbers', () => {
    repository.save(aBoard());

    const retrieved = repository.load();

    expect(retrieved).toBeInstanceOf(Board);
    expect(retrieved.summary(TODAY)).toStrictEqual({
      total: 6, open: 5, totalHours: 48,
      openHours: 45, overdue: 1, effort: 124
    });
  });

  test('status changes survive the complete trip', () => {
    const board = aBoard();
    board.changeStatus(2, 'in-progress');
    board.changeStatus(1, 'done');

    repository.save(board);
    const retrieved = repository.load();

    expect(retrieved.findById(2).status).toBe('in-progress');
    expect(retrieved.findById(1).status).toBe('done');
    expect(retrieved.openHours).toBe(33);             // 45 − 12
  });

  test('what comes back are Task instances with all their methods', () => {
    repository.save(aBoard());

    const task = repository.load().findById(6);

    // Having the data is not enough: they have to be live objects
    expect(task.isOverdue(TODAY)).toBe(true);         // the method exists and works
    expect(task.effort).toBe(15);                     // 5 h × weight 3 for high priority
    expect(() => task.changeStatus('done')).toThrow();   // R6 is still in force
  });

  test('what was saved is revalidated on load: data violating R3 is rejected', () => {
    const warnings = jest.spyOn(console, 'warn').mockImplementation(() => {});
    store.setItem('nomada:board:v1', JSON.stringify({
      name: 'Taller Nómada', version: 1,
      tasks: [{ id: 1, title: 'Tampered', estimatedHours: 999, dueDate: '2026-10-01' }]
    }));

    expect(repository.load()).toBeNull();             // ← the store is never trusted
    expect(warnings).toHaveBeenCalled();

    warnings.mockRestore();
  });
});

The third test is the one that justifies the integration level. Checking that the data is there would be a unit test of the repository; checking that what comes back has isOverdue(), computes effort and still enforces R6 is checking that the contract between the two layers is intact. If somebody "optimized" load() by returning plain objects instead of instances, the data would still be correct and the whole application would break. This test prevents that.

The fourth is just as important and expresses a security policy: localStorage is editable by the user from the Application panel of 08-01. What comes out of the store is never trusted, and this test locks that decision in.

  1. The round trip: toJSON → string → fromJSON

The subtlest seam, and the one that picks up 04-08 and 07-01 directly. The journey has five hops and something can be lost at each one:

flowchart LR
    A["Task<br/>with private #status"] -->|toJSON| B["plain object"]
    B -->|JSON.stringify| C["text string"]
    C -->|setItem| D["localStorage"]
    D -->|getItem + JSON.parse| E["plain object"]
    E -->|fromJSON| F["rebuilt<br/>Task"]

What can be lost at each hop if something is not right:

Hop What can be lost How you notice
toJSON The private fields (#status, #hours): they do not show up on their own The task always comes back as 'pending'
JSON.stringify undefined values, functions, Map/Set A field vanishes with no warning
setItem Nothing, but everything becomes a string A stored number comes back as text
JSON.parse The classes: everything comes back as a plain object instanceof Task gives false
fromJSON Nothing, if it revalidates Invalid data gets into the model

The tests that armor-plate the complete journey:

describe('Integration · the round trip', () => {
  test('the private status survives the whole trip', () => {
    const board = aBoard();
    board.changeStatus(2, 'in-progress');

    // The complete journey, hop by hop, written out so you can see it
    const object = board.toJSON();
    const string = JSON.stringify(object);
    store.setItem('nomada:board:v1', string);
    const retrieved = Board.importFrom(JSON.parse(store.getItem('nomada:board:v1')));

    expect(retrieved.findById(2).status).toBe('in-progress');
  });

  test('no field is lost along the way', () => {
    const original = aBoard();

    repository.save(original);
    const retrieved = repository.load();

    // We compare the complete representations: if a field is missing, it shows up here
    expect(retrieved.toJSON()).toStrictEqual(original.toJSON());
  });

  test('dates are still 10-character ISO strings, not Date objects', () => {
    repository.save(aBoard());

    for (const task of repository.load()) {
      expect(typeof task.dueDate).toBe('string');
      expect(task.dueDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
    }
  });

  test('a null reviewer stays null, it does not become undefined (R8)', () => {
    repository.save(aBoard());

    const task = repository.load().findById(2);          // task 2 has no reviewer

    expect(task.reviewer).toBeNull();
    expect('reviewer' in task.toJSON()).toBe(true);       // the key EXISTS
  });

  test('tags are preserved as an array, not as a string', () => {
    repository.save(aBoard());

    expect(repository.load().findById(1).tags).toEqual(['space', 'design']);
  });
});

The reviewer test is a real case that bites a lot of people. If toJSON returned reviewer: undefined instead of null, JSON.stringify would remove the key entirely (04-08). On rebuilding, data.reviewer would be undefined, the constructor's ?? null would save it by accident, and everything would appear to work… until somebody compared 'reviewer' in data and got false. The test pins the contract down explicitly.

And the date one is exactly the bug from section 2: a ten-character string, not a Date.

  1. Version migrations

LocalRepository uses the key nomada:board:v1. The day the format changes —because a required field is added, or another one is renamed— there will be users with data in the old format in their browser. Ignoring them means losing their work.

Suppose version 2 renames estimatedHours to hours and adds createdAt:

// js/data/migrations.js
const MIGRATIONS = {
  1: (data) => ({
    ...data,
    version: 2,
    tasks: data.tasks.map((t) => ({
      ...t,
      hours: t.estimatedHours,              // renamed
      createdAt: t.createdAt ?? '2026-01-01', // a new field, with a sensible value
      estimatedHours: undefined
    }))
  })
};

/** Applies whichever migrations are needed to reach the current version. */
export function migrate(data, currentVersion = 2) {
  let current = data;
  while ((current.version ?? 1) < currentVersion) {
    const migration = MIGRATIONS[current.version ?? 1];
    if (!migration) throw new DataError(`There is no migration from version ${current.version}`);
    current = migration(current);
  }
  return current;
}

And the integration tests, which are among the most valuable there are because they protect real people's data:

describe('Integration · format migrations', () => {
  test('a board in v1 format is migrated and keeps its hours', () => {
    store.setItem('nomada:board:v1', JSON.stringify({
      name: 'Taller Nómada', version: 1,
      tasks: [{ id: 1, title: 'Redesign the room', assignee: 'Iván', priority: 'high',
                status: 'in-progress', tags: ['space'], estimatedHours: 12,
                dueDate: '2026-09-30', reviewer: 'Marta' }]
    }));

    const board = repository.load();

    expect(board.total).toBe(1);
    expect(board.findById(1).hours).toBe(12);             // migrated
    expect(board.findById(1).createdAt).toBe('2026-01-01');  // filled in
  });

  test('a board already in v2 loads untouched', () => {
    const original = aBoard();
    repository.save(original);

    expect(repository.load().toJSON()).toStrictEqual(original.toJSON());
  });

  test('an unknown future format does not destroy the data', () => {
    store.setItem('nomada:board:v1', JSON.stringify({ version: 99, tasks: [] }));

    expect(repository.load()).toBeNull();                 // it is discarded carefully…
    expect(store.getItem('nomada:board:v1')).not.toBeNull();   // …but NOT deleted
  });
});

That last test encodes an important decision: faced with data it does not understand, discard it in memory but do not delete it from the store. If the user opened an old version of the application by mistake, their data is still there when they come back to the new one.

  1. jsdom: a DOM without a browser

The second seam: the view. The whole of Module 6 lives on top of document, which does not exist in Node. jsdom is an implementation of the DOM and HTML standards written in pure JavaScript: it creates document, window, Element, Event, localStorage and hundreds more APIs, in memory and with no window.

npm install --save-dev jest-environment-jsdom

It can be switched on globally or —better— file by file, with a header comment:

/**
 * @jest-environment jsdom
 */
import { BoardView } from '../../js/view/board-view.js';

That way the model tests stay in the node environment, which is faster, and only the ones that need a DOM pay the cost of building one.

Or by file pattern, which is the most convenient once there are many:

// jest.config.js
export default {
  projects: [
    {
      displayName: 'unit',
      testEnvironment: 'node',
      testMatch: ['**/test/{model,util,data}/**/*.test.js'],
      transform: {}
    },
    {
      displayName: 'integration',
      testEnvironment: 'jsdom',
      testMatch: ['**/test/integration/**/*.test.js'],
      transform: {}
    }
  ]
};

What jsdom does and does not offer. It is important to know, so you do not chase bugs that are not there:

jsdom does implement jsdom does not implement
The DOM tree, querySelector, classList, dataset Layout and painting: every measurement is 0
Events, propagation, delegation, CustomEvent getBoundingClientRect() returns zeros
Forms, FormData, constraint validation scrollIntoView, IntersectionObserver (you have to mock them)
localStorage, sessionStorage Service workers and the Cache API
fetch (in recent versions) and AbortController CSS rendering: getComputedStyle is limited
Basic accessibility: implicit roles, aria-* Real browser behaviors (focus across windows, full history)

From that list comes the module's rule: anything that depends on pixels, on painting or on a service worker is not tested in jsdom. It goes into the end-to-end tests of 08-06, in a real browser.

  1. Setting up the minimal HTML and rendering

BoardView expects certain containers and <template> elements that in the application live in index.html. In the test they are set up by hand, with the bare minimum:

// test/helpers/mount-dom.js

/** The minimal structure BoardView needs in order to work. */
const HTML = `
  <main>
    <form id="task-form" novalidate>
      <label for="title">Title</label>
      <input id="title" name="title" required minlength="3">

      <label for="assignee">Assignee</label>
      <select id="assignee" name="assignee">
        <option value="">Unassigned</option>
        <option value="Iván">Iván</option>
        <option value="Lucía">Lucía</option>
        <option value="Marta">Marta</option>
      </select>

      <label for="estimatedHours">Estimated hours</label>
      <input id="estimatedHours" name="estimatedHours" type="number" min="1" max="40" required>

      <label for="dueDate">Due date</label>
      <input id="dueDate" name="dueDate" type="date" required>

      <label for="tags">Tags</label>
      <input id="tags" name="tags">

      <button type="submit">Create task</button>
      <div id="form-errors" role="alert" hidden></div>
    </form>

    <div id="board"></div>
    <output id="summary" aria-live="polite"></output>
  </main>

  <template id="column-template">
    <section class="column"><h2 class="column__title"></h2><ul class="column__list"></ul></section>
  </template>

  <template id="task-template">
    <li class="task">
      <h3 class="task__title"></h3>
      <p class="task__meta"></p>
      <ul class="task__tags"></ul>
      <button data-action="advance"></button>
      <button data-action="reopen">Reopen</button>
    </li>
  </template>
`;

export function mountDom() {
  document.body.innerHTML = HTML;
  return {
    container: document.querySelector('#board'),
    summary: document.querySelector('#summary'),
    form: document.querySelector('#task-form')
  };
}

export function cleanDom() {
  document.body.innerHTML = '';
}

Two decisions in that helper deserve explaining:

  • Every <input> has its <label for>. It is not decoration: it is what will make querying with getByLabelText possible, and it forces the form to be accessible along the way. We will come back to this in section 10.
  • The HTML is the minimum, not a copy of index.html. Copying the real HTML ties the test to every layout change. You set up what the view needs, and nothing more.

And the first render test:

/**
 * @jest-environment jsdom
 */
import { BoardView } from '../../js/view/board-view.js';
import { mountDom, cleanDom } from '../helpers/mount-dom.js';
import { aBoard, TODAY } from '../helpers/test-backlog.js';

describe('Integration · BoardView over the DOM', () => {
  let dom, board, view;

  beforeEach(() => {
    dom = mountDom();
    board = aBoard();
    view = new BoardView({ container: dom.container, summary: dom.summary, board, today: TODAY });
    view.render();
  });

  afterEach(() => { cleanDom(); });

  test('paints the six tasks spread across their three columns', () => {
    expect(document.querySelectorAll('[data-id]')).toHaveLength(6);
    expect(document.querySelectorAll('[data-status="pending"]')).toHaveLength(3);
    expect(document.querySelectorAll('[data-status="in-progress"]')).toHaveLength(2);
    expect(document.querySelectorAll('[data-status="done"]')).toHaveLength(1);
  });

  test('visually marks the single overdue task (R10)', () => {
    const overdue = document.querySelectorAll('.task--overdue');

    expect(overdue).toHaveLength(1);
    expect(overdue[0].dataset.id).toBe('6');
    expect(overdue[0].textContent).toContain('Carpentry workshop quote');
  });

  test('the summary shows the 45 open hours', () => {
    expect(dom.summary.textContent).toContain('45');
  });

  test('reconcile reuses the nodes instead of recreating them', () => {
    const nodeBefore = document.querySelector('[data-id="2"]');

    board.changeStatus(2, 'in-progress');
    view.update();

    const nodeAfter = document.querySelector('[data-id="2"]');

    expect(nodeAfter).toBe(nodeBefore);                     // ← the SAME node (toBe, identity)
    expect(nodeAfter.dataset.status).toBe('in-progress');   // …updated
  });
});

The last test is one of the most valuable in the file, and it is only possible in integration. reconcile() exists precisely so as not to destroy nodes and thus preserve focus and transitions (06-06). Checking that requires a real DOM and the real view, and it is done with toBe on nodes: identity, not content. It is the deliberate use of toBe with objects that 08-03 talked about.

  1. Testing Library: querying the way a person would

querySelectorAll('.task--overdue') works, but it has a fundamental problem: it tests the implementation. The day somebody renames the class to .task--late, the test turns red without anything being broken.

Testing Library proposes something else: querying the DOM the way a person would —or a screen reader. By role, by text, by label.

npm install --save-dev @testing-library/dom @testing-library/user-event @testing-library/jest-dom
import { screen, within } from '@testing-library/dom';
import '@testing-library/jest-dom';        // matchers like toBeVisible, toHaveTextContent

The difference, with the same goal:

// ❌ Coupled to the implementation: it breaks when a class is renamed
expect(document.querySelector('.task__title').textContent).toBe('Redesign the multipurpose room');
const button = document.querySelector('[data-action="advance"]');

// ✅ Coupled to what the user perceives: it survives any CSS refactor
expect(screen.getByRole('heading', { name: 'Redesign the multipurpose room' })).toBeVisible();
const button = screen.getByRole('button', { name: 'Start: Redesign the multipurpose room' });

And the decisive argument, which almost nobody mentions the first time round: if you cannot query an element by its role or its accessible text, a screen reader probably cannot find it either. The test becomes a continuous accessibility audit. A <div onclick> has no button role: it does not show up in getByRole('button') and it is not reachable with the keyboard either. The test forces you to fix it, and fixing it genuinely improves the application.

Querying by… Robustness under refactors Relation to accessibility
CSS class Very low None
id or structural selector Low None
data-testid High None
Accessible text High Direct
Role + accessible name High Direct

data-testid is the fallback for what has no role and no text —a decorative container, an unnamed region. It is legitimate, but it should be the exception: every data-testid is a query that checks nothing about the real experience.

  1. The queries and when to use each one

Testing Library has three families of queries, and choosing badly produces tests that fail for no reason:

Prefix If it finds nothing If it finds several Waits When to use it
getBy… Throws an error Throws an error No The element must already be there
queryBy… Returns null Throws an error No Checking that it is not there
findBy… Throws after the timeout Throws an error Yes (returns a promise) The element will appear after an await
…AllBy… The variant that returns an array Several elements
// getBy: it is already rendered
const heading = screen.getByRole('heading', { name: 'Update the bookings website' });

// queryBy: the only correct way to assert an absence
expect(screen.queryByText('Loading…')).not.toBeInTheDocument();

// findBy: waits for it to appear (after a request, an asynchronous render…)
const alert = await screen.findByRole('alert');

// AllBy: several
expect(screen.getAllByRole('listitem')).toHaveLength(6);

The classic mistake: using getBy to check that something is absent. getByText('Loading…') throws if it does not find it, so the test fails precisely when the behavior is correct. For absences, always queryBy.

The role queries, ordered by how often they get used in a project like this one:

screen.getByRole('button', { name: 'Create task' });
screen.getByRole('heading', { name: /redesign the multipurpose/i });   // regex: case-tolerant
screen.getByRole('textbox', { name: 'Title' });                 // a text input, by its <label>
screen.getByRole('combobox', { name: 'Assignee' });             // <select>
screen.getByRole('spinbutton', { name: 'Estimated hours' });    // input type=number
screen.getByRole('list');                                        // <ul> / <ol>
screen.getByRole('listitem');                                    // <li>
screen.getByRole('alert');                                       // role="alert" or aria-live=assertive
screen.getByRole('status');                                      // aria-live="polite"
screen.getByLabelText('Due date');                               // by its label
screen.getByText(/45 h remaining/);                              // by its text

And within to narrow the search to a region, which is essential when the same text appears in several columns:

const notStartedColumn = screen.getByRole('region', { name: 'Not started' });
const tasks = within(notStartedColumn).getAllByRole('listitem');

expect(tasks).toHaveLength(3);
expect(within(tasks[0]).getByRole('button')).toHaveTextContent('Start');

The most-used matchers from @testing-library/jest-dom:

expect(element).toBeInTheDocument();
expect(element).toBeVisible();                  // takes hidden, display:none and visibility into account
expect(button).toBeDisabled();
expect(field).toHaveValue('Check the fire extinguishers');
expect(field).toBeInvalid();                    // aria-invalid or native validation
expect(element).toHaveClass('task--overdue');
expect(element).toHaveTextContent(/45/);
expect(field).toHaveFocus();
expect(field).toHaveAccessibleName('Estimated hours');

  1. user-event: interacting for real

Dispatching element.dispatchEvent(new MouseEvent('click')) produces one event. When a person presses a button, the browser produces a sequence: pointerdown, mousedown, focus, pointerup, mouseup, click. If your code listens for mousedown or depends on focus, the single-event test passes and the real application fails.

user-event simulates the complete sequence:

import userEvent from '@testing-library/user-event';

test('typing in the form triggers validation after the delay', async () => {
  const user = userEvent.setup();             // ← once per test

  await user.type(screen.getByLabelText('Title'), 'Check the fire extinguishers');
  await user.selectOptions(screen.getByLabelText('Assignee'), 'Marta');
  await user.click(screen.getByRole('button', { name: 'Create task' }));
});

The most useful actions:

Action What it simulates
click(el) The complete press sequence, with focus
dblClick(el) A double press
type(el, text) Key by key, with keydown/keypress/input/keyup per character
clear(el) Select all and delete
selectOptions(el, value) Choosing in a <select>
tab() Moving focus with the tab key
keyboard('{Enter}') Specific key presses: {Enter}, {Escape}, {ArrowDown}
hover(el) / unhover(el) Pointer entering and leaving

type writing key by key is what lets you genuinely test the debounce from 06-07 in its context, rather than in isolation as in 08-04.

And an important warning about fake timers. user-event uses timers internally to simulate typing rhythm. If you combine jest.useFakeTimers() with user-event, you have to tell it:

beforeEach(() => { jest.useFakeTimers(); });

test('the search box filters 300 ms after you stop typing', async () => {
  const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });

  await user.type(screen.getByLabelText('Search'), 'carpent');

  expect(screen.getAllByRole('listitem')).toHaveLength(6);   // not filtered yet

  jest.advanceTimersByTime(300);

  expect(screen.getAllByRole('listitem')).toHaveLength(1);   // filtered now
});

Without the advanceTimers option, user-event sits waiting for a clock that never moves and the test hangs until the timeout expires. It is one of the most frequent sticking points when starting out, and now you know what to look for.

  1. Testing event delegation

Here the mechanism from 06-04 really gets checked. connectActions puts a single listener on the container and uses closest('[data-action]') to identify the button. That architecture has one virtue that can only be demonstrated in integration: it works with cards that did not exist when the listener was connected.

describe('Integration · event delegation (06-04)', () => {
  let user, dom, board, view;

  beforeEach(() => {
    user = userEvent.setup();
    dom = mountDom();
    board = aBoard();
    view = new BoardView({ container: dom.container, summary: dom.summary, board, today: TODAY });
    view.render();
    connectActions({ container: dom.container, board, view });
  });

  afterEach(() => { cleanDom(); });

  test('pressing Start changes the status in the model and in the view', async () => {
    const card = screen.getByRole('listitem', { name: /signage for the screen-printing/i });

    await user.click(within(card).getByRole('button', { name: /start/i }));

    expect(board.findById(2).status).toBe('in-progress');            // the MODEL
    expect(card.dataset.status).toBe('in-progress');                 // the VIEW
    expect(within(card).getByRole('button', { name: /mark done/i })).toBeInTheDocument();
  });

  test('the summary is recalculated after every action', async () => {
    expect(dom.summary).toHaveTextContent(/45/);

    const card = screen.getByRole('listitem', { name: /redesign the multipurpose/i });
    await user.click(within(card).getByRole('button', { name: /mark done/i }));

    expect(dom.summary).toHaveTextContent(/33/);                     // 45 − 12
  });

  test('the button on the done task is disabled and does nothing', async () => {
    const card = screen.getByRole('listitem', { name: /screen-printing ink inventory/i });
    const button = within(card).getByRole('button', { name: /completed/i });

    expect(button).toBeDisabled();

    await user.click(button);

    expect(board.findById(4).status).toBe('done');                   // no change
  });

  test('delegation works with cards created AFTER the listener was connected', async () => {
    board.add(aTask({ id: 7, title: 'Check the fire extinguishers', status: 'pending',
                      estimatedHours: 2, assignee: 'Marta' }));
    view.update();

    const created = screen.getByRole('listitem', { name: /check the fire extinguishers/i });
    await user.click(within(created).getByRole('button', { name: /start/i }));

    expect(board.findById(7).status).toBe('in-progress');
  });

  test('a click in the gap between cards breaks nothing', async () => {
    await user.click(dom.container);

    expect(board.summary(TODAY).openHours).toBe(45);
  });
});

The fourth test is the one that demonstrates the value of delegation: with individual listeners per button, that new card would have none and the click would do nothing. The fifth protects the closest() that returns null when you press outside a button: without the check, it would be a TypeError.

  1. The complete flow: form → model → render

The longest seam in the application, and the one that crosses the most contracts: FormData → normalization → view validation → model validation (rules R2, R3, R6…) → Board.add → event → render → persistence.

flowchart LR
    A["The user types<br/>and submits"] --> B["FormData<br/>+ normalize"]
    B --> C["View<br/>validation"]
    C -->|errors| D["aria-invalid<br/>+ focus to the field"]
    C -->|ok| E["new Task()<br/>rules R2/R3/R8/R9"]
    E -->|ValidationError| D
    E --> F["board.add<br/>R1: unique id"]
    F --> G["TASK_CREATED event"]
    G --> H["render"]
    G --> I["repository.save"]
describe('Integration · form → model → render (06-07)', () => {
  let user, dom, board, view, repository;

  beforeEach(() => {
    user = userEvent.setup();
    dom = mountDom();
    board = aBoard();
    repository = new LocalRepository({ store: fakeStore() });
    view = new BoardView({ container: dom.container, summary: dom.summary, board, today: TODAY });
    view.render();
    connectForm({ form: dom.form, board, nextId: () => 7,
                  onCreate: () => { view.update(); repository.save(board); }, today: TODAY });
  });

  afterEach(() => { cleanDom(); });

  async function fillIn({ title = 'Check the fire extinguishers', assignee = 'Marta',
                          hours = '2', date = '2026-10-20', tags = 'safety' } = {}) {
    await user.type(screen.getByLabelText('Title'), title);
    if (assignee) await user.selectOptions(screen.getByLabelText('Assignee'), assignee);
    await user.type(screen.getByLabelText('Estimated hours'), hours);
    await user.type(screen.getByLabelText('Due date'), date);
    if (tags) await user.type(screen.getByLabelText('Tags'), tags);
  }

  test('a valid task reaches the model, the view and the store', async () => {
    await fillIn();
    await user.click(screen.getByRole('button', { name: 'Create task' }));

    // 1 · The model
    expect(board.total).toBe(7);
    expect(board.findById(7)).toMatchObject({
      title: 'Check the fire extinguishers', assignee: 'Marta',
      estimatedHours: 2, status: 'pending'                      // R5
    });

    // 2 · The view
    expect(screen.getByRole('listitem', { name: /check the fire extinguishers/i })).toBeVisible();

    // 3 · The store
    expect(repository.load().total).toBe(7);

    // 4 · The form has been cleared
    expect(screen.getByLabelText('Title')).toHaveValue('');
  });

  test('tags are normalized per R9 across the whole journey', async () => {
    await fillIn({ tags: 'Safety, SAFETY , extinguishers' });
    await user.click(screen.getByRole('button', { name: 'Create task' }));

    expect(board.findById(7).tags).toEqual(['safety', 'extinguishers']);
    expect(repository.load().findById(7).tags).toEqual(['safety', 'extinguishers']);
  });

  test('an empty title shows the accessible error and does NOT touch the model', async () => {
    await fillIn({ title: '' });
    await user.click(screen.getByRole('button', { name: 'Create task' }));

    expect(board.total).toBe(6);                                // the model intact
    expect(screen.getByRole('alert')).toBeVisible();
    expect(screen.getByLabelText('Title')).toBeInvalid();       // aria-invalid="true"
    expect(screen.getByLabelText('Title')).toHaveFocus();       // focus goes to the field
  });

  test('99 hours breaks R3 and the error identifies the right field', async () => {
    await fillIn({ hours: '99' });
    await user.click(screen.getByRole('button', { name: 'Create task' }));

    expect(board.total).toBe(6);
    expect(screen.getByRole('alert')).toHaveTextContent(/40/);
    expect(screen.getByLabelText('Estimated hours')).toBeInvalid();
  });

  test('a due date earlier than today is rejected (R4)', async () => {
    await fillIn({ date: '2026-09-01' });
    await user.click(screen.getByRole('button', { name: 'Create task' }));

    expect(board.total).toBe(6);
    expect(screen.getByLabelText('Due date')).toBeInvalid();
  });

  test('after fixing the error, the second submit works and the warnings disappear', async () => {
    await fillIn({ title: '' });
    await user.click(screen.getByRole('button', { name: 'Create task' }));

    await user.type(screen.getByLabelText('Title'), 'Check the fire extinguishers');
    await user.click(screen.getByRole('button', { name: 'Create task' }));

    expect(board.total).toBe(7);
    expect(screen.queryByRole('alert')).not.toBeInTheDocument();   // ← queryBy for the absence
  });

  test('the form can be completed and submitted with the keyboard alone', async () => {
    await user.tab();                                       // Title
    await user.keyboard('Check the fire extinguishers');
    await user.tab();                                       // Assignee
    await user.keyboard('Marta');
    await user.tab();                                       // Hours
    await user.keyboard('2');
    await user.tab();                                       // Date
    await user.keyboard('2026-10-20');
    await user.tab();                                       // Tags
    await user.tab();                                       // Button
    await user.keyboard('{Enter}');

    expect(board.total).toBe(7);
  });
});

Seven tests covering the whole journey, including the one almost nobody writes: the second submit after fixing an error. Clearing the error state is one of the things most often forgotten, and it produces forms that show a red warning forever. And the last one —completing the form with the keyboard alone— checks tab order, focus and submitting with Enter all at once: three accessibility properties no unit test touches.

  1. Testing network errors in the interface

The last seam: what the user sees when the network fails. In 08-04 you checked that tasks-api.js produces the right ApiError; here you check that the interface state machine from 07-03 reacts as it should.

describe('Integration · interface states on network failures (07-03)', () => {
  let user, dom, network;

  beforeEach(() => {
    user = userEvent.setup();
    dom = mountDom();
    network = installFakeFetch();
  });

  afterEach(() => { cleanDom(); jest.restoreAllMocks(); });

  test('shows "loading", then the six tasks', async () => {
    let resolve;
    network.mockReturnValue(new Promise((r) => { resolve = r; }));

    const loading = startApplication({ dom });

    expect(screen.getByRole('status')).toHaveTextContent(/loading/i);

    resolve(jsonResponse(backlogData));
    await loading;

    expect(await screen.findAllByRole('listitem')).toHaveLength(6);
    expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
  });

  test('a 500 shows an accessible message and a retry button', async () => {
    network.mockResolvedValue(jsonResponse({ message: 'Internal error' }, { status: 500 }));

    await startApplication({ dom });

    const alert = await screen.findByRole('alert');
    expect(alert).toHaveTextContent(/server/i);
    expect(screen.getByRole('button', { name: /retry/i })).toBeVisible();
  });

  test('the retry button asks again and shows the tasks', async () => {
    network.mockResolvedValueOnce(jsonResponse({ message: 'Error' }, { status: 500 }))
           .mockResolvedValueOnce(jsonResponse(backlogData));

    await startApplication({ dom });
    await user.click(await screen.findByRole('button', { name: /retry/i }));

    expect(await screen.findAllByRole('listitem')).toHaveLength(6);
    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
    expect(network).toHaveBeenCalledTimes(2);
  });

  test('an empty list shows the empty state, not an error', async () => {
    network.mockResolvedValue(jsonResponse([]));

    await startApplication({ dom });

    expect(await screen.findByText(/no tasks/i)).toBeVisible();
    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  });

  test('with no connection it warns and keeps whatever was in the store', async () => {
    const store = fakeStore();
    new LocalRepository({ store }).save(aBoard());
    network.mockRejectedValue(new TypeError('Failed to fetch'));

    await startApplication({ dom, store });

    expect(await screen.findByRole('alert')).toHaveTextContent(/connection/i);
    expect(screen.getAllByRole('listitem')).toHaveLength(6);   // ← the local data is still visible
  });
});

The first test uses a technique worth knowing: a promise whose resolution is controlled from the test (let resolve). It is the only way to observe the intermediate "loading" state, which with an ordinary mockResolvedValue would disappear before you could check it.

And the last test checks graceful degradation: with no network, the application does not go blank, it shows what it has stored and warns. That behavior crosses three layers and no unit test covers it.

  1. MSW: mocking the server at the right level

Replacing globalThis.fetch works, but it has a drawback: you are mocking the tool, not the server. If tomorrow some part of the code uses XMLHttpRequest, or navigator.sendBeacon, or a different HTTP client, your double does not cover it. And the tests fill up with fetch details that have nothing to do with the business.

MSW (Mock Service Worker) solves this by intercepting at the network layer: in the browser with a service worker (the ones from 07-05), and in Node with a request interceptor. The code under test makes real requests; they simply never leave the machine.

// test/helpers/fake-server.js
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { backlogData } from '../../js/data/backlog.js';

const BASE = 'https://api.tallernomada.example/v1';

export const handlers = [
  http.get(`${BASE}/tasks`, ({ request }) => {
    const assignee = new URL(request.url).searchParams.get('assignee');
    const tasks = assignee
      ? backlogData.filter((t) => t.assignee === assignee)
      : backlogData;
    return HttpResponse.json(tasks);
  }),

  http.post(`${BASE}/tasks`, async ({ request }) => {
    const data = await request.json();
    if (!data.title?.trim()) {
      return HttpResponse.json({ message: 'The title is required' }, { status: 422 });
    }
    return HttpResponse.json({ ...data, id: 7, status: 'pending' }, { status: 201 });
  }),

  http.patch(`${BASE}/tasks/:id`, async ({ params, request }) =>
    HttpResponse.json({ ...backlogData.find((t) => t.id === Number(params.id)),
                        ...(await request.json()) }))
];

export const server = setupServer(...handlers);
// Use in the tests
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());       // undoes the per-test overrides
afterAll(() => server.close());

test('an occasional 503 switches on the error state', async () => {
  // Override ONLY for this test
  server.use(http.get(`${BASE}/tasks`, () =>
    HttpResponse.json({ message: 'Unavailable' }, { status: 503 })));

  await startApplication({ dom });

  expect(await screen.findByRole('alert')).toHaveTextContent(/server/i);
});
Replacing fetch MSW
Interception level The fetch function The network request
Covers other HTTP clients No Yes
Reusable in E2E (08-06) No Yes, with the same handlers
Configuration None A package and a setupServer
Readability Medium High: it reads like an API
When to choose it One-off tests of a module Complete integration suites

onUnhandledRequest: 'error' deserves a comment: it fails any request with no handler. It is an extremely valuable guarantee —if a module starts calling a new endpoint, you find out— and it prevents the silent scenario in which a test actually connects to the internet without anybody noticing.

  1. Combined coverage: what integration adds

npm test -- --coverage
File                     | % Stmts | % Branch | Δ vs. unit only
-------------------------|---------|----------|---------------------
 js/model/task.js        |   98.2  |   96.1   |  +1.8   (little: it was already there)
 js/model/board.js       |  100.0  |  100.0   |   0.0
 js/data/local-repo…     |   94.7  |   89.5   | +63.4   ← the big jump
 js/view/board-view      |   88.3  |   76.2   | +88.3   ← from zero
 js/view/card.js         |   95.1  |   88.9   | +95.1   ← from zero
 js/view/controller.js   |   91.4  |   80.0   | +91.4   ← from zero
 js/view/form.js         |   86.9  |   79.3   | +86.9   ← from zero

Two readings of this table:

  • Integration barely moves the model's coverage. That makes sense: it was already covered by the unit tests, which also do it better (nine R6 combinations in nine lines). Duplicating that coverage in integration would be pure cost.
  • The whole view layer goes from zero to almost ninety. That is where integration adds value exclusively, because testing a render, a delegation or a form requires a DOM.

And a warning you already know from 08-03, now with a new twist: integration coverage is especially deceptive. A single test that boots the whole application runs hundreds of lines at once and sends the percentages soaring while checking almost nothing. The numbers go up; confidence does not necessarily. Look at the branch column and, above all, count the assertions.

  1. Flaky tests and how to make them deterministic

A flaky test is one that sometimes passes and sometimes fails without the code changing. It is more damaging than a test that always fails, because it teaches the team to ignore red: "run it again, it will pass". Once that takes hold, the suite has stopped being useful.

The causes, in order of frequency in integration tests:

Cause Symptom Fix
Waiting on time Fails on a slow machine or in CI findBy… / waitFor, never a sleep
Shared state Fails only if another test ran first A beforeEach that rebuilds everything, an afterEach that cleans up
Execution order Fails when running in parallel or when reordered Total independence; never depend on order
Real dates Fails on a Tuesday, or on the 1st of the month TODAY as data; fake timers
Randomness Fails one time in twenty Pin Math.random (08-04)
Unawaited promises Fails unpredictably await everything; findBy… for whatever appears later

The first is the queen, and its antidote is worth seeing in detail:

// ❌ Fragile: 100 ms may be enough today on your laptop and not enough in CI
await new Promise((r) => setTimeout(r, 100));
expect(screen.getByRole('listitem')).toBeInTheDocument();

// ✅ Wait for the CONDITION, not for a duration. It retries until it holds
expect(await screen.findByRole('listitem')).toBeInTheDocument();

// ✅ For conditions that are not "an element appears"
await waitFor(() => {
  expect(board.total).toBe(7);
});

// ✅ To wait for something to DISAPPEAR
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));

findBy and waitFor retry the check every few milliseconds until it passes or the timeout expires. On a fast machine they finish in 5 ms; on a saturated CI runner, in 400. A fixed setTimeout, by contrast, either waits too long (and the suite is slow) or not long enough (and it fails). Never wait for a duration: wait for a condition.

Four more measures that stabilize an integration suite:

// 1 · Detect order dependencies by running in random order
//     jest --randomize

// 2 · ALWAYS clean the DOM and the doubles between tests
afterEach(() => {
  document.body.innerHTML = '';
  jest.restoreAllMocks();
  localStorage.clear();                  // jsdom does implement it
});

// 3 · Pin the clock when the code reads it directly
beforeEach(() => { jest.useFakeTimers({ now: new Date('2026-09-20T09:00:00Z') }); });

// 4 · Make any unexpected request fail
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

And a policy worth agreeing in writing: a flaky test gets fixed or deleted; it is never blindly retried. The option of automatically retrying failures exists in some runners and it is tempting, but it turns a visible problem into an invisible one: the test is still exposing a real race in your code, and now nobody can see it.

  1. The Nómada Tasks integration suite

The complete map of what this lesson has written:

test/
  integration/
    persistence.test.js        model + LocalRepository + migrations         (node)
    serialization.test.js      the toJSON → string → fromJSON trip          (node)
    render.test.js             BoardView + card + reconcile                 (jsdom)
    delegation.test.js         controller + events + model + view           (jsdom)
    form.test.js               form → model → render → store                (jsdom)
    network.test.js            interface states on network failures         (jsdom)
  helpers/
    mount-dom.js               the minimal HTML
    fake-store.js              the Web Storage fake (08-04)
    fake-network.js            fetch responses (08-04)
    fake-server.js             the MSW handlers
    test-backlog.js            TODAY, aTask, aBoard, capture
$ npm test

 PASS  unit  test/model/task.test.js
 PASS  unit  test/model/board.test.js
 PASS  unit  test/util/dates.test.js
 PASS  unit  test/util/time.test.js
 PASS  unit  test/data/tasks-api.test.js
 PASS  unit  test/data/http.test.js
 PASS  unit  test/data/local-repository.test.js
 PASS  integration  test/integration/persistence.test.js
 PASS  integration  test/integration/serialization.test.js
 PASS  integration  test/integration/render.test.js
 PASS  integration  test/integration/delegation.test.js
 PASS  integration  test/integration/form.test.js
 PASS  integration  test/integration/network.test.js

Test Suites: 13 passed, 13 total
Tests:       124 passed, 124 total
Time:        3.71 s

One hundred and twenty-four checks in under four seconds, with no browser opened. And the step into continuous integration is one line of the 08-02 workflow, which was already prepared:

      - name: Run the tests
        run: npm test -- --coverage --ci

Common Mistakes and Tips

  • Turning an integration test into a unit test with too many doubles. If you replace LocalRepository, you are no longer testing the seam: only the piece. Everything of yours real; doubles only for what is external.
  • Copying the whole index.html into the test. It ties the test to every layout change. Set up the minimum the view needs.
  • Querying by CSS class. It breaks when a class is renamed, with nothing actually broken. Query by role and accessible name.
  • Using getBy… to check an absence. It throws when it finds nothing, so it fails exactly when the behavior is correct. For absences, queryBy….
  • Forgetting await with user-event or with findBy…. Both return promises. Without await, the assertion runs before anything has happened.
  • Combining jest.useFakeTimers() with user-event without advanceTimers. The test hangs until the timeout expires, and the error message is no help at all.
  • Waiting with setTimeout instead of waitFor/findBy. It is the number one cause of flaky tests: the duration that is enough on your laptop is not enough in CI.
  • Looking for visual layout bugs in jsdom. There is no painting: every measurement is zero and getComputedStyle is limited. That goes to E2E, in 08-06.
  • Not cleaning the DOM or the storage between tests. jsdom shares document within a single file: the card from the previous test is still there.
  • Tip: if querying by role is impossible, fix the HTML. A <div onclick> that does not show up in getByRole('button') is not reachable with the keyboard either. The test is pointing at a real problem.
  • Tip: write an integration test for every seam bug you find. They are the most profitable: they cover many lines and catch the class of bug that costs most in production.
  • Tip: run jest --randomize from time to time. It is the fastest way to discover hidden order dependencies between tests.

Exercises

Exercise 1 — Integrating the filters with the URL. Write test/integration/filters.test.js testing the complete seam between router.js (07-06), BoardView and the controller, in jsdom. It must check: (a) starting with ?assignee=Iván in the URL shows exactly 3 cards and the summary still says 45 open hours —because filtering is presentation and does not touch the model—; (b) pressing Lucía's filter updates the URL with pushState and leaves 1 card visible; (c) typing in the search box uses replaceState and filters 300 ms after you stop typing; (d) popstate (the back button) restores the previous filter. Remember the fake-timer detail with user-event.

Exercise 2 — Migrating the format v1 → v2 with real data. Version 2 of the LocalRepository format adds a required createdAt field (an ISO date) and turns reviewer from a string into an object { name, notified }. Write the migrate function and its integration suite, covering: a complete v1 board with the 6 backlog tasks that after migration keeps the canonical numbers; a task with reviewer: null that becomes reviewer: null and not { name: null }; a board already in v2 that is left untouched; an unknown version format that is discarded without deleting the original; and the check that after migrating and saving again, the store key is nomada:board:v2.

Exercise 3 — Diagnosing three flaky tests. These three tests fail "sometimes" in continuous integration and never locally. Identify the cause of each one, explain the conditions under which it fails, and rewrite it so it is deterministic.

// A
test('the tasks appear after loading', async () => {
  startApplication({ dom });
  await new Promise((r) => setTimeout(r, 200));
  expect(screen.getAllByRole('listitem')).toHaveLength(6);
});

// B
const repository = new LocalRepository({ store: fakeStore() });

test('saves the board', () => {
  repository.save(aBoard());
  expect(repository.load().total).toBe(6);
});

test('clear leaves the store empty', () => {
  repository.clear();
  expect(repository.load()).toBeNull();
});

// C
test('the overdue task is marked', () => {
  const view = new BoardView({ container: dom.container, board: aBoard() });
  view.render();
  expect(document.querySelectorAll('.task--overdue')).toHaveLength(1);
});

Solutions

Solution 1

/**
 * @jest-environment jsdom
 */
import { jest } from '@jest/globals';
import { screen } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';

import { BoardView } from '../../js/view/board-view.js';
import { connectRouter, readStateFromUrl } from '../../js/view/router.js';
import { connectFilters } from '../../js/view/controller.js';
import { mountDom, cleanDom } from '../helpers/mount-dom.js';
import { aBoard, TODAY } from '../helpers/test-backlog.js';

describe('Integration · filters ↔ URL (07-06)', () => {
  let user, dom, board, view;

  function start(search = '') {
    // jsdom lets you rewrite the URL without reloading
    history.replaceState(null, '', `/${search}`);

    dom = mountDom();
    board = aBoard();
    view = new BoardView({ container: dom.container, summary: dom.summary, board, today: TODAY });
    view.update({ filters: readStateFromUrl() });
    connectFilters({ container: document.body, view });
    connectRouter(view);
  }

  beforeEach(() => {
    jest.useFakeTimers();
    user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });  // ← essential
  });

  afterEach(() => {
    jest.useRealTimers();
    cleanDom();
    history.replaceState(null, '', '/');
  });

  test('(a) starting with ?assignee=Iván shows 3 cards without touching the model', () => {
    start('?assignee=Iván');

    expect(screen.getAllByRole('listitem')).toHaveLength(3);
    expect(board.total).toBe(6);                          // the MODEL is not filtered
    expect(dom.summary).toHaveTextContent(/45/);          // the summary is for the whole board
  });

  test('(b) pressing Lucía\'s filter adds an entry to the history', async () => {
    start();
    const before = history.length;

    await user.click(screen.getByRole('button', { name: 'Lucía' }));

    expect(new URL(location.href).searchParams.get('assignee')).toBe('Lucía');
    expect(history.length).toBe(before + 1);              // pushState, not replaceState
    expect(screen.getAllByRole('listitem')).toHaveLength(1);
  });

  test('(c) the search box uses replaceState and filters after 300 ms', async () => {
    start();
    const before = history.length;

    await user.type(screen.getByLabelText('Search'), 'carpent');

    expect(screen.getAllByRole('listitem')).toHaveLength(6);   // not filtered yet

    jest.advanceTimersByTime(300);

    expect(screen.getAllByRole('listitem')).toHaveLength(1);
    expect(new URL(location.href).searchParams.get('q')).toBe('carpent');
    expect(history.length).toBe(before);                       // replaceState: it does not grow
  });

  test('(d) popstate restores the previous filter', async () => {
    start();

    await user.click(screen.getByRole('button', { name: 'Iván' }));
    expect(screen.getAllByRole('listitem')).toHaveLength(3);

    // jsdom does not navigate on its own: the event is simulated with the state it would carry
    history.replaceState({ assignee: null, text: '', sort: 'priority' }, '', '/');
    window.dispatchEvent(new PopStateEvent('popstate', { state: { assignee: null } }));

    expect(screen.getAllByRole('listitem')).toHaveLength(6);
  });
});

Solution 2

// js/data/migrations.js
import { DataError } from '../model/errors.js';

export const CURRENT_VERSION = 2;

const MIGRATIONS = {
  1: (data) => ({
    name: data.name,
    version: 2,
    tasks: data.tasks.map((t) => ({
      ...t,
      createdAt: t.createdAt ?? '2026-01-01',
      // null stays null: it is NOT wrapped in an empty object
      reviewer: t.reviewer == null ? null : { name: t.reviewer, notified: false }
    }))
  })
};

export function migrate(data) {
  let current = data;
  let rounds = 0;

  while ((current.version ?? 1) < CURRENT_VERSION) {
    if (rounds++ > 10) throw new DataError('Migration loop detected');
    const step = MIGRATIONS[current.version ?? 1];
    if (!step) throw new DataError(`No migration from v${current.version}`);
    current = step(current);
  }

  if ((current.version ?? 1) > CURRENT_VERSION) {
    throw new DataError(`Format v${current.version} is newer than the application`);
  }
  return current;
}
// test/integration/migrations.test.js
import { jest } from '@jest/globals';
import { LocalRepository } from '../../js/data/local-repository.js';
import { fakeStore } from '../helpers/fake-store.js';
import { aBoard, TODAY } from '../helpers/test-backlog.js';
import { backlogData } from '../../js/data/backlog.js';

const v1 = () => ({ name: 'Taller Nómada', version: 1, tasks: backlogData });

describe('Integration · migration v1 → v2', () => {
  let store, repository, warnings;

  beforeEach(() => {
    store = fakeStore();
    repository = new LocalRepository({ store });
    warnings = jest.spyOn(console, 'warn').mockImplementation(() => {});
  });

  afterEach(() => { warnings.mockRestore(); });

  test('a complete v1 board migrates keeping the canonical numbers', () => {
    store.setItem('nomada:board:v1', JSON.stringify(v1()));

    const board = repository.load();

    expect(board.total).toBe(6);
    expect(board.summary(TODAY)).toMatchObject({
      openHours: 45, totalHours: 48, overdue: 1, effort: 124
    });
    expect(board.findById(1).createdAt).toBe('2026-01-01');
  });

  test('a reviewer with a name becomes an object', () => {
    store.setItem('nomada:board:v1', JSON.stringify(v1()));

    expect(repository.load().findById(1).reviewer)
      .toStrictEqual({ name: 'Marta', notified: false });
  });

  test('a null reviewer stays null, not an object with a null name', () => {
    store.setItem('nomada:board:v1', JSON.stringify(v1()));

    const task = repository.load().findById(2);             // task 2 has no reviewer

    expect(task.reviewer).toBeNull();
    expect(task.reviewer).not.toStrictEqual({ name: null, notified: false });
  });

  test('a board already in v2 is left untouched', () => {
    const original = aBoard();
    repository.save(original);

    expect(repository.load().toJSON()).toStrictEqual(original.toJSON());
  });

  test('an unknown future version is discarded WITHOUT deleting the original', () => {
    const future = JSON.stringify({ name: 'X', version: 99, tasks: [] });
    store.setItem('nomada:board:v2', future);

    expect(repository.load()).toBeNull();
    expect(store.getItem('nomada:board:v2')).toBe(future);   // untouched
    expect(warnings).toHaveBeenCalled();
  });

  test('after migrating and saving, the data lives under the v2 key', () => {
    store.setItem('nomada:board:v1', JSON.stringify(v1()));

    repository.save(repository.load());

    expect(store.getItem('nomada:board:v2')).not.toBeNull();
    expect(JSON.parse(store.getItem('nomada:board:v2')).version).toBe(2);
  });
});

Solution 3

Test Cause When it fails
A It waits a fixed duration (200 ms) and on top of that does not await startApplication In CI, with a loaded machine, loading takes 250 ms and getAllByRole throws because there are no <li> yet
B Shared state between tests: a single module-level repository The second test depends on the first having saved. If it runs alone, or the order changes (--randomize), it fails
C A real date: the BoardView is created without today, so it uses the system's It passes today and fails on 1 October 2026, when task 3 will also be overdue and there will be 2
// A · fixed: wait for the CONDITION, not for a duration
test('the tasks appear after loading', async () => {
  await startApplication({ dom });                          // ← await the promise

  expect(await screen.findAllByRole('listitem')).toHaveLength(6);   // ← findBy retries
});

// B · fixed: fresh state in every test
describe('LocalRepository', () => {
  let store, repository;

  beforeEach(() => {
    store = fakeStore();
    repository = new LocalRepository({ store });
  });

  test('save and load returns the six tasks', () => {
    repository.save(aBoard());

    expect(repository.load().total).toBe(6);
  });

  test('clear leaves the store empty', () => {
    repository.save(aBoard());                              // ← it prepares ITS own state

    repository.clear();

    expect(repository.load()).toBeNull();
    expect(store.length).toBe(0);
  });
});

// C · fixed: the date comes in as data, and the query goes by role
test('the overdue task is marked visually (R10)', () => {
  const view = new BoardView({
    container: dom.container, summary: dom.summary,
    board: aBoard(), today: TODAY                           // ← '2026-09-20', fixed
  });
  view.render();

  const overdue = screen.getByRole('listitem', { name: /carpentry workshop quote/i });

  expect(overdue).toHaveClass('task--overdue');
  expect(document.querySelectorAll('.task--overdue')).toHaveLength(1);
});

Conclusion

Nómada Tasks no longer just has tested pieces: it has tested seams. You know what distinguishes an integration test from a unit test —everything inside the system real, everything outside a double— and you know the five families of bugs that only appear when things are put together: misunderstood contracts, types that change when crossing a boundary, divergent date formats, errors each layer believes the other handles, and problems of order and lifecycle. All five share the fact that each piece, on its own, is green. You can place integration in the pyramid and you know the testing trophy, which widens that level for interface applications and puts the static checks from 08-02 as the free foundation of everything.

You have built the model and data seam: Board with a real LocalRepository over an in-memory store, checking not only that the data comes back but that it comes back as live instances with isOverdue(), effort and R6 still in force; that whatever comes out of the store is always revalidated, because anybody can edit it from the Application panel; and that the complete toJSONstringifysetItemgetItemparsefromJSON trip does not lose the private #status, does not turn the dates into Date objects, keeps reviewer: null as a present key instead of letting stringify delete it, and preserves the tags as an array. And you have written the version migrations, those unglamorous tests that protect real people's data, with the policy of discarding in memory without deleting from the store whatever cannot be understood.

You have built the view seam without opening a browser: jsdom as the environment, with its limits clearly drawn —there is a tree, events, forms and localStorage; there is no painting, no measurements, no service workers—, a minimal HTML set up by hand rather than a copy of index.html, and the check that reconcile() reuses the same node with toBe, which is the one place where comparing object identity is exactly what you want. With Testing Library you query the way a person would: getByRole with its accessible name, getByLabelText, queryBy… for absences —never getBy, which throws precisely when the behavior is correct— and findBy… for whatever appears after an await. And you understand the decisive argument: if you cannot find an element by its role, a screen reader cannot either; the test becomes a continuous accessibility audit. With user-event you simulate complete interaction sequences —key by key, with focus, with tabbing— and you know that combining it with fake timers requires passing advanceTimers or the test hangs.

With that you have tested the event delegation from 06-04, including the property that justifies it —that it works with cards created after the listener was connected— and the click in the gap that returns null from closest. You have tested the complete journey form → view validation → model rules → board → render → store, with the cases almost nobody writes: the second submit after fixing an error, and the form completed with the keyboard alone. And you have tested the interface states on network failures, with the technique of a promise controlled from the test so you can observe the "loading" state, the retry button from 07-03 and the graceful degradation that shows the stored data when there is no connection. You know MSW and why intercepting the request is a more correct level than replacing fetch, with onUnhandledRequest: 'error' as a safety net against unexpected calls. You know how to read the combined coverage —integration barely moves the model and lifts the view from zero— and you know that a single boot of the application inflates the percentages while checking almost nothing. And you have the catalog of causes of flaky tests with its central antidote: never wait for a duration, wait for a condition, with findBy and waitFor instead of setTimeout; plus --randomize to uncover order dependencies and the policy of fixing or deleting, never blindly retrying.

One hundred and twenty-four tests in under four seconds, thirteen suites, and the whole application covered… except for one thing. All of this runs in jsdom, which is an imitation of the browser: it paints nothing, every measurement is zero, getComputedStyle barely works, the service worker from 07-05 does not exist, and the real index.html, styles.css and manifest.json have not been loaded even once. A button covered by another element, a z-index that makes the form unreachable, a stylesheet that hides the completed-tasks column, a module that fails to load because an extension is missing from an import, or a service worker serving an old version of the application: none of that shows up in the 124 tests, and all of it leaves Marta staring at a screen that does not work. To see it you have to open the application for real, in a real browser, and use it the way she would. That is End-to-End Testing with Cypress, where you will write the three critical journeys of Nómada Tasks —creating a task, completing it and checking the summary, and filtering by assignee with a shareable URL— and learn why these tests in particular, the most convincing of all, must be few and very well chosen.

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