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
- What integrating means
- The five bugs that only appear when you put pieces together
- Where integration fits: the pyramid and the testing trophy
- Integrating model and data:
Board+LocalRepository - The round trip:
toJSON→ string →fromJSON - Version migrations
jsdom: a DOM without a browser- Setting up the minimal HTML and rendering
- Testing Library: querying the way a person would
- The queries and when to use each one
user-event: interacting for real- Testing event delegation
- The complete flow: form → model → render
- Testing network errors in the interface
- MSW: mocking the server at the right level
- Combined coverage: what integration adds
- Flaky tests and how to make them deterministic
- The Nómada Tasks integration suite
- Common Mistakes and Tips
- Exercises
- Conclusion
- 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.
- 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.
- 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.
- Integrating model and data:
Board + LocalRepository
Board + LocalRepositoryThe 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.
- The round trip:
toJSON → string → fromJSON
toJSON → string → fromJSONThe 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.
- 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.
jsdom: a DOM without a browser
jsdom: a DOM without a browserThe 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.
It can be switched on globally or —better— file by file, with a header comment:
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.
- 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 withgetByLabelTextpossible, 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.
- 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.
import { screen, within } from '@testing-library/dom';
import '@testing-library/jest-dom'; // matchers like toBeVisible, toHaveTextContentThe 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.
- 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 textAnd 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');
user-event: interacting for real
user-event: interacting for realDispatching 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.
- 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.
- 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.
- 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.
- 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.
- Combined coverage: what integration adds
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.
- 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.
- 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 sOne 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:
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.htmlinto 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
awaitwithuser-eventor withfindBy…. Both return promises. Withoutawait, the assertion runs before anything has happened. - Combining
jest.useFakeTimers()withuser-eventwithoutadvanceTimers. The test hangs until the timeout expires, and the error message is no help at all. - Waiting with
setTimeoutinstead ofwaitFor/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
getComputedStyleis limited. That goes to E2E, in 08-06. - Not cleaning the DOM or the storage between tests. jsdom shares
documentwithin 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 ingetByRole('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 --randomizefrom 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 toJSON → stringify → setItem → getItem → parse → fromJSON 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
- What Is JavaScript?
- Setting Up Your Development Environment
- Your First JavaScript Program
- JavaScript Syntax and Basic Concepts
- Variables and Data Types
- Basic Operators
- Type Conversion and Comparisons
- The Course Project: Nómada Tasks
Module 2: Control Structures
- Conditional Statements
- Loops: for, while, do-while
- Switch Statements
- Flow Control: break, continue and Nested Loops
- Error Handling with try-catch
Module 3: Functions
- Defining and Calling Functions
- Function Expressions and Arrow Functions
- Parameters and Return Values
- Scope and Closures
- Hoisting and the Execution Context
- Higher-Order Functions
- Recursion
Module 4: Objects and Arrays
- Introduction to Objects
- Object Methods and the
thisKeyword - Arrays: Basics and Methods
- Iterating over Arrays
- Searching, Sorting and Aggregating Data: find, sort and reduce
- Array Destructuring
- Object Destructuring, Spread and Rest
- JSON and Copying Objects
Module 5: Advanced Objects and Functions
- Prototypes and Inheritance
- Classes and Object-Oriented Programming
- Encapsulation: Getters, Setters and Private Fields
- Modules: Import and Export
- Asynchronous JavaScript: Callbacks
- Promises and Async/Await
- The Event Loop and the Microtask Queue
- Iterators and Generators
Module 6: The Document Object Model (DOM)
- Introduction to the DOM
- Selecting and Manipulating DOM Elements
- Handling Events
- Propagation, Delegation and Custom Events
- Creating and Removing DOM Elements
- Rendering Lists and HTML Templates
- Handling and Validating Forms
Module 7: Browser APIs and Advanced Topics
- Local and Session Storage
- The Fetch API and AJAX
- Robust Requests: Errors, Timeouts and AbortController
- WebSockets
- Service Workers and Progressive Web Apps (PWAs)
- Essential Browser APIs
- Introduction to WebAssembly
Module 8: Testing and Debugging
- Debugging JavaScript
- Code Quality: ESLint, Prettier and Conventions
- Unit Testing with Jest
- Test Doubles: Mocks, Stubs and Spies
- Integration Testing
- End-to-End Testing with Cypress
Module 9: Performance and Optimization
- Measure Before You Optimize: DevTools and Web Vitals
- Optimizing JavaScript Performance
- Memory Management
- Efficient DOM Manipulation
- Lazy Loading and Code Splitting
Module 10: JavaScript Frameworks and Libraries
- Why Frameworks Exist
- Introduction to React
- State Management with Redux
- Vue.js Basics
- Angular Basics
- Choosing the Right Framework
