Your project does what it promises: the domain enforces the rules, the data survives a reload, the application talks to an API and works offline. And now comes the uncomfortable question, the one that separates a demo from a product: how do you know it still does tomorrow? Because you have migrations that can silently corrupt data, synchronization with races that only appear on a bad network, and views that can retain memory when you navigate — three things you cannot see by looking at the screen and that, when they fail, fail on somebody else's device. In this lesson you bring everything you learned in Module 8 into your own project, but with an important difference: there the tests were handed to you written, and here you have to decide what to test, at which level and how far. You will see how to define a testing strategy with the pyramid applied to your concrete modules and a reasoned rather than cosmetic coverage target; how to test the domain exhaustively with parameterized tests of the fifteen rules; how to test the data layer with doubles, fake timers and a fake fetch, including the migrations and the queue; how to test the view by querying by role and also the keyboard walkthrough; how to choose three end-to-end journeys and why no more; how to set up complete continuous integration with a performance budget; how to debug three failures that are genuinely going to appear in your project, with the technique of reproducing first and writing a failing test; how to automate and complement accessibility testing; and how to measure your project against the Nómada Tasks baseline. By the end you will have the full suite green in CI plus the accessibility and performance reports.
Contents
- The testing strategy: deciding what is tested and where
- The pyramid applied to your concrete modules
- A reasoned coverage target, not a cosmetic one
- Testing the domain: the fifteen rules parameterized
- Testing the transitions and the edge cases
- Testing the data layer with doubles and fake timers
- Testing the migrations and the offline queue
- Testing the view with jsdom and Testing Library
- Testing the keyboard walkthrough
- Three end-to-end journeys, and why no more
- Continuous integration with GitHub Actions
- Lighthouse CI and the performance budget
- Debugging your own project: the method applied
- Failure 1: the migration that corrupts data
- Failure 2: the race condition during synchronization
- Failure 3: the memory leak when navigating
- Accessibility testing: automated and manual
- Measuring your own project's performance
- Flaky tests
- Common Mistakes and Tips
- Exercises
- Conclusion
- The testing strategy: deciding what is tested and where
Before you write a single further test, three questions need answering. Writing them in docs/testing-strategy.md is half an hour that saves weeks of badly placed tests.
Question 1 · What must never fail?
In Orbita, the answer is concrete: the business rules and the persistence. A button two pixels to the left is a cosmetic defect; a migration that loses somebody's tasks is the death of the product. Tests concentrate where failure is catastrophic, not where they are easy to write.
Question 2 · What changes often and what does not?
What changes a lot — the layout, the wording, the CSS classes — must not be coupled to the tests, or every visual tweak will break twenty tests that detect no real failure. What changes little — the rules, the contracts — can be tested thoroughly with no maintenance cost.
Question 3 · How much does each test cost and how much is it worth?
| Level | Cost to write | Cost to run | Cost to maintain | Confidence it gives |
|---|---|---|---|---|
| Domain unit test | Low | Milliseconds | Low | High about one rule |
| DOM integration | Medium | Tens of ms | Medium | High about one flow |
| End to end | High | Seconds | High | Very high about the system |
The operational conclusion: many unit tests, a fair number of integration tests, very few end-to-end tests. It is not an aesthetic preference; it is a direct consequence of the table.
The decision rule that resolves almost every case:
Test at the lowest level that catches the failure you are worried about.
If you are worried that R13 does not block closing, that is a domain unit test: no browser needed. If you are worried that the button is not disabled when it should be, that is DOM integration. If you are worried that creating a task, reloading and seeing it does not work end to end, that is end to end. Testing R13 from Cypress is a hundred times slower and gives the same information.
- The pyramid applied to your concrete modules
The generic testing pyramid does not help much. This one does, because it names your files:
| Module | Main level | Rough count | Tool | What is checked |
|---|---|---|---|---|
domain/task.js |
Unit (Node) | ~35 | Jest | R1–R6, R8–R10, invariants, toJSON/fromJSON |
domain/user.js |
Unit (Node) | ~12 | Jest | R11, R15 (roles), normalization |
domain/tree.js |
Unit (Node) | ~22 | Jest | R12: building, leaves, hours, cycles, depth |
domain/board.js |
Unit (Node) | ~25 | Jest | R7, R13, R15 (workload), summary, filters |
domain/rules.js |
— | 0 | — | They are constants; they are tested through whoever uses them |
data/*-repository.js |
Contract + integration | ~10 × 3 | Jest + jsdom | The shared contract against all three implementations |
data/migrations.js |
Unit with real fixtures | ~14 | Jest | Each step, idempotency, future version, validity |
data/http.js |
Unit with a fake fetch |
~15 | Jest + double | Status codes, timeout, retries, cancellation |
data/queue.js |
Unit with fake timers | ~12 | Jest + fake timers | Order, persistence, attempts, idempotency |
application/use-cases.js |
Integration | ~18 | Jest + doubles | States, optimistic rollback, errors |
application/selectors.js |
Pure unit | ~14 | Jest | Filters, workload per week, derived data |
view/*-view.js |
DOM integration | ~30 | Testing Library | Query by role, events, accessibility, destroy |
view/task-form.js |
DOM integration | ~16 | Testing Library + user-event |
Validation, focus, aria-invalid, keyboard |
| Full journeys | End to end | 3 | Cypress | The three flows that define the product |
Rough total: about 250 tests, of which three are end to end. Nómada Tasks had 124 tests and 3 journeys with fewer features; the order of magnitude is consistent.
Two observations about this table:
rules.jsis not tested directly. They are constants. A test checking thatMAX_HOURS === 40verifies nothing: it rewrites the value somewhere else. What gets tested is that the rule is enforced, and that happens intask.js.- The view-to-domain ratio is roughly 1 to 2. If your project had more view tests than domain tests, it would be a sign the logic is in the view — exactly what the architecture in 11-01 was meant to prevent. The distribution of your tests is a diagnosis of your architecture.
- A reasoned coverage target, not a cosmetic one
Coverage measures what percentage of the code runs during the tests. It is a useful and a dangerous metric in equal parts, and it is worth understanding why.
What coverage does tell you: which code is not tested at all. A 0 % in migrations.js is a real alarm.
What coverage does not tell you: whether what is tested is well tested. This test gives 100 % coverage and verifies nothing:
test('creates a task', () => {
const task = new Task(validData);
expect(task).toBeDefined(); // ← always passes; checks nothing useful
});That is why a single target for the whole project is a bad target. The sensible thing is per layer, and with judgment:
// jest.config.js
export default {
projects: [
{ displayName: 'domain', testEnvironment: 'node', testMatch: ['<rootDir>/test/domain/**/*.test.js'] },
{ displayName: 'data', testEnvironment: 'jsdom', testMatch: ['<rootDir>/test/data/**/*.test.js'] },
{ displayName: 'view', testEnvironment: 'jsdom', testMatch: ['<rootDir>/test/view/**/*.test.js'] }
],
collectCoverageFrom: ['src/**/*.js', '!src/main.js', '!src/data/seed.js'],
coverageThreshold: {
global: { branches: 75, functions: 80, lines: 80 },
'./src/domain/': { branches: 95, functions: 95, lines: 95 },
'./src/data/migrations.js': { branches: 100, functions: 100, lines: 100 },
'./src/application/': { branches: 85, functions: 85, lines: 85 },
'./src/view/': { branches: 60, functions: 65, lines: 65 }
}
};The justification for each threshold, which is what you have to write in your strategy:
| Path | Threshold | Why |
|---|---|---|
domain/ |
95 % | It is where the value lives and where a failure is a broken rule. Cheap to test |
migrations.js |
100 % | An untested path is a path that can destroy data irreversibly |
application/ |
85 % | Orchestration; the error paths matter, and they are the ones that get forgotten |
view/ |
65 % | Testing every layout branch is expensive and fragile. You test the flows, not the pixels |
| Global | 80 % | A consequence of the ones above, not a target in itself |
And the metric that really matters is not the percentage: it is rule coverage. Keep a table in docs/testing-strategy.md:
| Rule | Passing case | Failing case | Boundary case | File |
|---|---|---|---|---|
| R1 | ✅ | ✅ | ✅ id after deleting the last one | domain/task.test.js |
| R2 | ✅ | ✅ | ✅ only spaces and only tabs | domain/task.test.js |
| R3 | ✅ | ✅ | ✅ 0, 0.5, 40, 40.1 | domain/task.test.js |
| … | ||||
| R12 | ✅ | ✅ | ✅ all three kinds of cycle | domain/tree.test.js |
| R13 | ✅ | ✅ | ✅ with no subtasks | domain/board.test.js |
| R14 | ✅ | ✅ | ✅ attempt to modify the history | domain/history.test.js |
| R15 | ✅ | ✅ | ✅ week 53, year boundary | domain/board.test.js |
Fifteen rules × three cases = forty-five minimum tests. That table is a far more honest target than "85 % coverage", because you cannot pass it without verifying something.
- Testing the domain: the fifteen rules parameterized
Parameterized tests are the natural tool for rules, because a rule is a statement about a set of cases.
// test/domain/validation-rules.test.js
import { Task } from '../../src/domain/task.js';
import { ValidationError } from '../../src/domain/errors.js';
import { validTask } from '../helpers/factories.js';
describe.each([
// rule, field, value, valid?, note
['R2', 'title', 'Service the lathe', true, 'normal'],
['R2', 'title', '', false, 'empty'],
['R2', 'title', ' ', false, 'only spaces'],
['R2', 'title', '\t\n', false, 'only whitespace'],
['R2', 'title', 'a', true, 'one character'],
['R3', 'estimatedHours', 0, false, 'zero'],
['R3', 'estimatedHours', 0.5, true, 'reasonable minimum'],
['R3', 'estimatedHours', 40, true, 'exact limit'],
['R3', 'estimatedHours', 40.1, false, 'just above'],
['R3', 'estimatedHours', -3, false, 'negative'],
['R3', 'estimatedHours', NaN, false, 'not a number'],
['R3', 'estimatedHours', '12', false, 'text: no coercion'],
['R8', 'assigneeId', null, true, 'unassigned'],
['R8', 'assigneeId', '', false, 'empty string forbidden'],
['R4', 'dueDate', '2020-01-01', false, 'earlier than creation']
])('%s · %s = %p (%s)', (rule, field, value, valid) => {
test(valid ? 'is accepted' : 'is rejected with ValidationError', () => {
const build = () => new Task({ ...validTask(), [field]: value });
if (valid) {
expect(build).not.toThrow();
} else {
expect(build).toThrow(ValidationError);
try { build(); } catch (e) { expect(e.field).toBe(field); }
}
});
});Four decisions in this table that make it valuable:
1 · The table reads as a specification. Anyone reading it knows exactly what the model accepts and rejects, without reading the Task code.
2 · Every rule has its exact boundaries. For R3: 0 no, 0.5 yes, 40 yes, 40.1 no. Developer bugs live in the > that should have been >=, and they are only caught by testing the exact boundary value.
3 · error.field is checked, not just the type. That piece of data is what lets the form mark the right field (11-02). If it is not tested, one day somebody writes field: 'hours' instead of 'estimatedHours' and the form silently stops highlighting anything.
4 · There are type cases, not just value cases. '12' as text and NaN are the ones that slip through naive validations like if (hours > 40). With NaN, that comparison is false and the value gets through.
The R14 case deserves a special test, because its invariant is not about a value but about the structure:
test('R14 · a history entry cannot be modified', () => {
const entry = history.record({ taskId: 1, field: 'status', before: 'pending', after: 'in-progress' });
expect(() => { entry.after = 'done'; }).toThrow(TypeError); // frozen
expect(history.list(1)).toHaveLength(1);
});
test('R14 · a correction produces a new entry, it does not modify the previous one', () => {
history.record({ taskId: 1, field: 'hours', before: 10, after: 99 });
history.record({ taskId: 1, field: 'hours', before: 99, after: 12 });
expect(history.list(1)).toHaveLength(2);
expect(history.list(1)[0].after).toBe(99); // the first one still says 99
});
- Testing the transitions and the edge cases
The R6 transition matrix is tested in full, as in 11-02. But there are three kinds of edge case that are systematically forgotten and worth enumerating:
Numeric boundaries. For every comparison in your domain, test the exact value, one below and one above:
| Rule | Mandatory values |
|---|---|
| R3 (0 < h ≤ 40) | 0, 0.01, 40, 40.01 |
| R7 (≤ 40 h/week) | 39.5, 40, 40.5 |
| R12 (depth ≤ 3) | 3 levels (valid), 4 (rejected) |
Collection boundaries. For every function that takes a list:
| Case | Why it matters |
|---|---|
| Empty list | reduce with no initial value throws; the interface's empty state depends on this |
| One element | Algorithms comparing pairs fail here |
| All equal | Unstable sorts, degenerate groupings |
With null mixed in |
Imperfectly migrated data |
Time boundaries. The ones that produce the most bugs and get tested the least:
describe.each([
['2026-01-01', 2026, 1, 'year starting on a Thursday'],
['2027-01-01', 2026, 53, '1 January falls in week 53 of the previous year'],
['2028-02-29', 2028, 9, 'leap year'],
['2026-12-31', 2026, 53, 'last day of the year']
])('ISO week of %s', (date, year, week) => {
test(`is ${year}-W${week}`, () => {
expect(isoWeek(date)).toEqual({ year, week });
});
});The second row is the one that breaks almost every homegrown ISO-week implementation, and it directly affects R15 and the workload report. If your project uses weeks, this test is not optional.
And a case that only appears in projects with trees: the degenerate tree. A task with 200 subtasks at a single level, and a chain of 3 levels with one leaf at each. The two exercise different paths through your recursion.
- Testing the data layer with doubles and fake timers
Here everything from 08-04 applies. The governing rule: you replace what is slow, non-deterministic or external; never the logic you are testing.
| What gets replaced | With what | Why |
|---|---|---|
fetch |
A double returning controlled responses | No network, no server, deterministic |
| Timers | jest.useFakeTimers() |
An 8 s retry takes 0 ms in the test |
Date |
jest.setSystemTime() |
Overdue tests do not depend on the day |
localStorage |
jsdom's, cleared in beforeEach |
Isolation between tests |
crypto.randomUUID |
A double with a predictable sequence | So you can assert on ids |
6.1 A fake fetch
// test/helpers/fake-network.js
export function installFakeFetch(responses) {
const calls = [];
global.fetch = jest.fn(async (url, options = {}) => {
calls.push({ url, method: options.method ?? 'GET', body: options.body, headers: options.headers });
const next = responses.shift();
if (!next) throw new Error(`unexpected fetch to ${url}`);
if (next.error) throw next.error;
return {
ok: next.status < 400,
status: next.status,
json: async () => next.body,
text: async () => JSON.stringify(next.body)
};
});
return { calls };
}The throw new Error('unexpected fetch') detail is important: one extra call you did not expect is a failure you want to see, not something that should return undefined and produce a confusing error later on.
test('a 500 is retried and the second attempt succeeds', async () => {
const { calls } = installFakeFetch([
{ status: 500, body: { message: 'Server unavailable' } },
{ status: 200, body: [{ id: 1, title: 'Service the lathe' }] }
]);
const repo = new ApiRepository('http://api.local');
const tasks = await repo.listTasks();
expect(tasks).toHaveLength(1);
expect(calls).toHaveLength(2); // retried exactly once
});
test('a 400 is NOT retried', async () => {
const { calls } = installFakeFetch([
{ status: 400, body: { message: 'The title is missing', code: 'TITLE_REQUIRED' } }
]);
await expect(new ApiRepository('http://api.local').createTask({}))
.rejects.toMatchObject({ name: 'ApiError', status: 400, code: 'TITLE_REQUIRED' });
expect(calls).toHaveLength(1); // not one attempt more
});The two assertions about calls.length are the heart of these tests. Without them, both would pass even if the retry logic were broken.
6.2 Fake timers
test('exponential backoff waits 300, 600 and 1200 ms', async () => {
jest.useFakeTimers();
installFakeFetch([{ status: 503 }, { status: 503 }, { status: 503 }, { status: 200, body: [] }]);
const promise = new ApiRepository('http://api.local').listTasks();
await jest.advanceTimersByTimeAsync(300);
expect(global.fetch).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(600);
expect(global.fetch).toHaveBeenCalledTimes(3);
await jest.advanceTimersByTimeAsync(1200);
expect(global.fetch).toHaveBeenCalledTimes(4);
await expect(promise).resolves.toEqual([]);
jest.useRealTimers();
});advanceTimersByTimeAsync and not advanceTimersByTime. The async version also drains the microtask queue between advances, and without it the intermediate promises never resolve and the test hangs. It is one of those details that cost you an afternoon the first time, and it makes complete sense in the light of 05-07: microtasks and timers are different queues.
Remember to restore the real timers. A jest.useFakeTimers() left active contaminates subsequent tests with apparently random failures. Put it in a global afterEach.
- Testing the migrations and the offline queue
7.1 Migrations
You already saw the six base tests in 11-03. Add these three, which cover what goes wrong in practice:
test('a document with an unknown field is neither lost nor breaks anything', () => {
const withExtra = { ...v1, data: { ...v1.data, experiment: [1, 2, 3] } };
expect(() => migrate(withExtra)).not.toThrow();
});
test('migrating 500 tasks takes less than 100 ms', () => {
const big = generateV1Document({ tasks: 500 });
const start = performance.now();
migrate(big);
expect(performance.now() - start).toBeLessThan(100);
});
test('each version step is tested separately', () => {
const step2 = MIGRATIONS.find((m) => m.to === 2);
const result = step2.migrate(structuredClone(v1));
expect(result.data.tasks[0]).not.toHaveProperty('assignee');
expect(result.data.tasks[0]).toHaveProperty('assigneeId');
});The second one is a performance test inside the suite, and here it is justified: the migration happens at startup, blocking the first paint. A two-second migration wrecks the LCP from the 11-01 budget, and that failure only shows up with real user data. Putting a threshold in the test prevents it.
7.2 The offline queue
describe('ChangeQueue', () => {
beforeEach(() => { localStorage.clear(); jest.useFakeTimers(); });
afterEach(() => jest.useRealTimers());
test('enqueues when there is no network and applies locally anyway', async () => { /* … */ });
test('preserves the order when flushing', async () => {
const { calls } = installFakeFetch([{ status: 201, body: { id: 7 } }, { status: 200, body: {} }]);
queue.enqueue({ type: 'create', resource: 'task', payload: { title: 'A' } });
queue.enqueue({ type: 'update', resource: 'task', payload: { id: 7, status: 'in-progress' } });
await synchronizer.flush();
expect(calls[0].method).toBe('POST');
expect(calls[1].method).toBe('PUT');
});
test('resending uses the SAME idempotency key', async () => {
installFakeFetch([{ error: new TypeError('Failed to fetch') }, { status: 201, body: { id: 7 } }]);
const id = queue.enqueue({ type: 'create', resource: 'task', payload: { title: 'A' } });
await synchronizer.flush(); // fails, stays in the queue
await synchronizer.flush(); // retries
const keys = global.fetch.mock.calls.map(([, o]) => o.headers['Idempotency-Key']);
expect(keys[0]).toBe(keys[1]);
expect(keys[0]).toBe(id);
});
test('after 5 failed attempts, the entry is marked as needing attention', async () => { /* … */ });
test('the queue survives recreating the object', () => {
queue.enqueue({ type: 'create', resource: 'task', payload: {} });
expect(new ChangeQueue('orbita:queue').pending).toBe(1);
});
});The third test is the one that prevents the duplicate from section 13 of 11-03, and it is impossible to check by hand reliably. It is the perfect example of a failure that only an automated test catches.
- Testing the view with jsdom and Testing Library
The principle from 08-05, worth remembering because it changes everything:
Test the way a person uses the application, not the way it is built inside.
In practice, that means querying by role and by accessible text, never by CSS class or structure.
| ❌ Fragile and uninformative | ✅ Robust and meaningful |
|---|---|
container.querySelector('.task__btn') |
getByRole('button', { name: /start/i }) |
document.querySelectorAll('.row') |
getAllByRole('listitem') |
input.value = 'x'; input.dispatchEvent(...) |
await user.type(input, 'x') |
expect(el.className).toContain('error') |
expect(input).toHaveAttribute('aria-invalid', 'true') |
The double benefit: the test survives a CSS class change, and it verifies accessibility as a bonus. If getByRole('button', { name: /create/i }) finds nothing, it is because your button is not a button or has no accessible name — and that is a real defect a querySelector would have hidden.
// test/view/board-view.test.js
import { screen, within } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
describe('BoardView', () => {
let container, emitted, view;
beforeEach(() => {
document.body.innerHTML = '<div id="root"></div><div id="announcements" role="status" aria-live="polite"></div>';
container = document.getElementById('root');
emitted = [];
view = createBoardView(container, { onEmit: (e) => emitted.push(e) });
});
afterEach(() => view.destroy());
test('renders one row per task, as a semantic list', () => {
view.render(stateWith(3));
expect(screen.getAllByRole('listitem')).toHaveLength(3);
});
test('the overdue task is identified with text, not just color', () => {
view.render(stateWith([overdueTask({ title: 'Quote' })]));
const row = screen.getByRole('listitem');
expect(within(row).getByText(/overdue/i)).toBeInTheDocument();
});
test('clicking the button emits the intent with the right id', async () => {
const user = userEvent.setup();
view.render(stateWith([task({ id: 42, title: 'Service the lathe' })]));
await user.click(screen.getByRole('button', { name: /start/i }));
expect(emitted).toEqual([{ action: 'change-status', id: 42, value: 'in-progress' }]);
});
test('the number of visible tasks is announced', () => {
view.render(stateWith(12));
expect(screen.getByRole('status')).toHaveTextContent(/12 tasks/i);
});
test('no results shows an empty state with a way out', () => {
view.render(stateWithEmptyFilter());
expect(screen.getByText(/no tasks match/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /clear filters/i })).toBeInTheDocument();
});
test('destroy leaves the container empty and listener-free', () => {
view.render(stateWith(3));
const spy = jest.spyOn(container, 'removeEventListener');
view.destroy();
expect(container).toBeEmptyDOMElement();
expect(spy).toHaveBeenCalled();
});
});Notice that half of these tests verify accessibility and experience requirements — semantic list, text in addition to color, count announcement, empty state with a way out — that you wrote as acceptance criteria back in 11-01. The circle closes: story → criterion → test.
- Testing the keyboard walkthrough
This is the part almost no portfolio project has, and the one that most impresses in a review.
test('a task can be created without touching the mouse', async () => {
const user = userEvent.setup();
startApplication();
await user.tab(); // first focusable element
expect(screen.getByRole('button', { name: /new task/i })).toHaveFocus();
await user.keyboard('{Enter}'); // open the dialog
const dialog = screen.getByRole('dialog', { name: /new task/i });
expect(within(dialog).getByLabelText(/title/i)).toHaveFocus(); // focus enters on open
await user.keyboard('Service the lathe');
await user.tab();
await user.keyboard('8'); // hours
await user.keyboard('{Enter}'); // submit with Enter
expect(screen.getByRole('listitem', { name: /service the lathe/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /new task/i })).toHaveFocus(); // focus RETURNS
});
test('Escape closes the dialog and returns focus to the trigger', async () => {
const user = userEvent.setup();
startApplication();
const trigger = screen.getByRole('button', { name: /new task/i });
await user.click(trigger);
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
test('focus does not escape the open dialog', async () => {
const user = userEvent.setup();
startApplication();
await user.click(screen.getByRole('button', { name: /new task/i }));
for (let i = 0; i < 12; i++) await user.tab(); // go round several times
const dialog = screen.getByRole('dialog');
expect(dialog.contains(document.activeElement)).toBe(true);
});The three cover the three most common focus bugs: not entering on open, not returning on close and escaping while open. If you use <dialog> with showModal(), all three pass with almost no effort — which is exactly the argument from 11-02 about using the right element.
A warning about jsdom: jsdom implements <dialog> incompletely in some versions, and it does not compute styles, so it cannot verify visibility or contrast. Keyboard tests in jsdom cover the logic of focus; genuinely verifying that you can see where you are is a manual or end-to-end check.
- Three end-to-end journeys, and why no more
End-to-end tests (08-06) are the most valuable per test and the most expensive per test. That double condition dictates the strategy: few and very well chosen.
| Cost | Detail |
|---|---|
| Execution | Seconds per journey, versus milliseconds |
| Maintenance | Any flow change breaks them |
| Flakiness | They depend on timing, network and animations |
| Diagnosis | When they fail, they say that something is wrong, not what |
The selection criterion, in one sentence: choose the journeys that, if they broke, would make the product useless. For Orbita:
Journey 1 · The complete lifecycle of a task.
// cypress/e2e/01-lifecycle.cy.js
describe('Task lifecycle', () => {
beforeEach(() => {
cy.clearLocalStorage();
cy.visit('/');
});
it('creates, breaks down, advances and closes a task', () => {
cy.findByRole('button', { name: /new task/i }).click();
cy.findByLabelText(/title/i).type('Service the wood lathe');
cy.findByLabelText(/hours/i).type('8');
cy.findByRole('button', { name: /create task/i }).click();
cy.findByRole('listitem', { name: /service the wood lathe/i }).as('task');
cy.get('@task').findByRole('button', { name: /add subtask/i }).click();
cy.findByLabelText(/title/i).type('Buy the sandpaper');
cy.findByRole('button', { name: /create subtask/i }).click();
// R13: the parent cannot be closed while the child is open
cy.get('@task').findByRole('button', { name: /mark done/i }).click();
cy.findByRole('alert').should('contain.text', 'Buy the sandpaper');
// Close the child first, and then it works
cy.findByRole('listitem', { name: /buy the sandpaper/i })
.findByRole('button', { name: /mark done/i }).click();
cy.get('@task').findByRole('button', { name: /mark done/i }).click();
cy.get('@task').should('contain.text', 'Completed');
// Real persistence
cy.reload();
cy.findByRole('listitem', { name: /service the wood lathe/i }).should('contain.text', 'Completed');
});
});This journey covers, all at once: creating with validation, the tree, R13 genuinely enforced from the interface, the error message, the complete transition and persistence after a reload. One journey, six risks.
Journey 2 · Filter, share the filter and view the report. It covers shared state, the URL as the source of truth, the workload calculation and the empty state.
Journey 3 · Work offline and recover. It covers the queue, persistence and reconciliation:
it('offline changes synchronize when the connection comes back', () => {
cy.visit('/');
cy.intercept('POST', '**/tasks', { forceNetworkError: true }).as('noNetwork');
cy.createTask('Carpentry workshop quote');
cy.findByText(/1 unsynced change/i).should('be.visible');
cy.intercept('POST', '**/tasks', { statusCode: 201, body: { id: 99 } }).as('withNetwork');
cy.window().then((win) => win.dispatchEvent(new Event('online')));
cy.wait('@withNetwork');
cy.findByText(/unsynced/i).should('not.exist');
cy.findAllByRole('listitem', { name: /carpentry workshop quote/i }).should('have.length', 1);
});That final should('have.length', 1) is the duplicate test: if idempotency fails, two appear.
Why exactly three. With three journeys you cover the three systemic risks — the main flow, shared state and resilience — in about 30 seconds of execution. With twenty you would have five minutes of CI, weekly flaky failures and a maintenance load that would end with somebody disabling them. A disabled end-to-end suite is worth zero, so the right number is the maximum you can keep permanently green. Three is that number. Nómada Tasks also had three, with fewer features: the ratio holds.
- Continuous integration with GitHub Actions
Continuous integration runs npm run verify on every push, on a clean machine. That "clean machine" is half the value: it catches the "it works on my machine" cases caused by an uncommitted file or a global dependency.
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
# Cancel old runs of the same branch: saves minutes and gives relevant results
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
quality:
name: Lint, formatting and tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm # caches ~/.npm: from ~60 s to ~10 s
- name: Install dependencies
run: npm ci # ci, not install: honors the lockfile exactly
- name: Lint the code
run: npm run lint
- name: Check the formatting
run: npm run format:check
- name: Tests with coverage
run: npm run test:cov -- --ci --maxWorkers=2
- name: Publish the coverage report
if: always() # also when the tests failed
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage/
- name: Build
run: npm run build
- name: Save the build
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
e2e:
name: End-to-end journeys
runs-on: ubuntu-latest
needs: quality # do not spend minutes if lint already failed
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- name: Cypress
uses: cypress-io/github-action@v6
with:
build: npm run build
start: npm run preview
wait-on: 'http://localhost:4173'
wait-on-timeout: 60
- name: Save the failure screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: cypress-screenshots
path: cypress/screenshots/
accessibility-and-performance:
name: Lighthouse and axe
runs-on: ubuntu-latest
needs: quality
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npm run build
- name: Lighthouse CI
run: npx @lhci/[email protected] autorun
env:
LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}The file's decisions, one by one:
| Line | Why |
|---|---|
concurrency with cancel-in-progress |
If you push three times in a row, only the last one runs. Saves minutes and avoids stale results |
cache: npm |
Cuts installation from ~60 s to ~10 s. It is the cheapest improvement there is |
npm ci instead of npm install |
Installs exactly what package-lock.json says. Reproducible; install can bump minor versions |
--ci --maxWorkers=2 |
The runners have 2 cores; more workers compete and slow things down. --ci disables automatic snapshot updating |
if: always() on coverage |
When the tests fail is precisely when you want to see the report |
needs: quality on the other jobs |
Do not spend five Cypress minutes if lint failed in ten seconds |
if: failure() on the screenshots |
Cypress saves a screenshot and video of the failure: it is the first thing you will look at |
Branch protection. CI is worthless if you can merge while it is red. In the repository settings, mark main as protected and require all three jobs to pass before merging. It is one click that turns a recommendation into a guarantee — the same idea as the ESLint rule from 11-01.
- Lighthouse CI and the performance budget
In 11-01 you set the budget. Here you enforce it automatically.
{
"ci": {
"collect": {
"staticDistDir": "./dist",
"numberOfRuns": 3,
"settings": { "preset": "desktop" }
},
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"categories:accessibility": ["error", { "minScore": 0.95 }],
"categories:best-practices":["warn", { "minScore": 0.9 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-blocking-time": ["error", { "maxNumericValue": 200 }],
"resource-summary:script:size": ["error", { "maxNumericValue": 61440 }],
"resource-summary:total:count": ["warn", { "maxNumericValue": 10 }],
"color-contrast": ["error", { "minScore": 1 }],
"label": ["error", { "minScore": 1 }],
"button-name": ["error", { "minScore": 1 }],
"unused-javascript": ["warn", { "maxLength": 1 }]
}
},
"upload": { "target": "temporary-public-storage" }
}
}Four observations:
numberOfRuns: 3. Lighthouse is noisy; with a single run you would get random failures. Three runs and take the median, exactly the measurement discipline from 09-01.errorversuswarn. What breaks the build is what is in the signed budget; the rest is informative. If everything is an error, you will end up disabling the whole thing on the first difficult Friday.resource-summary:script:sizeat 61440 is the 60 kB budget from 11-01, in bytes. This single line is what stops somebody adding a charting library one day without anyone noticing.- The three accessibility audits with
minScore: 1— contrast, labels, button names — are the ones that degrade most over time. Demanding perfection on those three is achievable and prevents erosion.
When the budget fails, there are three legitimate ways out and one illegitimate one:
| Way out | When |
|---|---|
| Optimize the change | Almost always. Apply Module 9 |
| Drop the feature | If its value does not justify its cost |
| Raise the budget consciously and write down why | If the product has grown legitimately |
| ~~Disable the check~~ | Never. It is how you get to 2 MB applications |
- Debugging your own project: the method applied
Lesson 08-01 gave you the method. Here it is applied to your own failures, and the important change is that nobody is going to tell you where the problem is.
The procedure, in six steps:
flowchart TD
A["1 · Reproduce it<br/>reliably"] --> B["2 · Reduce to the<br/>minimal case"]
B --> C["3 · Write a test<br/>THAT FAILS"]
C --> D["4 · Form a concrete,<br/>falsifiable hypothesis"]
D --> E["5 · Check it with<br/>a single measurement"]
E -->|False| D
E -->|True| F["6 · Fix it · The test<br/>turns green"]
F --> G["The test stays:<br/>regression covered"]
style C fill:#fee2e2,stroke:#b91c1c
style G fill:#dcfce7,stroke:#16a34a
Step 3 is the one almost everybody skips, and it is the most profitable of the six. Writing the test before fixing has four simultaneous benefits:
- It proves you have understood the failure. If you cannot write the test, you do not know what is wrong: you only have a symptom.
- It tells you when you are done. Without it, "it seems to work now" is all you have.
- It prevents recurrence. Bugs fixed without a test come back, and they come back at the worst moment.
- It documents the odd case. A year from now, that test will explain why the code has that apparently unnecessary line.
Step 4 deserves a nuance. A useful hypothesis is falsifiable and concrete: "the id reaching the PUT is undefined because the server returns _id". A useless hypothesis is "something is going on with the ids". The first is checked with one measurement; the second leads you to poke at things to see if they fix themselves, which is the most expensive form of debugging there is.
The next three sections walk through three failures that are going to appear in your project. They are not made-up examples: they are the direct consequences of what you built in 11-02 and 11-03.
- Failure 1: the migration that corrupts data
The symptom. A test user (or you, with your old development profile) updates the application and sees the board with the tasks, but they all appear with no assignee and the workload report comes out at zero. There is no error in the console.
Step 1 · Reproduce. The failure does not happen with new data, only with old data. Reproducing it requires the original document — and here the backup from 11-03 pays off:
// In the console: recover the pre-migration backup
const old = localStorage.getItem('orbita:board:backup:v1');
console.log(JSON.parse(old).data.tasks.slice(0, 2));[
{ id: 1, title: 'Redesign the big room', assignee: 'Iván', estimatedHours: 12 },
{ id: 2, title: 'Signage', assignee: 'Marta', estimatedHours: 6 }
]The old data does have assignees. So the migration is what loses them.
Step 2 · Reduce. To the minimal case: two tasks and one user.
Step 3 · The failing test:
test('the v1→v2 migration preserves the assignee assignment', () => {
const v1 = {
version: 1,
data: {
users: [{ id: 'u-ivan', name: 'Iván' }],
tasks: [{ id: 1, title: 'Redesign the big room', assignee: 'Iván', estimatedHours: 12 }]
}
};
const result = migrate(v1);
expect(result.data.tasks[0].assigneeId).toBe('u-ivan'); // ← FAILS: gets null
});Step 4 · Hypothesis. The byName map does not find 'Iván'. Possible causes: (a) the users are not loaded when the migration runs, (b) the name does not match exactly, (c) the migration order is wrong.
Step 5 · Check with a measurement:
migrate: (doc) => {
const byName = new Map(doc.data.users.map((u) => [u.name, u.id]));
console.log('map keys:', [...byName.keys()]);
console.log('looking for:', JSON.stringify(doc.data.tasks[0].assignee));
// …
}There it is. The two strings look identical on screen and are not equal: one uses the precomposed character í (U+00ED) and the other an i followed by a combining accent (U+0301). It is a classic with data that has passed through different operating systems or browsers.
Step 6 · Fix. By normalizing Unicode on both sides:
const normalize = (s) => s?.normalize('NFC').trim();
const byName = new Map(doc.data.users.map((u) => [normalize(u.name), u.id]));
// …
assigneeId: t.assignee ? (byName.get(normalize(t.assignee)) ?? null) : nullAnd the three generalizable lessons:
- Matching by text is fragile. Match by identifier whenever possible. If there is no alternative, normalize (
normalize('NFC'),trim(), and considertoLocaleLowerCase()). - A silent failure is worse than a noisy one. The migration set
nullwithout warning. It should count how many assignments it could not resolve and log it:log('migration v2: 6 of 8 assignees unresolved'). A warning would have revealed the bug on day one. - The backup saved the investigation. Without it, the original data would have vanished and you would only have the symptom.
The regression test that stays, with the Unicode case made explicit:
test.each([
['Iván', 'Iván'], // combining vs precomposed
['Lucía ', 'Lucía'], // stray space
])('matches %p with %p despite the encoding difference', (inUser, inTask) => {
const doc = v1Document({ user: inUser, assigneeInTask: inTask });
expect(migrate(doc).data.tasks[0].assigneeId).not.toBeNull();
});
- Failure 2: the race condition during synchronization
The symptom. On a slow network, sometimes — not always — marking a task as done makes it go back to pending a second later. It does not happen on your machine with a fast network. There is no error in the console.
This is the worst kind of failure: intermittent and environment-dependent. And the first rule is not to try to debug it until you can reproduce it at will.
Step 1 · Reproduce. With the Network panel on Slow 3G, it happens one time in three. That is still not enough. To make it deterministic, you have to control the timing:
// In the console, artificially delay the PUT
const original = window.fetch;
window.fetch = async (url, options) => {
if (options?.method === 'PUT') await new Promise((r) => setTimeout(r, 3000));
return original(url, options);
};With a three-second delay, it happens every time. Now it is reproducible.
Step 2 · Reduce. The minimal sequence turns out to be:
t=0.0 s Mark done → optimistic: done → PUT goes out (will take 3 s)
t=0.5 s The list refreshes → GET goes out (fast)
t=0.8 s The GET arrives → the server still says PENDING → pending is rendered
t=3.0 s The PUT arrives → confirms doneThe list refreshes with data from before my change and overwrites the optimistic update.
Step 3 · The failing test, with fake timers to make it deterministic:
test('an in-flight refresh does not revert an optimistic update', async () => {
jest.useFakeTimers();
const repo = repositoryWithLatencies({ PUT: 3000, GET: 300 });
const store = createStore(stateWith([task({ id: 1, status: 'pending' })]));
const marking = markDone(store, repo, 1); // we do not await
await jest.advanceTimersByTimeAsync(500);
const refresh = refreshTasks(store, repo); // fired in the middle
await jest.advanceTimersByTimeAsync(3000);
await Promise.all([marking, refresh]);
expect(store.get().tasks[0].status).toBe('done'); // ← FAILS: 'pending'
jest.useRealTimers();
});Step 4 · Hypothesis. The GET replaces the whole list with whatever the server returns, without accounting for local changes still awaiting confirmation.
Step 5 · Confirmed: refreshTasks does store.update({ tasks: fromServer }).
Step 6 · Fix. Three possible solutions, and it is worth seeing why one is chosen:
| Solution | How | Assessment |
|---|---|---|
| Do not refresh while there are in-flight sends | A global flag | Fragile; blocks legitimate refreshes |
| Merge while respecting what is pending | The refresh does not overwrite tasks with a change in flight | The correct one |
| Version and discard the stale | Every task with a version; older data is ignored |
The most robust, if the API offers it |
export function mergeWithPending(fromServer, current, pendingIds) {
return fromServer.map((remote) =>
pendingIds.has(remote.id)
? current.find((t) => t.id === remote.id) // keep the optimistic local one
: remote
);
}The three generalizable lessons:
- An intermittent failure is almost always a race. When something happens "sometimes", look for two asynchronous operations that can finish in different orders.
- Reproducing requires controlling time. Delaying requests on purpose turns "one time in three" into "always", and that is what makes debugging possible.
- Any state that is replaced wholesale is suspect.
update({ tasks: newOnes })discards information that may be more recent. Replacing should always be a decision, not the default path.
- Failure 3: the memory leak when navigating
The symptom. After navigating between board, report and history for a while, the application becomes slow. At first you do not notice; after twenty navigations, every screen change takes visibly longer.
Step 1 · Reproduce and measure, with the protocol from 09-03:
- Open the Memory panel in incognito mode.
- Snapshot 1 (baseline).
- Navigate 20 times between the three screens.
- Force garbage collection (the trash-can icon).
- Snapshot 2.
- Compare by Objects allocated between snapshots.
The result:
| Snapshot | Heap | Detached nodes | BoardView |
|---|---|---|---|
| 1 (baseline) | 12.4 MB | 0 | 1 |
| 2 (after 20 navigations) | 38.9 MB | 4,812 | 21 |
Twenty-one live instances of BoardView. There should be one. The 4,812 detached nodes are the DOM of the previous twenty, retained by something.
Step 2 · Find the retainer. In the Memory panel, select one instance and look at Retainers:
The store is retaining twenty subscriber functions, each with its closure over its view and its DOM.
Step 3 · The failing test:
test('destroying the view unsubscribes it from the store', () => {
const store = createStore(initialState());
const view = createBoardView(document.createElement('div'), { store, onEmit: () => {} });
expect(store.subscriberCount).toBe(1);
view.destroy();
expect(store.subscriberCount).toBe(0); // ← FAILS: still 1
});
test('navigating ten times does not accumulate subscribers', () => {
const store = createStore(initialState());
for (let i = 0; i < 10; i++) {
const v = createBoardView(document.createElement('div'), { store, onEmit: () => {} });
v.destroy();
}
expect(store.subscriberCount).toBe(0); // ← FAILS: 10
});Steps 4 and 5 · The cause. The code was this:
// ❌ Subscribes and throws away the unsubscribe function
store.subscribe((state) => update(state));
function destroy() {
container.replaceChildren(); // clears the DOM… but the subscription is still alive
}Step 6 · Fix, by keeping every unsubscribe:
const unsubscribes = [];
unsubscribes.push(store.subscribe((state) => update(state)));
unsubscribes.push(connectController(container, onEmit));
unsubscribes.push(channel.subscribe(onMessage));
const observer = new IntersectionObserver(onVisible);
unsubscribes.push(() => observer.disconnect());
const timer = setInterval(refresh, 30_000);
unsubscribes.push(() => clearInterval(timer));
function destroy() {
unsubscribes.forEach((off) => off());
unsubscribes.length = 0;
container.replaceChildren();
lastState = null; // release the reference to the state
}The four generalizable lessons:
- Everything that connects must disconnect. Listeners, subscriptions, timers, observers, sockets. The "push the unsubscribe into an array" pattern turns
destroy()into three always-identical lines. - A leak is not seen, it is measured. Nobody detects 26 MB by looking at the screen. Without the three-snapshot protocol from 09-03, this is discovered when a user says "it gets slow after a while".
- It is checkable in a unit test. You do not need the Memory panel to prevent recurrence: exposing
subscriberCountand counting is enough. That test takes one millisecond and runs on everypush. - Add the navigation counter to your checklist. Entering and leaving each screen three times, with the listener counter in view, should be part of closing every increment.
- Accessibility testing: automated and manual
There is a figure worth keeping in mind: automated tools detect around a third of real accessibility problems. They are indispensable and they are not sufficient.
17.1 The automated part: axe
// test/accessibility/screens.test.js
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe.each([
['board', mountBoard],
['form', mountOpenForm],
['report', mountReport],
['history', mountHistory],
['calendar', mountCalendar],
['empty state', mountNoResults],
['error state', mountWithError]
])('Accessibility · %s', (name, mount) => {
test('no axe violations', async () => {
const { container } = mount();
expect(await axe(container)).toHaveNoViolations();
});
});The last two cases — empty state and error state — are the ones nobody tests and where the most violations show up, because they are laid out in a hurry and never reviewed.
And in Cypress, against the real application (which does compute styles, and therefore does detect contrast):
// cypress/e2e/accessibility.cy.js
it('the board has no serious violations', () => {
cy.visit('/');
cy.injectAxe();
cy.checkA11y(null, { includedImpacts: ['critical', 'serious'] });
});17.2 The manual part: the remaining two thirds
No tool detects that the tab order is absurd, that an alternative text says "image1", or that the flow is impossible to follow without seeing the screen. That is tested by hand, with this list:
| # | Test | How | What to look for | Time |
|---|---|---|---|---|
| 1 | Keyboard only | Put the mouse away, walk the whole application | Everything reachable, focus visible, logical order, no traps | 10 min |
| 2 | Focus in dialogs | Open and close them all | It enters on open, returns on close, Escape works | 3 min |
| 3 | Screen reader | NVDA (Windows), VoiceOver (macOS), Orca (Linux) | With your eyes closed: can you create a task? | 20 min |
| 4 | 200 % zoom | Ctrl and the wheel | Nothing is cut off, nothing disappears, no horizontal bar | 3 min |
| 5 | Enlarged text only | Browser font at 200 % | The layout holds up without overlapping | 3 min |
| 6 | No color | The system grayscale filter | Can you tell overdue, overload and status apart? | 2 min |
| 7 | Reduced motion | prefers-reduced-motion enabled |
The animations switch off | 2 min |
| 8 | Contrast | DevTools on each piece of text | ≥ 4.5:1 normal, ≥ 3:1 large and UI | 5 min |
About 50 minutes per full review. Do it at the close of each milestone, not at the end.
Point 3 is scary the first time and it is the one that teaches most. Tips for getting started: learn five shortcuts (read everything, next element, next heading, list of links, list of headings), genuinely close your eyes, and try to complete a specific task. What you will almost certainly discover: that your headings do not form a navigable structure, that your icon buttons say nothing, and that when something changes on screen you have no idea.
17.3 The accessibility report
A milestone deliverable, in docs/accessibility.md:
# Accessibility report — Orbita v1.0
Date: 2026-11-14 · Reviewer: <your name>
## Automated (axe 4.x)
| Screen | Critical | Serious | Moderate | Minor |
|---|---|---|---|---|
| Board | 0 | 0 | 1 | 2 |
| Form | 0 | 0 | 0 | 1 |
| Report | 0 | 0 | 0 | 0 |
| History | 0 | 0 | 0 | 1 |
| Empty state | 0 | 0 | 0 | 0 |
## Manual
| Test | Result | Notes |
|---|---|---|
| Keyboard only | ✅ | Full walkthrough verified |
| Focus in dialogs | ✅ | Native `<dialog>` |
| Screen reader (NVDA) | ⚠️ | The report table reads slowly with many columns |
| 200 % zoom | ✅ | |
| No color | ✅ | Overdue and overload carry text |
| Contrast | ✅ | Lowest measured: 4.8:1 |
## Known limitations
- The calendar is not optimized for screen readers with more than 30 tasks/month.
- Not tested with mobile screen readers.
## Next steps
- [ ] Add a textual summary of the report before the table
- [ ] Test with TalkBack on AndroidThat document, with its stated limitations, is worth more in an interview than a Lighthouse score of 100. It shows you actually looked.
- Measuring your own project's performance
Apply the protocol from 09-01 to your project: the median of 15 repetitions after 5 warm-up runs, 4× CPU, Slow 4G network, incognito, same machine.
Generate realistic data first. Measuring with six tasks tells you nothing:
// src/data/large-seed.js — development only
export function generateRealisticLoad({ tasks = 500, subtasksPer = 2, history = 3000 } = {}) {
// A distribution close to the real one: 60 % pending, 25 % in progress, 15 % done
// Dates spread over 6 months; 20 % with no date; 8 % overdue
// 4 users, one of them inactive
}And the results table, with the Nómada Tasks reference alongside:
| # | Measurement | Budget (11-01) | Orbita | Nómada Tasks | Verdict |
|---|---|---|---|---|---|
| 1 | Initial JS compressed | ≤ 60 kB | 54.1 kB | 58.3 kB | ✅ |
| 2 | Requests for the first screen | ≤ 4 | 4 | 3 | ✅ |
| 3 | LCP (simulated mobile) | ≤ 2.5 s | 2.1 s | 1.9 s | ✅ |
| 4 | CLS | ≤ 0.1 | 0.03 | 0.02 | ✅ |
| 5 | INP when filtering | ≤ 200 ms | 61 ms | 42 ms | ✅ |
| 6 | render() with 500 tasks |
≤ 50 ms | 44 ms | 31 ms (600) | ✅ |
| 7 | DOM nodes | ≤ 1,500 | 1,380 | 1,194 | ✅ |
| 8 | Memory after 3 cycles | ≈ 0 | +0.4 MB | no leaks | ✅ |
| 9 | Lighthouse performance | ≥ 90 | 94 | — | ✅ |
How to read the comparison honestly, which is the part that matters:
- Orbita is somewhat heavier than Nómada Tasks, and it should be. It has three more screens, a subtask tree, a history and a synchronization queue. Staying within budget with more functionality is the correct result.
- Being under budget is no reason to relax: it is headroom for what is coming. Note the headroom down: 5.9 kB of JavaScript and 400 ms of LCP.
- If you are outside, the table tells you where to look. Every row has its Module 9 lesson: high
render()→ 09-04; high JS → 09-05; memory → 09-03; INP → 09-02.
And a rule against self-deception: measure in incognito mode, with no extensions and without the dev server. Vite in development serves unbundled modules, and measuring there gives numbers with nothing to do with production. You measure on npm run build and npm run preview.
- Flaky tests
A flaky test passes sometimes and fails other times with no code change. They are a suite's worst enemy, for one concrete reason: they destroy trust. As soon as people start re-running CI "to see if it passes this time", the tests stop being a signal and become noise.
The causes, with their solutions:
| Cause | Typical symptom | Solution |
|---|---|---|
| Dependence on real time | Fails on Mondays, or at month end | jest.setSystemTime(); inject today |
| Fixed waits | A cy.wait(500) that is sometimes not enough |
Wait on a condition: cy.findByText(...) |
| Order between tests | Passes alone, fails in a group | Clean up in beforeEach; do not share state |
| Asynchrony without awaiting | Fails on slow machines | Genuine await; findBy* instead of getBy* |
| Randomness | Fails one time in twenty | Replace Math.random and crypto.randomUUID |
| Animations | The element "is not visible" | prefers-reduced-motion in tests; disable transitions |
| Concurrency | Fails only in CI | Run whatever shares a resource serially |
| External resources | Fails when the network is bad | Fake it; never call a real service |
The protocol when a flaky test appears:
- Never re-run it and move on. That is the habit that ruins suites.
- Label it and run it in a loop to measure the frequency:
npx jest --testNamePattern="name" --runInBand --repeat=50 - Isolate the cause with the table above. It is almost always time or shared state.
- Fix the cause, not the symptom. Adding
cy.wait(2000)fixes nothing: it makes the test slower and it will still fail on a slower machine. - If you cannot fix it today, mark it with
test.skipand open an issue with a date. A disabled, documented test is honest; an active flaky test is corrosive.
And the hygiene rule: run the full suite five times in a row before signing off a milestone. If all five pass, you have a reliable suite. If one fails, you have a flaky test you already know about — and it is far better to know about it now than on a Friday during a deployment.
Common Mistakes and Tips
Testing the implementation instead of the behavior. A test that checks an internal method was called breaks with every refactor without detecting any real failure. Test what goes in and what comes out, or what the user sees. The practical rule: if reorganizing the code internally, without changing behavior, breaks tests, those tests were wrong.
Chasing 100 % coverage. The last few percentage points are usually defensive branches that never run, and forcing them produces artificial tests that add no confidence. Raise the bar where the risk is high — domain and migrations — and accept less where the cost is high and the risk low.
Writing twenty end-to-end tests. They are slow, fragile and flaky. With twenty, CI takes five minutes and somebody will end up disabling them. Three well-chosen ones give almost the same systemic confidence at a fraction of the cost.
querySelector by CSS class in view tests. It couples the test to the layout and verifies no accessibility. getByRole does both things well and fails when your HTML is not semantic, which is valuable information.
Not cleaning state between tests. localStorage, fake timers, fetch doubles and the DOM carry over from one test to the next and produce order-dependent failures. A beforeEach that cleans everything, always.
Using advanceTimersByTime with asynchronous code. It does not drain the microtask queue and the test hangs or fails with no explanation. advanceTimersByTimeAsync and await.
Fixing a bug without first writing the failing test. You lose all four benefits: the proof that you understood it, the completion criterion, protection against recurrence and documentation of the odd case. It is the habit that most separates people who debug with a method from people who poke at things.
Debugging an intermittent failure without making it deterministic first. "It happens sometimes" cannot be debugged. Control the timing, the responses and the randomness until it happens every time; then start investigating.
Re-running CI until it passes. Every time you do it, your suite loses value. Treat every flaky test as a real failure, because it usually is: the race producing the failure in CI exists in production too.
Believing axe is enough. It detects around a third of the problems. A keyboard and twenty minutes with a screen reader find things no tool sees.
Tip · Run the tests in watch mode while you program. npm run test:watch with Jest runs only what is affected by what you just saved. The feedback loop drops from minutes to under a second, and that changes how you work.
Tip · Write the test name as a sentence describing the behavior. 'rejects closing a task with open subtasks', not 'test changeStatus 3'. When it fails in CI three months from now, the name will be all the information you have at first.
Tip · Keep a well-tended test/helpers/ folder. Entity factories, application mounting, fake fetch, data generators. Every minute invested there is multiplied by the number of tests you will write afterwards.
Tip · Add the test count and coverage to the README. "251 tests · 3 E2E journeys · 94 % domain coverage" is an immediate signal of seriousness to anybody looking at your repository, and it reminds you to keep it up.
Exercises
These exercises are milestone H5 of your project: the full suite green in CI plus the accessibility and performance reports.
Exercise 1 — The strategy and the complete suite.
- Write
docs/testing-strategy.mdwith: the three questions from section 1 answered for your project, the pyramid table with your modules and their numbers, and the coverage thresholds per layer with their justification. - Configure Jest with separate projects:
nodefor the domain andjsdomfor the rest, withcoverageThresholdper path. - Complete the rule coverage table: the 15 rules × (passing case, failing case, boundary case). At least 45 domain tests.
- Write parameterized tests with
describe.eachfor at least: field validation, the complete transition matrix and the date cases (including week 53). - Test the data layer with a fake
fetchand fake timers: status codes, selective retries, timeout, cancellation and exponential backoff verified against timings. - Test the migrations (at least 9 tests, including idempotency, domain validity and the performance one) and the queue (at least 6, including the idempotency key shared across retries).
- Test the view with Testing Library, always querying by role or accessible text, including at least three keyboard walkthrough tests (focus entry, focus return, no escape).
- Write exactly three Cypress journeys and justify in writing why those three.
Exercise 2 — Complete continuous integration.
- Write
.github/workflows/ci.ymlwith the three jobs: quality, end to end and Lighthouse, withconcurrency, the npm cache,needsand artifact uploads. - Configure
lighthouserc.jsonwith your budget from 11-01 translated into assertions, distinguishingerrorfromwarn. - Enable branch protection on
mainrequiring all three jobs. - Deliberately trigger a failure of each kind and check that CI catches it: a lint error, a broken test, a coverage regression below the threshold, and a bundle size increase above the budget (import a large library and remove it afterwards).
- Document the CI status in the README with its badge.
Exercise 3 — The three failures, the reports and the baseline.
- Reproduce the three failures from sections 14, 15 and 16 in your own project. If any does not occur naturally, cause it: remove the normalization from a migration, artificially delay a
PUT, or delete a subscription's unsubscribe. - For each one, document in
docs/debugging.md: symptom, reproduction procedure, minimal case, failing test, hypotheses (including the discarded ones), root cause and fix. - Leave all three regression tests in the suite.
- Run the complete accessibility review: automated axe on the 7 screens (including the empty and error states) and the 8 manual tests. Write
docs/accessibility.mdin the format from section 17.3, including the known limitations. - Generate realistic data (≥ 500 tasks, ≥ 3,000 history entries) and measure your baseline with the protocol from 09-01 against the production build. Write
docs/performance.mdwith the 9-row table comparing the budget, your result and Nómada Tasks. - Run the full suite five times in a row. If any run fails, find and fix the flaky test before considering the milestone closed.
Solutions
Acceptance criteria for exercise 1 — Suite
| # | Criterion | How it is checked |
|---|---|---|
| 1 | The domain tests run without jsdom | testEnvironment: 'node' and everything passes |
| 2 | All 15 rules have their 3 cases | Complete table, no gaps |
| 3 | The exact numeric boundaries are tested | 40 and 40.1 appear in the suite |
| 4 | Week 53 is tested | An explicit 1 January test |
| 5 | Retries are selective | A test counting calls for 500 and for 400 |
| 6 | The backoff timings are verified | With fake timers |
| 7 | Idempotency is tested | The same key across two attempts |
| 8 | The view is queried by role | grep -r "querySelector" test/view/ returns nothing relevant |
| 9 | There are ≥ 3 keyboard tests | Entry, return and focus trapping |
| 10 | There are exactly 3 E2E journeys | With a written justification |
| 11 | Domain coverage ≥ 95 % | npm run test:cov |
| 12 | Migration coverage = 100 % | Same |
Rubric for exercise 1 (27 points)
| Dimension | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| Documented strategy | Does not exist | A list of tests | With levels | With the 3 questions and justified thresholds |
| Rule coverage | < 8 rules | 12 rules | All 15 | All 15 with exact boundaries |
| Parameterization | Everything repeated | Some table | Tables where appropriate | The tables read as a specification |
| Doubles | None | Fake fetch |
Plus timers | Plus Date, crypto and a call counter |
| Migrations | Untested | Happy path | With real data | With idempotency, validity and performance |
| View tests | By CSS | By text | By role | By role and verifying accessibility |
| Keyboard | No | 1 test | 3 tests | Full walkthrough with no mouse |
| E2E | 0 or > 5 | 3 unjustified | 3 justified | 3 covering the 3 systemic risks |
| Reliability | Known flaky tests | Some documented | Stable suite | 5 consecutive green runs |
Threshold: 19/27, with a mandatory ≥ 2 in "Rule coverage" and in "Reliability".
Acceptance criteria for exercise 2 — CI
| # | Criterion | How it is checked |
|---|---|---|
| 1 | CI triggers on every PR and on main |
See the runs in the Actions tab |
| 2 | A lint error turns it red | Trigger it |
| 3 | A broken test turns it red | Trigger it |
| 4 | Dropping coverage turns it red | Delete a domain test |
| 5 | Exceeding the byte budget turns it red | Import a large library |
| 6 | Artifacts are uploaded on failure too | if: always() verified |
| 7 | The slow jobs depend on the fast one | needs: present |
| 8 | The cache works | The second run is clearly faster |
| 9 | main is protected |
You cannot merge while red |
| 10 | The total time is reasonable | < 5 minutes |
Acceptance criteria for exercise 3 — Debugging and reports
| # | Criterion | How it is checked |
|---|---|---|
| 1 | The three failures are documented | docs/debugging.md with the seven sections for each |
| 2 | Each one has its regression test | They are in the suite and fail if the fix is reverted |
| 3 | The discarded hypotheses are documented | Not just the correct one: the process matters |
| 4 | axe on 7 screens, 0 critical and 0 serious | The report with the table |
| 5 | The 8 manual tests are done | With results and notes, not just ticked |
| 6 | The screen reader was genuinely used | The notes section proves it |
| 7 | Limitations are stated | A report with no limitations is suspicious |
| 8 | The baseline was measured against production | npm run build + preview, not dev |
| 9 | The data was realistic | ≥ 500 tasks |
| 10 | The Nómada Tasks comparison is interpreted | Not just the table: what each difference means |
| 11 | Every row within budget, or justified | With a remediation plan if any fails |
| 12 | Five consecutive green runs | No flaky tests |
Overall rubric for milestone H5 (24 points)
| Dimension | Weight | What is assessed |
|---|---|---|
| Strategy and levels | 4 | Documented, justified, applied consistently |
| Rule coverage | 5 | All 15 with their three cases and exact boundaries |
| Data layer | 4 | Doubles, timers, migrations, queue, idempotency |
| View and keyboard | 4 | By role, full walkthrough, destroy verified |
| CI | 3 | The three jobs, budgets enforced, protected branch |
| Debugging | 2 | The three failures with method and regression tests |
| Accessibility and performance | 2 | The two reports with stated limitations |
Threshold: 17/24. Plus one additional condition that cannot be traded away: the suite has to be green five times in a row. A flaky suite is not finished, however many tests it has.
Self-assessment for milestone H5:
| Question | Yes / No |
|---|---|
| Could I say, for every rule, which file its test is in? | |
| Do my view tests still pass if I change every CSS class? | |
| Have I used a screen reader with my own application? | |
| Does my CI turn red if the bundle grows by 30 kB? | |
| Did I write the test before fixing the three failures? | |
| Have I run the suite five times in a row without a single failure? | |
| Is my baseline measured against the production build? |
Conclusion
You have turned "it works on my machine" into "it works, and there is a machine that checks it on every push".
You have a testing strategy built on three questions — what must never fail, what changes often, and how much each test costs and is worth — and on a decision rule that resolves almost every case: test at the lowest level that catches the failure you are worried about. You have the pyramid translated into your concrete files, with about 250 tests of which only three are end to end, and with an observation that is a free diagnosis: if you had more view tests than domain tests, your logic would be in the wrong place.
You know how to use coverage without being fooled by it: it says what is not tested, not whether what is tested is well tested. That is why your thresholds are per layer and justified — 95 % in the domain, 100 % in migrations, 65 % in the view — and why the metric that really governs is not the percentage but the rules × three cases table: fifteen rules, forty-five minimum tests, and no gap you can pass without verifying anything.
You know how to test the domain with parameterized tables that read as a specification, with the exact boundaries where the bugs live — 40 and 40.1, not 20 — with the type cases that slip through naive validations — '12' and NaN — checking error.field and not just the error type, and with the three families of edge case everybody forgets: numeric, collection and time, including the week 53 that breaks almost every homegrown ISO-week implementation.
You know how to test the data layer by replacing only the slow, the external and the non-deterministic: a fake fetch that fails on unexpected calls, fake timers with advanceTimersByTimeAsync — and not the synchronous version, which leaves microtasks undrained — a fixed clock, and assertions about the number of calls, which is what distinguishes a real retry test from one that would pass just as well with the retry broken. With the migrations tested against real documents, including the performance test that stops a slow migration from wrecking the LCP, and with the queue tested on what is impossible to verify by hand: that resending uses the same idempotency key.
You know how to test the view the way a person uses it: by role and accessible text, never by CSS class, with the double benefit of surviving layout changes and verifying accessibility for free. And you know how to test the keyboard walkthrough, which almost no project has, covering the three classic focus bugs — not entering, not returning, escaping — which with a native <dialog> pass almost by themselves.
You have three end-to-end journeys chosen with an explicit criterion — the ones that, if broken, would leave the product useless — covering the complete lifecycle with R13 genuinely enforced, the shared state in the URL with the report, and offline resilience with the duplicate check. And you know why not twenty: a disabled end-to-end suite is worth zero, so the right number is the maximum you can keep permanently green.
You have continuous integration complete and commented line by line: concurrency that cancels the stale, an npm cache, npm ci instead of install, artifacts uploaded on failure too — which is exactly when you need them — slow jobs depending on the fast one, and branch protection that turns the recommendation into a guarantee. With Lighthouse CI enforcing your 11-01 budget translated into assertions, three runs because of the noise, error only where you signed up for it, and the 61,440-byte line that stops a charting library getting in one day without anyone noticing.
And you know how to debug your own project with a six-step method whose third step — writing the failing test before fixing — is the one almost everybody skips and the one that gives four benefits at once. You have applied it to three real failures: the migration that corrupted data by matching names with different Unicode normalization, with its three lessons — match by identifier, do not fail silently, and the backup saved the investigation; the race condition where an in-flight refresh overwrote an optimistic update, where reproducing required controlling the timing to turn "one time in three" into "always"; and the memory leak of twenty-one live views retained by subscriptions with no unsubscribe, which cannot be seen but can be measured, and which is prevented forever by a one-millisecond unit test.
You know that automated accessibility tools detect around a third of the problems, and you have both halves: axe over the seven screens — including the empty and error states, which nobody tests and where the most violations are — and the eight manual tests taking fifty minutes, with the screen reader and closed eyes as the one that teaches most. With a report that states its limitations, which is worth more than a Lighthouse 100.
You have your baseline measured against the production build, with realistic data, compared to Nómada Tasks row by row, and you know how to read it: being somewhat heavier with more functionality is correct, being under budget is headroom and not permission, and every out-of-range row points to its Module 9 lesson.
And you know how to treat flaky tests as what they are: real failures that destroy trust in the suite. With the eight causes and their solutions, the protocol of never re-running, and the hygiene rule of five consecutive runs before closing a milestone.
Milestone H5 is closed. Your project works, it is tested, it is measured and it watches itself. It is missing the one thing that turns a repository into a product: that other people can use it. Building for production without leaking a single secret, choosing where to host it, configuring caching, security and HTTPS, deploying continuously with the ability to roll back, and watching over it once it is no longer on your machine, is Deploying the Project.
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
