The 124 tests from the previous lesson run in jsdom, which is an imitation of the browser: it paints nothing, every measurement is zero, 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, an import without an extension that the browser rejects, a stylesheet that hides the completed-tasks column, or a worker serving an old version of the application: none of that shows up in the suite, and all of it leaves Marta staring at a broken screen. This lesson closes the module by opening the application for real, in a real browser, and using it the way she would. You will learn the architecture of Cypress and why it differs from every other runner, the anatomy of a test with stable selectors, the chaining and automatic-waiting model that makes cy.wait(3000) an antipattern, network interception with cy.intercept, state seeding with cy.session, custom commands and fixtures, running in continuous integration, an honest comparison with Playwright, and —most important of all— what you should not test in E2E.
Contents
- What an end-to-end test validates
- The cost: slow, fragile and expensive to maintain
- Cypress: installing and project structure
- The architecture: inside the browser
- The interactive runner versus
runmode - Anatomy of a test
- Stable selectors:
data-cyand why not the CSS class - Chaining and automatic waiting
- Why
cy.wait(3000)is an antipattern - Intercepting the network with
cy.intercept - Seeding the initial state
- Custom commands and fixtures
- Journey 1: creating a task and seeing it on the board
- Journey 2: completing it and checking the summary
- Journey 3: filtering by assignee with a shareable URL
- Debugging a failure: screenshots, videos and time travel
- Running in continuous integration
- Automated accessibility with axe
- Cypress versus Playwright
- What NOT to test in E2E
- Common Mistakes and Tips
- Exercises
- Conclusion
- What an end-to-end test validates
An E2E test boots the real application in a real browser and uses it from the outside, exactly like a person would. Nothing about your code matters: the only thing it touches is the interface.
And that is why it validates things none of the previous 124 tests can touch:
| Only an E2E test sees this | Why |
|---|---|
That index.html loads and links the modules correctly |
jsdom does not load the real HTML nor resolve the browser's import statements |
| That the CSS covers or hides nothing | jsdom does not paint: every measurement is zero |
| That the button is actually clickable | An element covered by another still receives the click in jsdom but not in the browser |
| That the ES modules load with no errors | An import './task' with no extension only fails in the browser (08-02) |
| That the service worker serves the right thing | jsdom does not implement it |
| That the whole application boots | Nobody has ever run the complete js/app.js |
| That the business journey works from start to finish | It is the only test that answers "is this any use?" |
The last row is the key one. All the previous tests answer technical questions: does this function calculate correctly? do these two pieces understand each other? An E2E test answers the only question that matters to Marta: can I create a task, mark it done and see the right summary?
flowchart LR
subgraph J["Tests in jsdom (08-05)"]
A["Your JS code"] --> B["simulated DOM"]
end
subgraph C["E2E test"]
D["Real browser"] --> E["real index.html"]
E --> F["css/styles.css"]
E --> G["js/app.js + 14 modules"]
G --> H["Service worker"]
G --> I["Network"]
end
style C fill:#dbeafe,stroke:#1d4ed8
- The cost: slow, fragile and expensive to maintain
If they are so convincing, why not do everything this way? Because the price is high, and it is worth seeing it in numbers:
| Dimension | Unit | Integration | E2E |
|---|---|---|---|
| Time per test | ~2 ms | ~30 ms | 2–10 s |
| Complete suite | 0.9 s | 3.7 s | 3–15 min |
| Precision on failure | The exact line | The module | "something in the journey" |
| Fragility | Very low | Medium | High |
| Maintenance cost | Low | Medium | High |
| Confidence provided | Low | Medium | Maximum |
The three specific problems:
They are slow. Every test boots a browser, loads the application, waits for the network and performs real clicks. A hundred E2E tests is fifteen minutes, and a fifteen-minute suite stops being run on every commit.
They are fragile. Changing a button's text, moving an element or adding an animation can break them. And they suffer the flakiness of 08-05 multiplied: real network, real timers, animations, races.
When they fail, they do not say where. "The create-task journey fails" could be the form, the model, the API, the render or the CSS. You have to investigate with the method from 08-01.
Hence the conclusion that governs the whole lesson: few E2E tests, and very well chosen. Three or four journeys that represent the real value of the product. If you find yourself writing the twentieth, you are almost certainly covering in E2E something that belongs at a lower level.
- Cypress: installing and project structure
nomada-tasks/
cypress.config.js
cypress/
e2e/ ← the tests
create-task.cy.js
complete-task.cy.js
filter-by-assignee.cy.js
fixtures/ ← test data
backlog.json
support/
commands.js ← custom commands
e2e.js ← runs before every file
screenshots/ ← automatic screenshots of failures (do not commit)
videos/ ← recordings (do not commit)// cypress.config.js
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
// The base for every cy.visit(): lets you write cy.visit('/')
baseUrl: 'http://localhost:5173',
// Window size: fixing it makes the tests reproducible
viewportWidth: 1280,
viewportHeight: 800,
// Timeouts. 4 s by default is usually enough; raise them only with a reason
defaultCommandTimeout: 5000,
requestTimeout: 8000,
video: true,
screenshotOnRunFailure: true,
// Cypress retries a failed file in CI: it softens flakiness
// without hiding it entirely, because the report marks the test as unstable
retries: { runMode: 2, openMode: 0 }
}
});And in .gitignore:
- The architecture: inside the browser
Here is the most important difference between Cypress and every other tool, and it explains nearly all its virtues and its limits.
Most E2E runners (Selenium, Playwright, Puppeteer) work outside the browser: they send commands over an automation protocol and wait for the answer. Cypress, by contrast, runs inside the same event loop as your application: it loads your page in an iframe and runs alongside it.
flowchart TD
subgraph N["Browser"]
subgraph P["Tab process"]
C["Cypress<br/>your test"]
A["Nómada Tasks<br/>in an iframe"]
C <-->|direct access<br/>same event loop| A
end
end
S["Node process<br/>server, files, tasks"] <-->|websocket| C
style C fill:#bbf7d0,stroke:#15803d
Consequences in its favor:
- Genuine automatic waiting. Because Cypress sees the DOM directly, it knows when an element appears without polling from outside.
- Access to the application's code. You can spy on
window, call functions, interceptfetchfrom the inside. - Exceptional debugging. Console messages, errors and the DOM state at every step are all within reach.
- DOM snapshots at every command: the "time travel" of section 16.
Consequences against, worth knowing before you choose:
- One browser per test. You cannot test two tabs or two sessions at once.
- Origin restrictions. Navigating between different domains within one test requires
cy.origin(). - No new tabs. A link with
target="_blank"has to be handled another way. - The test runs in the browser, so Node code (reading a file, spawning a process) needs
cy.task().
For Nómada Tasks —a single-page application with a single origin— none of those limitations gets in the way.
- The interactive runner versus
run mode
run modeCypress has two modes, and they are used at different moments:
npx cypress open # interactive: visible browser, reloads on save
npx cypress run # headless: for CI, with video and screenshotsopen (interactive) |
run (console) |
|
|---|---|---|
| Browser | Visible, you can inspect it | Headless |
| On saving the file | Reloads and reruns | — |
| Time travel | Yes | No (but it records video) |
| Speed | Lower | Higher |
| Use | Writing and debugging | Continuous integration |
The project scripts:
{
"scripts": {
"dev": "vite",
"e2e:open": "cypress open",
"e2e": "cypress run",
"e2e:ci": "start-server-and-test dev http://localhost:5173 e2e"
}
}start-server-and-test boots the development server, waits until it answers at the given URL, runs the tests and kills the server afterwards. Without it, continuous integration would have to start the server in the background and guess when it is ready, which is exactly the kind of time-based waiting that causes flakiness.
- Anatomy of a test
// cypress/e2e/first-look.cy.js
describe('The Nómada Tasks board', () => {
beforeEach(() => {
cy.visit('/'); // loads the real application
});
it('shows the six backlog tasks on start-up', () => {
cy.get('[data-cy="card"]').should('have.length', 6);
cy.contains('Redesign the multipurpose room').should('be.visible');
cy.get('[data-cy="summary"]').should('contain', '45');
});
});The essential commands:
| Command | What it does |
|---|---|
cy.visit(url) |
Loads a page |
cy.get(selector) |
Selects elements, waiting for them to exist |
cy.contains(text) |
Selects by visible text |
cy.find(selector) |
Searches inside the current element |
.click() |
Presses (checking first that it is genuinely clickable) |
.type(text) |
Types, key by key |
.select(value) |
Chooses in a <select> |
.clear() |
Empties a field |
.should(assertion) |
Checks, retrying until the timeout |
.and(assertion) |
Chains another assertion on the same thing |
cy.url() |
The current URL, so you can check it |
cy.intercept(...) |
Intercepts network requests |
And describe, it and beforeEach are the same ones from Jest: Cypress uses Mocha underneath, so the structure feels familiar.
- Stable selectors:
data-cy and why not the CSS class
data-cy and why not the CSS classIn 08-05 the answer was to query by accessible role. In E2E, the Cypress recommendation is different and worth understanding: attributes dedicated to testing.
<!-- In index.html and in the templates -->
<li class="task" data-cy="card" data-id="3" data-status="pending">
<h3 class="task__title" data-cy="title">Update the bookings website</h3>
<button data-action="advance" data-cy="advance">Start</button>
</li>
<form id="task-form" data-cy="task-form">
<input id="title" name="title" data-cy="title-field">
<button type="submit" data-cy="create">Create task</button>
</form>// ❌ Fragile: it breaks when you tweak the CSS or the structure
cy.get('.column:nth-child(1) > ul > li:first-child .task__title');
cy.get('#board > section > ul > li').first();
// ✅ Stable: the attribute exists ONLY for the tests and nobody touches it while styling
cy.get('[data-cy="card"]').first().find('[data-cy="title"]');| Selector | Stability | Problem |
|---|---|---|
.task__title |
❌ Low | It changes when the CSS is refactored |
#task-form |
⚠️ Medium | id values change and are sometimes reused |
nth-child, > |
❌ Very low | Any new element breaks it |
| Visible text | ⚠️ Medium | It changes when the wording is fixed or translated |
[data-cy] |
✅ High | None: it exists only for this |
And what about the accessible role, which was the answer in 08-05? It is still excellent, and it deserves to be used for what is the user experience: cy.contains('button', 'Create task') documents that the button says that. The combination that works best is data-cy to locate and assertions about text and state to verify:
cy.get('[data-cy="create"]') // locate: stable
.should('be.visible')
.and('contain', 'Create task'); // verify: what the user seesAnd a hygiene detail: add data-cy only where a test needs it. Peppering the HTML with attributes nobody uses is noise.
- Chaining and automatic waiting
This is the mental model to internalize, because it is different from everything before it.
Cypress commands do not run when you write them: they get queued. The whole body of the it is walked first, queuing commands, and then they run one by one, asynchronously.
it('demonstrates the command queue', () => {
cy.visit('/'); // queued 1
cy.get('[data-cy="card"]'); // queued 2
console.log('this gets printed FIRST'); // ← it runs right away, before 1 and 2
});That is where the most common beginner error comes from:
// ❌ Does NOT work: cy.get() does not return the element, it returns a command chain
const cards = cy.get('[data-cy="card"]');
expect(cards.length).to.equal(6); // undefined
// ✅ The value arrives in a callback
cy.get('[data-cy="card"]').then(($cards) => {
expect($cards).to.have.length(6);
});
// ✅ Better still: the chained assertion, which also RETRIES
cy.get('[data-cy="card"]').should('have.length', 6);Automatic waiting is the second piece. Every command retries until its condition holds or the timeout expires:
cy.get('[data-cy="card"]')retries until the element exists in the DOM..should('be.visible')retries until it is visible (notdisplay:none, notvisibility:hidden, with a size)..click()checks first that the element exists, is visible, is not covered by another one, is not disabled and is not moving (because of an animation).
That "not covered by another one" check is one of the gems of the E2E level: it is exactly the bug jsdom cannot detect. If a transparent modal covers the button, Cypress fails with an explicit message pointing at the offending element.
CypressError: cy.click() failed because this element is being covered by another element: <div class="overlay">...</div>
- Why
cy.wait(3000) is an antipattern
cy.wait(3000) is an antipatternWith the model above clear, the conclusion is inevitable:
// ❌ The antipattern
cy.get('[data-cy="create"]').click();
cy.wait(3000); // "in case it is slow"
cy.get('[data-cy="card"]').should('have.length', 7);The four problems:
- If it takes longer than 3 s, it fails anyway. You have solved nothing; you have set an arbitrary limit different from the one you already had.
- If it takes 100 ms, you have wasted 2.9 s. Multiplied by twenty waits, that is almost a minute per run.
- It hides a real problem. Why is it slow? It could be an unnecessary request or a race, and the fixed wait covers it up.
- It is unstable by nature. The CI server is slower than your laptop: the number that works locally does not work there.
The alternative is always the same idea from 08-05: wait for a condition, not for a duration.
// ✅ Wait for the expected state: it retries for up to 5 s and moves on as soon as it holds
cy.get('[data-cy="create"]').click();
cy.get('[data-cy="card"]').should('have.length', 7);
// ✅ Wait for a specific REQUEST, not for a duration
cy.intercept('POST', '**/tasks').as('createTask');
cy.get('[data-cy="create"]').click();
cy.wait('@createTask'); // ← this cy.wait IS correct
cy.get('[data-cy="card"]').should('have.length', 7);
// ✅ Wait for something to disappear
cy.get('[data-cy="loading"]').should('not.exist');Notice the distinction: cy.wait('@alias') is correct and cy.wait(3000) is not. The first waits for a specific event —that request has finished— and carries on the moment it happens. The second counts the clock blindly.
The only legitimate exception for cy.wait(ms) is deliberately testing that something does not happen during an interval ("the search box does not fire the request before 300 ms"), and even then there is usually a better way of expressing it.
- Intercepting the network with
cy.intercept
cy.interceptcy.intercept intercepts the browser's requests. It serves three different purposes, and they are worth telling apart:
A · Observing, without modifying anything:
cy.intercept('GET', '**/tasks').as('list');
cy.visit('/');
cy.wait('@list').its('response.statusCode').should('eq', 200);B · Fixing the response (stubbing), so the test does not depend on the server:
C · Simulating a failure, which against a real server is nearly impossible:
cy.intercept('GET', '**/tasks', { statusCode: 500, body: { message: 'Internal error' } });
cy.intercept('GET', '**/tasks', { forceNetworkError: true }); // network outage
cy.intercept('GET', '**/tasks', { statusCode: 200, body: [], delay: 2000 }); // slownessThe underlying decision: real server or fixed responses?
| Real server | cy.intercept with fixed data |
|
|---|---|---|
| Realism | Maximum: it tests the complete contract | Medium: the server could change without you noticing |
| Speed | Low | High |
| Determinism | Low: the data changes | High: always the canonical backlog |
| Provoking errors | Very hard | Trivial |
| Depends on another team | Yes | No |
The recommended policy, which is the one we will follow: the main happy path against a real server (even if it is a local json-server with the canonical backlog, the one from 07-02), and everything else —errors, edge cases, empty states— with cy.intercept. That way the real contract is checked at least once and the rest of the tests are fast and deterministic.
And here a decision from 08-05 pays off: the MSW handlers you wrote there describe the same API. Sharing that definition between integration and E2E stops the two suites mocking different servers with neither resembling the real one.
- Seeding the initial state
Every test must start from a known point: the same principle as the beforeEach from 08-03, now with a complete browser in the way.
Cypress clears cookies and storage between tests, but the initial state is up to you. Three techniques, from worst to best:
1 · Through the interface (slow, fragile). Creating the six tasks by pressing buttons before each test. Never do this: it multiplies the time and, if the form breaks, every test fails for the same reason.
2 · Seeding localStorage directly. Fast and direct, using the format from 07-01:
// cypress/support/commands.js
Cypress.Commands.add('seedBoard', (tasks) => {
cy.fixture('backlog.json').then((backlog) => {
window.localStorage.setItem('nomada:board:v1', JSON.stringify({
name: 'Taller Nómada',
version: 1,
tasks: tasks ?? backlog
}));
});
});
// Use: seed BEFORE visiting, so the app boots with that data
beforeEach(() => {
cy.clearLocalStorage();
cy.seedBoard();
cy.visit('/');
});3 · cy.session for what is expensive to repeat. It caches the state (cookies, localStorage, sessionStorage) after the first time and restores it afterwards, without running the steps again:
Cypress.Commands.add('martaSession', () => {
cy.session('marta', () => {
cy.visit('/');
cy.get('[data-cy="user"]').select('Marta');
cy.get('[data-cy="sign-in"]').click();
cy.url().should('include', '/board');
}, {
// A check that the restored session is still valid
validate() {
cy.window().its('localStorage').invoke('getItem', 'nomada:user').should('eq', 'Marta');
},
cacheAcrossSpecs: true
});
});In an application with authentication, cy.session is the difference between a twelve-minute suite and a three-minute one.
And a note about the service worker from 07-05, which in E2E can be very confusing: if it serves a cached version of the application, your changes are invisible and the tests fail incomprehensibly. It is worth switching it off during the tests:
// cypress/support/e2e.js
beforeEach(() => {
// Stop the service worker from serving an old version during the tests
if (window.navigator?.serviceWorker) {
cy.window().then((win) =>
win.navigator.serviceWorker.getRegistrations()
.then((registrations) => registrations.forEach((r) => r.unregister()))
);
}
});Plus a separate, dedicated test that does check the offline behavior. Mixing the two produces failures nobody understands.
- Custom commands and fixtures
Custom commands extend cy with domain actions. They turn a test into something that reads like a description of the behavior:
// cypress/support/commands.js
/** Creates a task by filling in the form, the way a person would. */
Cypress.Commands.add('createTask', ({
title, assignee = 'Marta', priority = 'medium',
hours = 2, date = '2026-10-20', tags = ''
} = {}) => {
cy.get('[data-cy="title-field"]').clear().type(title);
cy.get('[data-cy="assignee-field"]').select(assignee);
cy.get('[data-cy="priority-field"]').select(priority);
cy.get('[data-cy="hours-field"]').clear().type(String(hours));
cy.get('[data-cy="date-field"]').clear().type(date);
if (tags) cy.get('[data-cy="tags-field"]').clear().type(tags);
cy.get('[data-cy="create"]').click();
});
/** Selects the card whose title contains that text. */
Cypress.Commands.add('card', (title) =>
cy.contains('[data-cy="card"]', title));
/** A domain assertion: the summary shows these figures. */
Cypress.Commands.add('summaryShouldSay', ({ open, hours }) => {
cy.get('[data-cy="summary"]')
.should('contain', `${open} open`)
.and('contain', `${hours} h`);
});With them, a test reads like this:
cy.createTask({ title: 'Check the fire extinguishers', assignee: 'Marta', hours: 2 });
cy.card('Check the fire extinguishers').should('be.visible');
cy.summaryShouldSay({ open: 6, hours: 47 });Fixtures are JSON files with test data, and here go the canonical six:
// cypress/fixtures/backlog.json
[
{ "id": 1, "title": "Redesign the multipurpose room", "assignee": "Iván",
"priority": "high", "status": "in-progress", "tags": ["space", "design"],
"estimatedHours": 12, "dueDate": "2026-09-30", "reviewer": "Marta" },
{ "id": 2, "title": "Signage for the screen-printing workshop", "assignee": "Marta",
"priority": "medium", "status": "pending", "tags": ["screen-printing", "communication"],
"estimatedHours": 6, "dueDate": "2026-10-15", "reviewer": null },
{ "id": 3, "title": "Update the bookings website", "assignee": "Lucía",
"priority": "high", "status": "pending", "tags": ["web", "bookings"],
"estimatedHours": 14, "dueDate": "2026-10-02", "reviewer": "Iván" },
{ "id": 4, "title": "Screen-printing ink inventory", "assignee": "Marta",
"priority": "low", "status": "done", "tags": ["screen-printing", "storeroom"],
"estimatedHours": 3, "dueDate": "2026-09-12", "reviewer": null },
{ "id": 5, "title": "Bookbinding guide for residents", "assignee": "Iván",
"priority": "medium", "status": "in-progress", "tags": ["bookbinding", "documentation"],
"estimatedHours": 8, "dueDate": "2026-11-05", "reviewer": "Lucía" },
{ "id": 6, "title": "Carpentry workshop quote", "assignee": "Iván",
"priority": "high", "status": "pending", "tags": ["carpentry", "purchasing"],
"estimatedHours": 5, "dueDate": "2026-09-05", "reviewer": "Marta" }
]
- Journey 1: creating a task and seeing it on the board
The first of the three critical journeys. It checks the complete path: form → validation → model → API → render → persistence.
// cypress/e2e/create-task.cy.js
describe('Journey 1 · Creating a task', () => {
beforeEach(() => {
cy.clock(new Date('2026-09-20T09:00:00Z'), ['Date']); // the canonical date, frozen
cy.intercept('GET', '**/tasks', { fixture: 'backlog.json' }).as('list');
cy.visit('/');
cy.wait('@list');
cy.get('[data-cy="card"]').should('have.length', 6); // a known starting point
});
it('Marta creates a task and sees it appear in the not-started column', () => {
cy.intercept('POST', '**/tasks', {
statusCode: 201,
body: { id: 7, title: 'Check the fire extinguishers', assignee: 'Marta', priority: 'medium',
status: 'pending', tags: ['safety'], estimatedHours: 2,
dueDate: '2026-10-20', reviewer: null }
}).as('create');
cy.createTask({ title: 'Check the fire extinguishers', assignee: 'Marta',
hours: 2, date: '2026-10-20', tags: 'safety' });
// 1 · The request went out with the right data
cy.wait('@create').its('request.body').should('deep.include', {
title: 'Check the fire extinguishers',
assignee: 'Marta',
estimatedHours: 2
});
// 2 · The card appears, in the right column and with its content
cy.card('Check the fire extinguishers')
.should('be.visible')
.and('have.attr', 'data-status', 'pending')
.within(() => {
cy.contains('Marta').should('be.visible');
cy.contains('2 h').should('be.visible');
cy.contains('safety').should('be.visible');
});
cy.get('[data-cy="column-pending"] [data-cy="card"]').should('have.length', 4);
// 3 · The summary has been updated: 47 open hours (45 + 2)
cy.get('[data-cy="summary"]').should('contain', '47');
// 4 · The form has been cleared and focus has returned to the first field
cy.get('[data-cy="title-field"]').should('have.value', '').and('be.focused');
});
it('survives a reload: the task is still there', () => {
cy.intercept('POST', '**/tasks', { statusCode: 201, body: { id: 7, title: 'Check the fire extinguishers',
assignee: 'Marta', priority: 'medium', status: 'pending', tags: [],
estimatedHours: 2, dueDate: '2026-10-20', reviewer: null } });
cy.createTask({ title: 'Check the fire extinguishers' });
cy.card('Check the fire extinguishers').should('be.visible');
cy.reload(); // ← the test that did not exist in jsdom
cy.card('Check the fire extinguishers').should('be.visible');
});
it('an empty title shows the accessible error and creates nothing', () => {
cy.get('[data-cy="hours-field"]').type('2');
cy.get('[data-cy="date-field"]').type('2026-10-20');
cy.get('[data-cy="create"]').click();
cy.get('[role="alert"]').should('be.visible').and('contain', 'title');
cy.get('[data-cy="title-field"]')
.should('have.attr', 'aria-invalid', 'true')
.and('be.focused');
cy.get('[data-cy="card"]').should('have.length', 6); // nothing created
});
it('a 500 from the server shows the error and the retry button (07-03)', () => {
cy.intercept('POST', '**/tasks', { statusCode: 500, body: { message: 'Internal error' } })
.as('createFailed');
cy.createTask({ title: 'Check the fire extinguishers' });
cy.wait('@createFailed');
cy.get('[role="alert"]').should('be.visible').and('contain', 'server');
cy.get('[data-cy="retry"]').should('be.visible');
// And on retrying with the server back up, it works
cy.intercept('POST', '**/tasks', { statusCode: 201, body: { id: 7, title: 'Check the fire extinguishers',
assignee: 'Marta', priority: 'medium', status: 'pending', tags: [],
estimatedHours: 2, dueDate: '2026-10-20', reviewer: null } }).as('createOk');
cy.get('[data-cy="retry"]').click();
cy.wait('@createOk');
cy.card('Check the fire extinguishers').should('be.visible');
cy.get('[role="alert"]').should('not.exist');
});
});Four things in this suite are only possible in E2E: the reload with cy.reload(), which checks real persistence from end to end; the should('be.focused'), which verifies focus management in a real browser; the check that the request went out with the right body; and the complete error → retry → success cycle with the server changing behavior mid-test.
cy.clock(date, ['Date']) freezes only Date and leaves the real timers running: that way task 6 still shows up as overdue whatever day the test runs, without breaking the animations or the debounce.
- Journey 2: completing it and checking the summary
The second journey covers the R6 transitions and the recalculation of the figures, with the state → render cycle from 06-06.
// cypress/e2e/complete-task.cy.js
describe('Journey 2 · Completing a task', () => {
beforeEach(() => {
cy.clock(new Date('2026-09-20T09:00:00Z'), ['Date']);
cy.intercept('GET', '**/tasks', { fixture: 'backlog.json' }).as('list');
cy.intercept('PATCH', '**/tasks/*', (request) => {
request.reply({ statusCode: 200, body: { ...request.body, id: Number(request.url.split('/').pop()) } });
}).as('update');
cy.visit('/');
cy.wait('@list');
});
it('Marta takes a task from not started to done and the summary adds up at each step', () => {
// Initial state: 5 open, 45 h
cy.summaryShouldSay({ open: 5, hours: 45 });
// ── Step 1 · pending → in-progress ─────────────────────────────────
cy.card('Signage for the screen-printing workshop').within(() => {
cy.get('[data-cy="advance"]').should('contain', 'Start').click();
});
cy.wait('@update').its('request.body').should('deep.include', { status: 'in-progress' });
cy.card('Signage for the screen-printing workshop')
.should('have.attr', 'data-status', 'in-progress')
.find('[data-cy="advance"]').should('contain', 'Mark done');
cy.summaryShouldSay({ open: 5, hours: 45 }); // still open: nothing changes
// ── Step 2 · in-progress → done ────────────────────────────────────
cy.card('Signage for the screen-printing workshop')
.find('[data-cy="advance"]').click();
cy.wait('@update').its('request.body').should('deep.include', { status: 'done' });
cy.summaryShouldSay({ open: 4, hours: 39 }); // 45 − 6
// The card has moved to the completed column
cy.get('[data-cy="column-done"]')
.should('contain', 'Signage for the screen-printing workshop');
cy.get('[data-cy="column-done"] [data-cy="card"]').should('have.length', 2);
});
it('a done task cannot be reopened: the button is disabled (R6)', () => {
cy.card('Screen-printing ink inventory').within(() => {
cy.get('[data-cy="advance"]')
.should('be.disabled')
.and('contain', 'Completed');
});
cy.summaryShouldSay({ open: 5, hours: 45 }); // nothing has changed
});
it('the per-assignee split is recalculated on completion', () => {
cy.get('[data-cy="workload-Iván"]').should('contain', '25');
// Task 1 is Iván's: in-progress, 12 h
cy.card('Redesign the multipurpose room').find('[data-cy="advance"]').click();
cy.wait('@update');
cy.get('[data-cy="workload-Iván"]').should('contain', '13'); // 25 − 12
cy.summaryShouldSay({ open: 4, hours: 33 });
});
it('if the server rejects the change, the interface reverts (optimistic UI from 07-03)', () => {
cy.intercept('PATCH', '**/tasks/2', { statusCode: 500, body: { message: 'Error' } })
.as('failure');
cy.card('Signage for the screen-printing workshop').find('[data-cy="advance"]').click();
cy.wait('@failure');
// The card goes back to its previous state and a warning is shown
cy.card('Signage for the screen-printing workshop')
.should('have.attr', 'data-status', 'pending');
cy.get('[role="alert"]').should('be.visible');
cy.summaryShouldSay({ open: 5, hours: 45 });
});
});The last test is especially valuable: checking an optimistic revert requires the model, the view and the network layer to collaborate correctly in the face of a failure. No unit or integration test covers that complete journey with a real browser in the middle.
- Journey 3: filtering by assignee with a shareable URL
The third one covers the router from 07-06 and a concrete product property: that the URL can be shared.
// cypress/e2e/filter-by-assignee.cy.js
describe('Journey 3 · Filtering by assignee with a shareable URL', () => {
beforeEach(() => {
cy.clock(new Date('2026-09-20T09:00:00Z'), ['Date']);
cy.intercept('GET', '**/tasks*', { fixture: 'backlog.json' }).as('list');
});
it('Iván filters by his name and the URL reflects the filter', () => {
cy.visit('/');
cy.wait('@list');
cy.get('[data-cy="card"]').should('have.length', 6);
cy.get('[data-cy="filter-Iván"]').click();
cy.get('[data-cy="card"]').should('have.length', 3);
cy.get('[data-cy="card"]').each(($t) => {
cy.wrap($t).should('contain', 'Iván');
});
cy.url().should('include', 'assignee=Iv%C3%A1n');
cy.get('[data-cy="filter-Iván"]').should('have.attr', 'aria-pressed', 'true');
// The filter is PRESENTATION: the summary is still for the whole board
cy.summaryShouldSay({ open: 5, hours: 45 });
});
it('the filtered URL, opened directly, already shows the filter applied', () => {
cy.visit('/?assignee=Lucía');
cy.wait('@list');
cy.get('[data-cy="card"]').should('have.length', 1);
cy.card('Update the bookings website').should('be.visible');
cy.get('[data-cy="filter-Lucía"]').should('have.attr', 'aria-pressed', 'true');
});
it('the browser back button restores the previous filter', () => {
cy.visit('/');
cy.wait('@list');
cy.get('[data-cy="filter-Iván"]').click();
cy.get('[data-cy="card"]').should('have.length', 3);
cy.get('[data-cy="filter-Marta"]').click();
cy.get('[data-cy="card"]').should('have.length', 2);
cy.go('back'); // ← impossible in jsdom
cy.url().should('include', 'assignee=Iv%C3%A1n');
cy.get('[data-cy="card"]').should('have.length', 3);
cy.go('back');
cy.url().should('not.include', 'assignee');
cy.get('[data-cy="card"]').should('have.length', 6);
});
it('the search box filters without cluttering the history', () => {
cy.visit('/');
cy.wait('@list');
cy.get('[data-cy="search"]').type('carpent');
cy.get('[data-cy="card"]').should('have.length', 1); // it waits out the debounce by itself
cy.url().should('include', 'q=carpent');
cy.go('back'); // replaceState: back to the start
cy.url().should('not.include', 'q=');
});
it('the copied link leads to the same view', () => {
cy.visit('/?assignee=Iv%C3%A1n&sort=date');
cy.wait('@list');
cy.get('[data-cy="card"]').should('have.length', 3);
cy.get('[data-cy="card"]').first().should('contain', 'Carpentry workshop quote');
});
});Three things here are exclusive to E2E: cy.go('back') with the browser's real history; the check that ?assignee=Iván opened cold works, which is what makes the link genuinely shareable; and waiting out the debounce with no cy.wait(300) at all, because the assertion retries until the list changes.
- Debugging a failure: screenshots, videos and time travel
When an E2E test fails in CI, you have four tools:
1 · The automatic screenshot. Cypress saves a PNG in cypress/screenshots/ at the exact moment of the failure, named after the file and the test.
2 · The video. With video: true, the complete run is recorded. It is the fastest way to see what happened before the failure.
3 · Time travel (interactive mode). The left-hand panel lists every command that ran; hovering over one makes the browser show the DOM exactly as it was at that instant. It is literally rewinding the test, and it has no equivalent in any other tool.
4 · The debugging commands, which connect with everything from 08-01:
cy.get('[data-cy="card"]').debug(); // pauses and prints the subject in the console
cy.pause(); // stops the test; you resume it by hand
cy.get('[data-cy="summary"]').then(($s) => {
debugger; // the debugger from 08-01, with the element to hand
});
cy.window().its('board').invoke('summary', '2026-09-20').then(console.table);That last line is a lovely bridge to the lesson that opened the module: cy.window() gives you access to the application's real window, so you can inspect the live model from the test and dump it with console.table.
And a very useful feature in CI: retries in the configuration. With runMode: 2, a failing test is retried twice before being declared failed, and the report marks it as unstable. It is a reasonable balance: it absorbs the unavoidable flakiness of a real environment (a slow server, a delayed resource) without hiding it, because the unstable mark is still there for you to investigate. What you must never do is raise the number until everything passes.
- Running in continuous integration
We extend the workflow from 08-02, now with all three test layers:
# .github/workflows/quality.yml
name: Quality
on:
push:
branches: [master]
pull_request:
jobs:
# ── Job 1 · fast: static analysis and Jest tests ───────────────────────
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
- run: npm ci
- run: npm run lint
- run: npm run format:check
- run: npm test -- --coverage --ci
# ── Job 2 · slow: end to end ───────────────────────────────────────────
e2e:
runs-on: ubuntu-latest
needs: verify # ← only if the cheap stuff passed: it saves minutes
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
# The official action installs Cypress, caches its binary, starts the
# server, waits until it answers and runs the tests
- name: Run Cypress
uses: cypress-io/github-action@v6
with:
start: npm run dev
wait-on: 'http://localhost:5173'
wait-on-timeout: 120
browser: chrome
# If anything fails, we upload the evidence
- name: Save screenshots
if: failure()
uses: actions/upload-artifact@v4
with:
name: cypress-screenshots
path: cypress/screenshots
retention-days: 7
- name: Save videos
if: always()
uses: actions/upload-artifact@v4
with:
name: cypress-videos
path: cypress/videos
retention-days: 3Three decisions in this workflow:
needs: verifychains the jobs: if the lint fails, no minutes are spent on E2E. The cheap stuff first.wait-onwaits until the server genuinely answers, instead of sleeping for a number of seconds.if: failure()uploads the screenshots only when there is a failure, which is when they are useful; the videos are always uploaded but expire in three days.
And a scheduling recommendation, because CI time is money and patience:
| When | What runs |
|---|---|
| On save (editor) | Prettier + ESLint |
| Pre-commit | ESLint + Prettier over the staged files |
| Every push / PR | Lint + the full Jest suite + the three E2E journeys |
| Every night | Extended E2E, several browsers, accessibility |
| Before deploying | Everything, against the staging environment |
- Automated accessibility with axe
axe is an accessibility audit engine that integrates with Cypress and automatically checks dozens of WCAG rules:
it('the board has no serious accessibility problems', () => {
cy.visit('/');
cy.injectAxe();
cy.checkA11y(null, {
includedImpacts: ['critical', 'serious'] // start with the serious ones, not with everything
});
});
it('the form with errors is still accessible', () => {
cy.visit('/');
cy.injectAxe();
cy.get('[data-cy="create"]').click(); // triggers the validation errors
cy.checkA11y('[data-cy="task-form"]'); // audit only that region
});What axe detects: insufficient contrast, images with no alternative text, fields with no label, misused ARIA attributes, out-of-order headings, interactive elements unreachable with the keyboard.
And what is worth being clear about: automated tools detect around a third of real accessibility problems. They do not check whether the reading order makes sense, whether an alternative text is useful, or whether the application can be used with a screen reader. They are a minimum floor, not a certificate. That said, that third is cheap to get and covers the most frequent failures, so it deserves a place in the suite.
Notice too a pleasing consistency: querying by accessible role in 08-05 and auditing with axe here push in the same direction. HTML that lets itself be queried by role usually passes axe effortlessly.
- Cypress versus Playwright
Playwright is the other serious tool in the ecosystem. An honest comparison:
| Cypress | Playwright | |
|---|---|---|
| Architecture | Inside the browser | Outside, over an automation protocol |
| Browsers | Chrome, Edge, Firefox, Electron; WebKit experimental | Chromium, Firefox and WebKit out of the box |
| Multiple tabs / windows | No | Yes |
| Multiple origins in one test | With cy.origin() |
Native |
| Parallelism | Paid in their service, or configured by hand | Free and built in |
| API | Chained, with an implicit queue | Standard async/await |
| Automatic waiting | Yes | Yes |
| Debugging | Exceptional: time travel | Very good: trace viewer, UI mode |
| Learning curve | Gentler | Somewhat steeper |
| Mobile emulation | Limited | Complete, with predefined devices |
| Recording a test | With an extension | Built-in codegen |
The API difference is visible at a glance:
// Cypress: a command queue, no await
cy.visit('/');
cy.get('[data-cy="create"]').click();
cy.get('[data-cy="card"]').should('have.length', 7);
// Playwright: standard async/await, like the rest of your JavaScript
await page.goto('/');
await page.getByTestId('create').click();
await expect(page.getByTestId('card')).toHaveCount(7);When to choose each one:
- Cypress: a single-page, single-origin application, a team starting out with E2E, debugging experience as a priority. That is the Nómada Tasks case.
- Playwright: you need to test on WebKit (Safari) for real, multiple simultaneous tabs or sessions, serious mobile emulation, or free parallelism in CI.
And the important part for you: the concepts are the same and they transfer entirely. Stable selectors, waiting for a condition and never for a duration, intercepting the network, seeding state, few well-chosen tests. The syntax changes; nothing you have learned in this lesson does.
- What NOT to test in E2E
Just as important as knowing how to write them is knowing when not to. Every extra E2E test is CI time, maintenance and a potential source of flakiness.
| Do not test in E2E | Test it in… | Why |
|---|---|---|
| The nine R6 transitions | Unit (08-03) | Nine 2 ms tests versus nine 5 s ones, with a better diagnosis |
| The hours boundary values (0, 1, 40, 41) | Unit | Combinatorics: E2E is the worst place for many cases |
That toJSON/fromJSON preserve the fields |
Integration (08-05) | It needs no browser |
| Every validation error message | Integration | One in E2E is enough to verify the mechanism works |
Every path through fetchJson |
Unit with doubles (08-04) | Seven scenarios that require no interface |
| Specific styles: colors, margins | Visual regression testing | Another tool, another problem |
| That some text says exactly this sentence | Nowhere | It breaks with every wording tweak |
| Exhaustive filter combinations | Integration | In E2E, one representative case |
The decision rule:
flowchart TD
A["Does it need a REAL browser:<br/>CSS, focus, history, reload, SW?"] -->|No| B["It is not E2E<br/>Unit or integration"]
A -->|Yes| C["Is it a journey that<br/>matters to the business?"]
C -->|No| D["It is probably superfluous"]
C -->|Yes| E["Is it already covered by<br/>another E2E journey?"]
E -->|Yes| F["Extend the existing one"]
E -->|No| G["✅ Write it"]
style G fill:#bbf7d0,stroke:#15803d
style B fill:#fde68a,stroke:#b45309
That is why Nómada Tasks has three E2E journeys and not thirty: create, complete and filter. They are the three that, if they stopped working, would mean Marta could not use the application. Everything else is covered by 124 tests that take four seconds.
Common Mistakes and Tips
cy.wait(3000)to "make sure" something loaded. It is slow, fragile and it covers up the real problem. Wait for the condition (.should()) or for the request alias (cy.wait('@list')).- Trying to use the value returned by
cy.get(). The commands are queued and do not return elements: use.then()or, better,.should(). - Selecting by CSS class or by
nth-child. The test breaks with every layout tweak. Usedata-cy. - Writing thirty E2E tests. The suite goes from three minutes to thirty, stops being run on every commit and all the value is lost. Few and well chosen.
- Depending on state left behind by the previous test. Every
itmust start from a known point: seed inbeforeEachand clear the storage. - Creating the data through the interface before every test. It multiplies the time and means a broken form takes down the whole suite. Seed via
localStorageor via the API. - Forgetting the service worker. It can serve an old version and cause incomprehensible failures. Unregister it in the tests and give it a test of its own.
- Testing in E2E what a unit test already covers. The nine R6 transitions in E2E are 45 seconds to learn what you already knew in 20 milliseconds.
- Raising
retriesuntil everything passes. It turns a visible problem into an invisible one. Two retries in CI are a reasonable buffer; more is sweeping the dirt under the rug. - Tip: freeze the date.
cy.clock(new Date('2026-09-20T09:00:00Z'), ['Date'])keeps the overdue task overdue whatever the day, without breaking animations or thedebounce. - Tip: write the journeys the way Marta would tell them. "I create a task, I see it under not started, I mark it done and the summary drops from 45 to 39 hours." If the test cannot be narrated that way, it is probably not an E2E journey.
- Tip: when a journey fails, watch the video before reading the code. Seeing what happened saves half the "reproduce" step of the method from 08-01.
Exercises
Exercise 1 — The fourth journey: working offline.
Write cypress/e2e/offline.cy.js checking the PWA from 07-05 from end to end: (a) with the application loaded and the service worker active, take the browser offline with cy.intercept and forceNetworkError and check that the board is still visible with the six tasks and an offline-mode notice appears; (b) that a status change made offline is applied in the interface and queued; (c) that when the network comes back the queued change is sent and the notice disappears. Justify why this journey can only be tested in E2E and what precautions have to be taken with the service worker between tests.
Exercise 2 — Custom commands and domain assertions.
Extend cypress/support/commands.js with five commands that make the tests read like a business description: cy.seedBacklog(changes) (seeds the canonical backlog while letting you modify specific tasks), cy.card(title), cy.advance(title) (presses the advance button on that card and waits for the request to finish), cy.filterBy(assignee) and cy.summaryShouldSay({ open, hours, overdue }). Then rewrite journey 2 using them and compare the readability of the two versions.
Exercise 3 — Splitting a suite across the three levels. For each of these eight checks, decide which level it should live at (unit, integration or E2E), justify it in one sentence and write the first line of the corresponding test:
- A task with
estimatedHours: 41throwsValidationError. - The "Create task" button is not covered by the filter bar in a 1280×800 window.
toJSON()includes the private#status.- On pressing "Start", the summary goes from 45 to 45 h and the button changes its text.
- A
500inlistTasksproduces anApiErrorwithcode: 'server'. - The link
?assignee=Ivánopened cold shows 3 cards. reconcile()reuses the existing node instead of recreating it.- The search box's
debouncewaits 300 ms from the last keystroke.
Solutions
Solution 1
// cypress/e2e/offline.cy.js
describe('Journey 4 · Working offline (07-05)', () => {
beforeEach(() => {
// Precaution 1: always start from a clean service worker, or a cached version
// from a previous run would serve old HTML and the failures would be
// incomprehensible.
cy.window().then((win) => {
if (win.navigator.serviceWorker) {
return win.navigator.serviceWorker.getRegistrations()
.then((regs) => Promise.all(regs.map((r) => r.unregister())));
}
});
cy.clock(new Date('2026-09-20T09:00:00Z'), ['Date']);
cy.intercept('GET', '**/tasks', { fixture: 'backlog.json' }).as('list');
cy.visit('/');
cy.wait('@list');
// Precaution 2: wait until the SW is ACTIVE before cutting the network,
// or the test would measure the "no SW" case, which is a different scenario.
cy.window().its('navigator.serviceWorker.ready').should('exist');
cy.get('[data-cy="card"]').should('have.length', 6);
});
it('(a) offline, the board is still visible and a notice is shown', () => {
cy.intercept('GET', '**/tasks', { forceNetworkError: true }).as('outage');
cy.reload();
cy.get('[data-cy="card"]').should('have.length', 6); // served by the SW
cy.get('[data-cy="offline-notice"]')
.should('be.visible')
.and('contain', 'offline');
});
it('(b) a change made offline is applied in the interface and queued', () => {
cy.intercept('PATCH', '**/tasks/*', { forceNetworkError: true }).as('patchFailed');
cy.card('Signage for the screen-printing workshop').find('[data-cy="advance"]').click();
cy.wait('@patchFailed');
// The interface reflects the change (optimistically) and warns that it is pending
cy.card('Signage for the screen-printing workshop')
.should('have.attr', 'data-status', 'in-progress')
.and('have.class', 'task--pending-sync');
cy.get('[data-cy="pending-queue"]').should('contain', '1');
});
it('(c) when the network comes back, the queued change is sent and the notice disappears', () => {
cy.intercept('PATCH', '**/tasks/*', { forceNetworkError: true }).as('patchFailed');
cy.card('Signage for the screen-printing workshop').find('[data-cy="advance"]').click();
cy.wait('@patchFailed');
cy.get('[data-cy="pending-queue"]').should('contain', '1');
// The network is back
cy.intercept('PATCH', '**/tasks/*', (r) => r.reply({ statusCode: 200, body: r.body }))
.as('patchOk');
cy.window().then((win) => win.dispatchEvent(new Event('online')));
cy.wait('@patchOk').its('request.body').should('deep.include', { status: 'in-progress' });
cy.get('[data-cy="pending-queue"]').should('not.exist');
cy.get('[data-cy="offline-notice"]').should('not.exist');
});
});Why it can only be tested in E2E: jsdom implements neither service workers nor the Cache API, so the central part of the journey —that the application is served from the cache after a reload with no network— is literally unobservable in 08-05. On top of that it needs a real reload (cy.reload()), the browser's online event and the worker's complete lifecycle.
Precautions: unregister the SW in every beforeEach so caches do not carry over between tests; wait for serviceWorker.ready before cutting the network; and keep this file separate from the other three journeys, because an active SW interferes with cy.intercept responses and produces failures that are hard to diagnose.
Solution 2
// cypress/support/commands.js
/**
* Seeds the canonical backlog into localStorage, letting you modify
* specific tasks by id: cy.seedBacklog({ 2: { status: 'in-progress' } })
*/
Cypress.Commands.add('seedBacklog', (changes = {}) => {
cy.fixture('backlog.json').then((backlog) => {
const tasks = backlog.map((t) => ({ ...t, ...(changes[t.id] ?? {}) }));
cy.window().then((win) => {
win.localStorage.setItem('nomada:board:v1',
JSON.stringify({ name: 'Taller Nómada', version: 1, tasks }));
});
});
});
/** The card whose title contains that text. */
Cypress.Commands.add('card', (title) =>
cy.contains('[data-cy="card"]', title));
/** Presses "advance" on that card and waits for the request to finish. */
Cypress.Commands.add('advance', (title) => {
cy.intercept('PATCH', '**/tasks/*').as('advanceRequest');
cy.card(title).find('[data-cy="advance"]').click();
cy.wait('@advanceRequest'); // never cy.wait(ms)
return cy.card(title); // returns the card, chainable
});
/** Applies a person's filter and waits for the list to settle. */
Cypress.Commands.add('filterBy', (assignee) => {
cy.get(`[data-cy="filter-${assignee}"]`).click()
.should('have.attr', 'aria-pressed', 'true');
cy.url().should('include', `assignee=${encodeURIComponent(assignee)}`);
});
/** A domain assertion about the summary panel. */
Cypress.Commands.add('summaryShouldSay', ({ open, hours, overdue }) => {
cy.get('[data-cy="summary"]').within(() => {
if (open !== undefined) cy.contains(`${open} open`).should('be.visible');
if (hours !== undefined) cy.contains(`${hours} h`).should('be.visible');
if (overdue !== undefined) cy.contains(`${overdue} overdue`).should('be.visible');
});
});// Journey 2, rewritten: it reads the way Marta would tell it
describe('Journey 2 · Completing a task', () => {
beforeEach(() => {
cy.clock(new Date('2026-09-20T09:00:00Z'), ['Date']);
cy.intercept('GET', '**/tasks', { fixture: 'backlog.json' }).as('list');
cy.visit('/');
cy.wait('@list');
});
it('Marta takes the signage from not started to done', () => {
cy.summaryShouldSay({ open: 5, hours: 45, overdue: 1 });
cy.advance('Signage for the screen-printing workshop')
.should('have.attr', 'data-status', 'in-progress');
cy.summaryShouldSay({ open: 5, hours: 45 });
cy.advance('Signage for the screen-printing workshop')
.should('have.attr', 'data-status', 'done');
cy.summaryShouldSay({ open: 4, hours: 39 });
});
it('a task that is already done cannot be reopened (R6)', () => {
cy.card('Screen-printing ink inventory')
.find('[data-cy="advance"]').should('be.disabled');
});
});The version with commands is half the length, does not repeat a single selector and can be read aloud to Marta to check that the test verifies what she expects. That last point is the decisive argument: an E2E test a non-programmer can understand is a test that verifies the business, not the implementation.
Solution 3
| # | Level | Justification | First line |
|---|---|---|---|
| 1 | Unit | Pure model logic, no DOM and no network; it is a boundary value from a table | expect(() => aTask({ estimatedHours: 41 })).toThrow(ValidationError); |
| 2 | E2E | It depends on painting and on real positions: jsdom cannot know | cy.get('[data-cy="create"]').should('be.visible').click(); |
| 3 | Unit | Pure serialization, with no collaborators | expect(aTask({ status: 'in-progress' }).toJSON()).toStrictEqual({ … }); |
| 4 | Integration | View + model + controller collaborating; it needs no real browser | await user.click(within(card).getByRole('button', { name: /start/i })); |
| 5 | Unit with doubles | One case from the network error table; it is solved with a fetch double (08-04) |
network.mockResolvedValue(jsonResponse({ message: 'Error' }, { status: 500 })); |
| 6 | E2E | It requires a real URL, a cold load and the browser's router | cy.visit('/?assignee=Iv%C3%A1n'); |
| 7 | Integration | It needs a DOM, but not a browser: toBe on the node's identity |
expect(document.querySelector('[data-id="2"]')).toBe(nodeBefore); |
| 8 | Unit | It is pure timing logic, with fake timers (08-04) | jest.advanceTimersByTime(299); expect(search).not.toHaveBeenCalled(); |
Observations about the split: only two of the eight are E2E, and both for the same reason —they need a real browser for painting or for the history. Four are unit tests because they are pure logic, and two are integration tests because they need a DOM but not a browser. That split, applied systematically, is what keeps a suite fast and reliable.
Conclusion
Nómada Tasks has the complete safety net. You know what an end-to-end test validates and nothing else does: that index.html loads, that the ES modules resolve in a real browser, that the CSS does not cover the button, that focus goes where it should, that a reload preserves the data, that the history works and that the service worker serves the right thing. And you know what they cost —seconds per test, minutes per suite, high fragility, low precision on failure—, which gives the rule that governs the whole level: few and very well chosen.
You know Cypress thoroughly: its architecture inside the browser, with the virtues it brings (real automatic waiting, access to the application's window, time travel) and the limits it imposes (one tab, one origin, cy.task for Node work); the interactive runner for writing and run mode with start-server-and-test for CI; the anatomy of a test with cy.visit, cy.get, cy.contains, .click(), .type() and .should(); and stable selectors with data-cy, reserving text and role assertions for verifying what the user sees. You have internalized the command queue model —which explains why const x = cy.get(...) does not work— and automatic waiting, including that "the element is not covered by another one" check which is exactly what jsdom cannot see. And you know why cy.wait(3000) is an antipattern while cy.wait('@list') is correct: one counts the clock blindly, the other waits for an event.
You have cy.intercept down in its three uses —observing, fixing responses and provoking failures that are impossible to reproduce against a real server— with the policy of one happy path against the real server and everything else with fixed data. You seed the initial state through localStorage or with cy.session rather than creating it through the interface, you unregister the service worker so it does not serve old versions, and you write custom commands and fixtures that make a test something you can read aloud to Marta. You know how to debug a failure with screenshots, video, time travel and cy.window() to inspect the live model; how to run in GitHub Actions with needs so the cheap stuff goes first and the screenshots only upload on failure; how to add an accessibility audit with axe knowing it covers around a third of the real problems; and how to choose between Cypress and Playwright with judgment, clear that the concepts transfer entirely.
And above all you have written the three critical journeys: Marta creates a task and sees it appear under not started with the summary rising to 47 h, with its check that it survives a reload and its 500 → retry → success cycle; she takes the signage from not started to under way to completed, watching the summary drop from 45 to 39 h and Iván's workload from 25 to 13, with the optimistic revert when the server rejects the change; and Iván filters by his name with the URL reflecting the filter, the back button restoring the previous state and the ?assignee=Iván link working when opened cold. Three journeys, not thirty, because you know what not to test in E2E: the nine R6 transitions are unit tests, the toJSON trip is integration, the seven fetchJson scenarios are covered with doubles, and E2E gets only what needs a real browser and matters to the business.
That brings Module 8 to a close. Nómada Tasks has gone from having no automated checks at all to four layers of defense: the static analysis of ESLint and Prettier with their Git hooks, catching form bugs before anything runs; 48 unit tests that armor-plate the whole model —validations, transitions, overdue rules, serialization and the canonical numbers: 6 tasks, 48 total hours, 45 open hours, effort 124—; tests with doubles covering network, clock, storage and randomness without leaving the machine; integration tests verifying the seams between model, data and view in a DOM with no browser; and three E2E journeys confirming that the whole application works the way a person would use it. One hundred and twenty-four tests in four seconds, plus three journeys in a minute. And you have the debugging method for when something breaks anyway: reproduce, isolate, form a falsifiable hypothesis, verify it with the right instrument, fix the cause and prevent it with a test.
Notice what has really changed. At the start of the module, changing one line was frightening. Now you can rewrite the whole of Board, replace the rendering system, change the data layer… and know in four seconds whether anything has broken. That is what makes what comes next possible. Because Nómada Tasks now works and is correct, but nobody has yet asked whether it is fast: how long it takes to paint with six hundred tasks instead of six, how many frames it drops while scrolling, how much memory it holds on to, how much JavaScript it downloads before showing the first card. And optimizing without a safety net is the most efficient way of breaking things subtly: a badly invalidated cache, a comparison that skips a case, a calculation done in the wrong place. With 124 tests behind you, performance can be touched without fear… but there is a precondition almost everybody skips, and it is exactly the same discipline you learned while debugging: measure first, act afterwards. Optimizing by intuition produces the same result as debugging by intuition —changes that do not converge, added complexity and no verifiable improvement. That is Module 9: Performance, which starts precisely there: Measure Before Optimizing.
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
