The previous four lessons have built a solid safety net: business rules tested without React, components tested the way a person actually uses them, and whole pages tested against the network MSW simulates. And yet there is a family of failures that none of them can see. A perfectly functional "Book" button gets covered by a cookie banner with position: fixed, and the jsdom tests stay green because there is no layout engine there. The redirect after signing in lands on /reservas instead of the screen the user was bounced from, and because each page is tested in isolation, nobody notices. The session doesn't survive a reload because localStorage is written under one key and read under another, and the in-memory router never reloads. All three are real failures, visible on a user's very first attempt, and invisible to everything above.
This lesson sets up Cypress: a real browser, with the real app running and json-server responding, walking through CicloUrbano end to end. It's the most expensive layer of the trophy and the one that gives the most confidence, which is exactly why it has to be rationed with the most judgment. By the end you'll have the project's three critical flows automated, a continuous integration workflow that runs the whole module on every change, and the full picture of what each level covers.
Contents
- What an end-to-end test sees that the others don't
- What it costs, and which flows are worth that cost
- Installation and project structure
- Starting the app and the API before the tests
- Anatomy of a Cypress test
- The asynchronous, retrying nature: why
awaitisn't used - Robust selectors: the
data-testidcontract - Assertions with
shouldand chaining - Controlling the network with
cy.intercept - Initial state and isolation between tests
cy.session: not repeating sign-in- Flow 1: signing in
- Flow 2: booking a bike
- Flow 3: cancelling a booking
- Debugging a failing test
- Continuous integration with GitHub Actions
- Cypress vs. Playwright
- The module's full strategy
- What an end-to-end test sees that the others don't
| Capability | jsdom + RTL |
Cypress |
|---|---|---|
| Real routing | Simulated in memory; the browser URL doesn't change | The address bar really changes; back/forward and reload work |
| CSS and visibility | No layout engine: toBeVisible is an approximation |
Elements that are covered, off-screen, or zero-sized fail as they should |
| Storage and session | localStorage exists but gets cleared between tests |
Real persistence across navigations and reloads |
| Real network | Intercepted by MSW | The real API (json-server), or intercepted at will |
| Several chained screens | Each test mounts a component | A full journey: sign-in → catalogue → detail → booking → list |
| Real build | The code goes through the test transform | Runs the build that gets deployed, with its bundling and lazy loading |
| Chunk-loading failures | Don't exist | A lazy that fails to resolve produces the real failure (08-04) |
| Browser behaviour | Simulated | Focus, scrolling, animations, native datetime-local |
The sentence that sums up the difference: the earlier tests check that the pieces work; this one checks that the app works.
- What it costs, and which flows are worth that cost
The cost is not negligible:
| Cost | Magnitude |
|---|---|
| Speed | Seconds per test, versus milliseconds. A 30-test e2e suite takes several minutes |
| Flakiness | It's the layer most prone to intermittent failures: network, timing, animations |
| Maintenance | A design change can break several tests at once |
| Infrastructure | Requires the app running, the API running, and data in a known state |
| Diagnosis | When it fails, the failure could be in any of the five layers it crosses |
Hence the decision criteria:
| Question | If the answer is yes… |
|---|---|
| Is it on the path of money or of the product's mission? | Clear candidate |
| Does it cross several screens and several layers (session, network, routing)? | Clear candidate |
| Would its failure be catastrophic and invisible until it reaches the user? | Clear candidate |
| Can it be verified just as well with an integration test? | Not worth e2e |
| Does it mainly depend on appearance? | No: that's visual review or visual regression testing |
| Is it an edge case of a business rule? | No: that's a unit test (09-02) |
Applied to CicloUrbano, exactly three flows come out, and the short list is deliberate:
- Signing in. Without this, nothing else matters. It crosses the form, Redux, the redirect, and persistence.
- Booking a bike. It's the path of the money: catalogue, filter, detail page, form, mutation, invalidation, and navigation.
- Cancelling a booking. It touches existing data, confirms in a modal, and refreshes two lists.
What is not tested with Cypress, and why: the type filter (already covered in 09-04 with the URL and MSW), field-by-field validation messages (09-03), validateBooking's edge cases (09-02), dark mode (appearance), and each page's error state (much cheaper with server.use).
- Installation and project structure
ciclourbano/ ├── cypress/ │ ├── e2e/ │ │ ├── sign-in.cy.js │ │ ├── booking.cy.js │ │ └── cancel.cy.js │ ├── fixtures/ │ │ └── bikes.json ← fixed data for cy.intercept │ ├── support/ │ │ ├── e2e.js ← loaded before EVERY test file │ │ └── commands.js ← custom commands: cy.signInAs(), cy.seedData() │ └── downloads/ ├── cypress.config.js └── db.json ← json-server's database
// cypress.config.js
import { defineConfig } from 'cypress';
export default defineConfig({
e2e: {
// Lets you write cy.visit('/') instead of the full URL
baseUrl: 'http://localhost:5173',
supportFile: 'cypress/support/e2e.js',
specPattern: 'cypress/e2e/**/*.cy.js',
viewportWidth: 1280,
viewportHeight: 800,
// No retries in interactive mode; two in continuous integration,
// where a one-off infrastructure hiccup shouldn't sink the deployment.
retries: { runMode: 2, openMode: 0 },
video: true,
screenshotOnRunFailure: true,
env: {
apiUrl: 'http://localhost:3001'
}
}
});A note about retries. Lesson 09-01 said that a flaky test doesn't get retried, it gets fixed, and that's still true. Retries in runMode are a different concession: in continuous integration there are sources of flakiness unrelated to the test — a slow container, a port that takes a while to open — and two retries avoid blocking a deployment over that. What they must not do is paper over a test that fails one time in five: Cypress flags retries in the report, and a test that repeatedly shows up as "recovered after retry" needs fixing.
// cypress/support/e2e.js
import './commands.js';
// Testing Library for Cypress: brings in cy.findByRole and friends,
// with the same query priority from 09-03
import '@testing-library/cypress/add-commands';
// Fails the test if the app throws an uncaught error.
// This is the default behaviour and it's worth NOT disabling it:
// a console error is a failure, even if the screen looks fine.
- Starting the app and the API before the tests
Cypress doesn't start anything: it just visits URLs. Two live processes are needed — Vite on 5173 and json-server on 3001 — and both must respond before the tests are launched. start-server-and-test takes care of that:
{
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --port 5173",
"api": "json-server --watch db.json --port 3001",
"test": "vitest",
"test:run": "vitest run",
"coverage": "vitest run --coverage",
"dev:all": "npm-run-all --parallel dev api",
"cy:open": "cypress open",
"cy:run": "cypress run",
"e2e": "start-server-and-test dev:all 'http://localhost:5173|http://localhost:3001' cy:run",
"e2e:open": "start-server-and-test dev:all 'http://localhost:5173|http://localhost:3001' cy:open",
"test:all": "npm run test:run && npm run e2e"
}
}What start-server-and-test does, in order: it launches dev:all (which starts Vite and json-server in parallel), polls both URLs until they respond, runs cy:run, and when it's done kills both processes and forwards Cypress's exit code. That polling is what avoids the classic continuous integration failure: launching Cypress half a second before Vite has finished compiling, and watching every test fail with ECONNREFUSED.
flowchart LR
A["npm run e2e"] --> B["dev:all<br/>Vite 5173 + json-server 3001"]
B --> C{"Do both URLs<br/>respond?"}
C -- No --> C
C -- Yes --> D["cypress run"]
D --> E["Closes both processes<br/>and forwards the exit code"]
A note on dev versus preview: in local development you use Vite's dev server for hot reload. In continuous integration it's better to test against npm run build + npm run preview, because that's the code that gets deployed, with its bundling and its chunk splitting. That's where module 8's lazy failures show up.
- Anatomy of a Cypress test
// cypress/e2e/catalogue.cy.js
describe('CicloUrbano catalogue', () => {
beforeEach(() => {
cy.visit('/');
});
it('shows the five bikes in the fleet', () => {
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);
});
it('lets you view a bike detail page', () => {
cy.contains('[data-testid="tarjeta-bicicleta"]', 'Classic Urban')
.findByRole('button', { name: 'View details' })
.click();
cy.url().should('include', '/bicicletas/bici-001');
cy.findByRole('heading', { name: 'Classic Urban' }).should('be.visible');
});
});describe and it are the same as ever: Cypress uses Mocha, with the same structure from 09-02. What changes is everything else.
| Command | What it does |
|---|---|
cy.visit(path) |
Loads a URL (relative to baseUrl) |
cy.get(selector) |
Finds elements by CSS selector |
cy.contains(text) |
Finds by text content |
cy.contains(selector, text) |
Finds elements of that selector containing that text |
cy.findByRole(...) |
Testing Library queries, with their priority |
.click(), .type(), .select(), .check() |
Interactions |
.should(assertion) |
Assertion with retries |
cy.url(), cy.location() |
The current URL |
cy.intercept(...) |
Control the network |
cy.wait('@alias') |
Wait for a specific request |
- The asynchronous, retrying nature: why
await isn't used
await isn't usedThis is the concept that trips people up the most, and it explains 90% of the confusion around Cypress.
// ❌ This does NOT work: cy.get doesn't return an element
const button = cy.get('[data-testid="boton-reservar"]');
button.click(); // `button` is not a DOM element
// ❌ Neither does this: commands aren't promises you can await
const text = await cy.get('h1').text();Cypress commands don't run anything when you write them: they get queued. The body of the it runs entirely synchronously, and all it does is build a queue of commands. When it finishes, Cypress starts executing the queue, one after another, waiting for each to finish before moving to the next.
it('books a bike', () => {
cy.visit('/'); // 1) queued
cy.get('[data-testid="x"]'); // 2) queued
cy.contains('Book').click(); // 3) queued
// The body ends HERE. Now Cypress runs 1, 2, and 3 in order.
});Direct consequence: a command's result isn't available on the next line of JavaScript, because that line has already run. To work with a value, you use .then():
cy.get('[data-testid="total-reserva"]').then(($element) => {
// This is fine: the command has already run and $element is a jQuery object
const total = parseFloat($element.text().replace(',', '.'));
expect(total).to.be.greaterThan(0);
});Automatic waiting and retries
The other half of the model: every query command retries until it finds what it's looking for or the timeout runs out (4 seconds by default, configurable). And every should assertion retries along with the command before it.
// No need to wait for anything: cy.get retries until the card shows up,
// even if json-server takes a second to respond
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);This wipes out 09-04's problem at the root: in Cypress you don't write explicit waits for data. And that's exactly why the ban on fixed delays still stands:
// ❌ Never
cy.wait(2000);
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);
// ✅ Always
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);One important detail that avoids a subtle failure: retries apply to the last command in the chain, not the whole chain. If you write cy.get('ul').find('li').should('have.length', 3), Cypress retries the find, but cy.get('ul') was resolved once. If the whole list gets remounted in the middle, you get the "element detached from DOM" error. The fix is a single query that covers everything: cy.get('ul li').should('have.length', 3).
And another one: action commands (click, type) don't retry indefinitely, but they do check that the element is "actionable" — visible, not covered, not disabled, not animating — and wait for it to be. That's what catches the button covered by the cookie banner from the start of this lesson.
- Robust selectors: the
data-testid contract
data-testid contractIn 09-03, getByRole was recommended first and data-testid as a last resort. In end-to-end tests the criteria change, and it's worth understanding why:
| Selector | In component tests | In e2e tests |
|---|---|---|
getByRole / findByRole |
First choice | Very good for interactive elements |
| Visible text | Good | Fragile: text changes for editorial reasons, and it breaks with translations |
| CSS classes | Never | Never: they're CSS Modules hashes |
| DOM structure | Never | Never |
data-testid / data-cy |
Last resort | Recommended for structural anchors |
The difference is one of purpose. A component test verifies a component and can afford to depend on its full semantics. An e2e test walks through five screens and needs stable anchor points that survive redesigns, copy changes, and layout refactors. A data-testid is an explicit contract: whoever writes it in the JSX is declaring "the tests use this, don't delete it without warning."
The combination the project uses: data-testid to locate the zone or structural element, and findByRole for the interactive element inside it.
// src/components/BikeCard.jsx (fragment with the anchors)
<article className={styles.tarjeta} data-testid="tarjeta-bicicleta" data-bicicleta={bike.id}>
<h3>{bike.model}</h3>
<p className={styles.estacion}>{stationName}</p>
<StatusBadge status={bike.status} />
<p className={styles.precio}>{EURO_FORMAT.format(bike.pricePerHour)} / hour</p>
<button type="button" onClick={() => onSelect(bike.id)}>View details</button>
<button
type="button"
onClick={() => onBook(bike.id)}
disabled={bike.status !== 'disponible'}
>
Book
</button>
</article>// Locate a specific card by its domain identifier
cy.get('[data-bicicleta="bici-001"]').findByRole('button', { name: 'Book' }).click();
// Or by its content, when the identifier doesn't matter
cy.contains('[data-testid="tarjeta-bicicleta"]', 'Electric Pro')
.findByRole('button', { name: 'View details' })
.click();A custom command saves repetition and centralizes the selector:
// cypress/support/commands.js
Cypress.Commands.add('byTestId', (id, ...rest) =>
cy.get(`[data-testid="${id}"]`, ...rest)
);
Cypress.Commands.add('cardFor', (bikeId) =>
cy.get(`[data-bicicleta="${bikeId}"]`)
);cy.byTestId('lista-bicicletas').should('be.visible');
cy.cardFor('bici-001').findByRole('button', { name: 'Book' }).click();If the attribute gets renamed to data-cy tomorrow, it changes in one place.
- Assertions with
should and chaining
should and chainingCypress uses Chai, in two styles: chained should (the usual one) and expect inside a .then().
// Chained style: retries until it holds
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);
cy.findByRole('button', { name: 'Book' }).should('be.visible').and('not.be.disabled');
cy.url().should('include', '/reservas');
cy.get('[data-testid="aviso"]').should('contain.text', 'Booking created');
cy.findByLabelText('Duration (hours)').should('have.value', '2');
// Expect style: inside then, no retries
cy.get('[data-testid="total-reserva"]').then(($el) => {
expect($el.text()).to.match(/€12\.00/);
});| Assertion | Checks |
|---|---|
should('be.visible') |
Actually visible: on screen, not covered, with a size |
should('not.exist') |
Not in the DOM |
should('be.disabled') / ('not.be.disabled') |
Control state |
should('have.length', n) |
Number of elements |
should('contain.text', t) |
Contains that text |
should('have.value', v) |
Value of a field |
should('have.attr', a, v) |
Attribute, useful for aria-* |
should('have.class', c) |
Class: avoid it with CSS Modules |
.and(...) |
Chains another assertion on the same element |
The difference between not.exist and not.be.visible matters: the first requires the element not to be in the DOM; the second accepts that it's there but hidden. For a closed modal, which one is correct depends on how Modal is implemented, and picking the wrong one produces a test that passes for the wrong reason.
- Controlling the network with
cy.intercept
cy.interceptcy.intercept does three things: observe requests so you can wait for them, replace responses, and delay them.
Observing and waiting
it('loads the catalogue from the API', () => {
// The alias lets you wait for THIS specific request
cy.intercept('GET', '**/bicicletas*').as('loadBikes');
cy.visit('/');
cy.wait('@loadBikes').its('response.statusCode').should('eq', 200);
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);
});cy.wait('@alias') is Cypress's only legitimate wait, because it waits for a specific event, not for a duration. Use it when you need to assert on the request — its body, its status code — or when the interface doesn't change observably once it completes.
Verifying what gets sent
cy.intercept('POST', '**/reservas').as('createBooking');
// … fill in and submit …
cy.wait('@createBooking').then(({ request, response }) => {
expect(request.body).to.include({
bicicletaId: 'bici-001',
user: 'usr-01',
hours: 2,
status: 'activa'
});
expect(response.statusCode).to.eq(201);
});Replacing the response
// A server error
cy.intercept('GET', '**/bicicletas*', { statusCode: 500, body: {} }).as('failure');
// An empty list
cy.intercept('GET', '**/bicicletas*', { body: [] });
// Fixed data from cypress/fixtures/bikes.json
cy.intercept('GET', '**/bicicletas*', { fixture: 'bikes.json' });
// A slow response, to check the skeleton
cy.intercept('GET', '**/bicicletas*', (req) => {
req.reply({ delay: 1000, fixture: 'bikes.json' });
});
// Fail only the first time, to test the retry
cy.intercept('GET', '**/bicicletas*', { statusCode: 500, times: 1 });When to use the real API and when to simulate it
| Situation | Decision | Why |
|---|---|---|
| The three critical flows | Real API (json-server) |
It's what gives an e2e test its value: verifying the real integration |
| Checking the error state | Simulate with intercept |
Shutting down json-server mid-test isn't viable |
| Checking an empty list | Simulate | Emptying the database would affect other tests |
| Checking the loading skeleton | Simulate with delay |
The real API responds too fast to observe it |
| Verifying the request body | Observe with an alias, without replacing | You want the real integration and to assert on the request |
| A third-party payment service | Always simulate | You don't call a third party from a test |
The rule: the happy path of the critical flows runs against the real API; the alternative paths get simulated. If you simulate the happy path, the e2e test stops adding anything that 09-04 didn't already give you, and you'll have paid the cost without getting the benefit.
- Initial state and isolation between tests
The fundamental problem with e2e tests against a real API: the database keeps what they do. If one test creates a booking, the next one starts with one extra booking, and the result depends on order.
CicloUrbano's solution has three pieces.
db.json with a resettable seed state
{
"bicicletas": [
{ "id": "bici-001", "model": "Classic Urban", "type": "urbana", "status": "disponible", "stationId": "est-01", "pricePerHour": 2.5 },
{ "id": "bici-002", "model": "Electric Pro", "type": "electrica", "status": "alquilada", "stationId": "est-01", "pricePerHour": 4.0 },
{ "id": "bici-003", "model": "Cargo Max", "type": "carga", "status": "mantenimiento", "stationId": "est-02", "pricePerHour": 5.5 },
{ "id": "bici-004", "model": "Classic Urban", "type": "urbana", "status": "disponible", "stationId": "est-03", "pricePerHour": 2.5 },
{ "id": "bici-005", "model": "Electric Pro", "type": "electrica", "status": "disponible", "stationId": "est-02", "pricePerHour": 4.0 }
],
"estaciones": [
{ "id": "est-01", "name": "Main Square", "district": "Downtown", "docks": 20 },
{ "id": "est-02", "name": "North Park", "district": "North", "docks": 15 },
{ "id": "est-03", "name": "Central Station", "district": "Riverside", "docks": 30 }
],
"reservas": [
{ "id": "res-01", "bicicletaId": "bici-002", "user": "usr-01", "startDate": "2026-05-04T09:00", "hours": 2, "status": "activa" }
]
}An untouched copy is kept in cypress/fixtures/db.seed.json, and a custom command restores it:
// cypress/support/commands.js
Cypress.Commands.add('seedData', () => {
cy.fixture('db.seed.json').then((seed) => {
// Deletes whatever is there and puts back the known state
cy.request('GET', `${Cypress.env('apiUrl')}/reservas`).then(({ body }) => {
body.forEach((booking) => {
cy.request('DELETE', `${Cypress.env('apiUrl')}/reservas/${booking.id}`);
});
});
seed.reservas.forEach((booking) => {
cy.request('POST', `${Cypress.env('apiUrl')}/reservas`, booking);
});
seed.bicicletas.forEach((bike) => {
cy.request('PUT', `${Cypress.env('apiUrl')}/bicicletas/${bike.id}`, bike);
});
});
});cy.request makes HTTP requests outside the browser: it doesn't go through the app, doesn't trigger the UI, and is fast. It's the right tool for setup and teardown; you never set up a scenario by clicking buttons.
Automatic browser isolation
Since Cypress 12, every it starts with localStorage, sessionStorage, and cookies clean (testIsolation: true, the default). That solves half the problem and creates the other half: if every test starts with no session, you have to sign in again in each one. That's what cy.session is for.
cy.session: not repeating sign-in
cy.session: not repeating sign-inSigning in through the UI in every test costs seconds and, more importantly, couples every test to the sign-in form: a change to SignInPage would break all twenty of them.
cy.session runs the sign-in only once, saves the resulting state — localStorage, cookies, sessionStorage — and restores it in the following tests.
// cypress/support/commands.js
Cypress.Commands.add('signInAs', (userId) => {
cy.session(
// 1) Session key: different sessions for different users
['session', userId],
// 2) How the session gets set up. Runs the first time and whenever validation fails
() => {
cy.visit('/acceso');
cy.findByLabelText('Sign in as').select(userId);
cy.findByRole('button', { name: /Sign in/ }).click();
cy.url().should('not.include', '/acceso');
},
// 3) Validation: if it returns something falsy or throws, the session gets rebuilt
{
validate() {
cy.window().its('localStorage')
.invoke('getItem', 'ciclourbano:usuario')
.should('contain', userId);
},
cacheAcrossSpecs: true // also reused across test files
}
);
});describe('Booking a bike', () => {
beforeEach(() => {
cy.seedData();
cy.signInAs('usr-01'); // instant from the second time on
cy.visit('/');
});
// …
});The validate block is the piece that makes the mechanism reliable: it checks that the restored session is still valid and, if it isn't, reruns the sign-in. Without it, an expired session would produce baffling failures in tests that have nothing to do with signing in.
And a rule follows from this: the sign-in flow is tested once, through the UI, in its own file. Every other test uses cy.signInAs.
- Flow 1: signing in
// cypress/e2e/sign-in.cy.js
describe('Signing in to CicloUrbano', () => {
beforeEach(() => {
cy.seedData();
});
it('lets a customer sign in and shows their name in the header', () => {
cy.visit('/acceso');
cy.findByRole('heading', { name: /Sign in to CicloUrbano/ }).should('be.visible');
cy.findByLabelText('Sign in as').select('usr-01');
cy.findByRole('button', { name: /Sign in/ }).click();
// Redirect to the catalogue
cy.url().should('eq', `${Cypress.config('baseUrl')}/`);
cy.byTestId('menu-usuario').should('contain.text', 'Ana Ribera');
});
it('sends the user back to the screen they were trying to visit', () => {
// Without a session, /reservas bounces to sign-in, saving the origin (06-05)
cy.visit('/reservas');
cy.url().should('include', '/acceso');
cy.findByRole('alert').should('contain.text', '/reservas');
cy.findByLabelText('Sign in as').select('usr-01');
cy.findByRole('button', { name: /Sign in/ }).click();
// And returns to the original destination, not the home page
cy.url().should('include', '/reservas');
cy.findByRole('heading', { name: /Your bookings/ }).should('be.visible');
});
it('the session survives a page reload', () => {
cy.signInAs('usr-01');
cy.visit('/');
cy.byTestId('menu-usuario').should('contain.text', 'Ana Ribera');
cy.reload();
cy.byTestId('menu-usuario').should('contain.text', 'Ana Ribera');
cy.url().should('not.include', '/acceso');
});
it('a customer cannot enter the workshop', () => {
cy.signInAs('usr-01');
cy.visit('/taller');
cy.url().should('include', '/sin-permisos');
cy.findByRole('heading', { name: /You don't have permission/ }).should('be.visible');
});
it('an operator can enter the workshop', () => {
cy.signInAs('usr-02');
cy.visit('/taller');
cy.url().should('include', '/taller');
cy.findByRole('heading', { name: /Workshop/ }).should('be.visible');
});
it('signing out returns to sign-in and blocks going back', () => {
cy.signInAs('usr-01');
cy.visit('/reservas');
cy.byTestId('menu-usuario').findByRole('button', { name: /Sign out/ }).click();
cy.url().should('include', '/acceso');
cy.go('back');
cy.url().should('include', '/acceso'); // the protection is still active
});
});The two tests that only Cypress can do are here: the reload one (cy.reload()), which verifies that the session really persists in the browser, and the back button one (cy.go('back')), which verifies that the route protection from 06-05 can't be bypassed with history. Neither exists with an in-memory router.
- Flow 2: booking a bike
The path of the money, from start to finish.
// cypress/e2e/booking.cy.js
describe('Booking a bike', () => {
beforeEach(() => {
cy.seedData();
cy.signInAs('usr-01');
});
it('walks through the full flow: catalogue → detail → booking → list', () => {
// The request is observed to assert on the body sent,
// but NOT replaced: the happy path runs against the real API
cy.intercept('POST', '**/reservas').as('createBooking');
cy.visit('/');
// 1) The catalogue loads and bici-001 is available
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);
cy.cardFor('bici-001').should('contain.text', 'Available');
// 2) Its detail page opens
cy.cardFor('bici-001').findByRole('button', { name: 'View details' }).click();
cy.url().should('include', '/bicicletas/bici-001');
cy.findByRole('heading', { name: 'Classic Urban' }).should('be.visible');
cy.contains('Main Square').should('be.visible');
// 3) The booking starts from the detail page
cy.findByRole('button', { name: /Book this bike/ }).click();
cy.url().should('include', '/reservas/nueva');
// 4) The form gets filled in
cy.findByLabelText('Bike').should('have.value', 'bici-001'); // preselected
cy.findByLabelText('Booking start').type('2030-06-01T10:00');
cy.findByLabelText('Duration (hours)').clear().type('3');
cy.findByRole('checkbox', { name: /I accept the terms/ }).check();
// The derived total: €2.50 × 3 h
cy.byTestId('total-reserva').should('contain.text', '7.50');
// 5) It gets submitted
cy.findByRole('button', { name: 'Create booking' }).click();
// 6) What went out over the network gets verified
cy.wait('@createBooking').then(({ request, response }) => {
expect(response.statusCode).to.eq(201);
expect(request.body).to.include({
bicicletaId: 'bici-001',
user: 'usr-01',
startDate: '2030-06-01T10:00',
hours: 3,
status: 'activa'
});
expect(request.body.id).to.match(/^res-[a-z0-9]{8}$/);
});
// 7) Confirmation and navigation
cy.findByRole('status').should('contain.text', 'Booking created');
cy.url().should('include', '/reservas');
// 8) The new booking is in the list, alongside the seeded one
cy.get('[data-testid="fila-reserva"]').should('have.length', 2);
cy.contains('[data-testid="fila-reserva"]', 'Classic Urban')
.should('contain.text', '3 h')
.and('contain.text', 'Active');
// 9) And it persists after a reload: it was really saved on the server
cy.reload();
cy.get('[data-testid="fila-reserva"]').should('have.length', 2);
});
it('does not allow booking a bike that is in maintenance', () => {
cy.visit('/');
cy.cardFor('bici-003')
.should('contain.text', 'In maintenance')
.findByRole('button', { name: 'Book' })
.should('be.disabled');
});
it('shows validation errors and sends nothing', () => {
// If anything were sent, this alias would catch it
cy.intercept('POST', '**/reservas').as('createBooking');
cy.visit('/reservas/nueva');
cy.findByRole('button', { name: 'Create booking' }).click();
cy.findAllByRole('alert').should('have.length.at.least', 3);
cy.findByLabelText('Duration (hours)').should('have.attr', 'aria-invalid', 'true');
cy.url().should('include', '/reservas/nueva');
cy.get('@createBooking.all').should('have.length', 0);
});
it('warns if the server fails and does not lose what was entered', () => {
// Here it IS simulated: the alternative path can't be triggered any other way
cy.intercept('POST', '**/reservas', { statusCode: 500, body: {} }).as('createBookingFailure');
cy.visit('/reservas/nueva');
cy.findByLabelText('Bike').select('bici-001');
cy.findByLabelText('Booking start').type('2030-06-01T10:00');
cy.findByLabelText('Duration (hours)').clear().type('2');
cy.findByRole('checkbox', { name: /I accept the terms/ }).check();
cy.findByRole('button', { name: 'Create booking' }).click();
cy.wait('@createBookingFailure');
cy.findByRole('alert').should('contain.text', 'The booking could not be created');
// The user's work is still there
cy.findByLabelText('Duration (hours)').should('have.value', '2');
cy.url().should('include', '/reservas/nueva');
});
});Three details deserve attention:
- The date is
2030-06-01, not a nearby date.validateBookingrejects the past, and a fixed 2026 date would turn the test into a time bomb that starts failing on its own. Cypress doesn't have convenient fake timers, so the fix is a date far enough in the future. The alternative —cy.clock()— exists, but interferes with requests and complicates more than it solves. cy.get('@createBooking.all').should('have.length', 0)checks that no request was sent. It's the right way to verify that client-side validation blocks the submit.- Step 9, the reload, is what sets this test apart from the one in 09-04. There, it verified that the UI updated after invalidation; here, it verifies that the data reached the server and is still there.
- Flow 3: cancelling a booking
// cypress/e2e/cancel.cy.js
describe('Cancelling a booking', () => {
beforeEach(() => {
cy.seedData();
cy.signInAs('usr-01');
cy.visit('/reservas');
});
it('cancels an active booking after confirming in the dialog', () => {
cy.intercept('PATCH', '**/reservas/res-01').as('cancelBooking');
cy.contains('[data-testid="fila-reserva"]', 'Electric Pro')
.should('contain.text', 'Active')
.findByRole('button', { name: /Cancel/ })
.click();
// The modal dialog must receive focus and be accessible (03-06)
cy.findByRole('dialog').should('be.visible').within(() => {
cy.findByRole('heading', { name: /Cancel the booking/ }).should('be.visible');
cy.contains('Electric Pro').should('be.visible');
cy.findByRole('button', { name: /Yes, cancel/ }).click();
});
cy.wait('@cancelBooking').then(({ request, response }) => {
expect(request.body).to.include({ status: 'cancelada' });
expect(response.statusCode).to.eq(200);
});
cy.findByRole('dialog').should('not.exist');
cy.findByRole('status').should('contain.text', 'Booking cancelled');
// The row is still there, just with a different status: cancelling doesn't delete (09-02)
cy.contains('[data-testid="fila-reserva"]', 'Electric Pro')
.should('contain.text', 'Cancelled');
cy.contains('[data-testid="fila-reserva"]', 'Electric Pro')
.findByRole('button', { name: /Cancel/ })
.should('not.exist');
});
it('closing the dialog without confirming cancels nothing', () => {
cy.intercept('PATCH', '**/reservas/*').as('cancelBooking');
cy.contains('[data-testid="fila-reserva"]', 'Electric Pro')
.findByRole('button', { name: /Cancel/ })
.click();
cy.findByRole('dialog').findByRole('button', { name: /No, keep it/ }).click();
cy.findByRole('dialog').should('not.exist');
cy.contains('[data-testid="fila-reserva"]', 'Electric Pro').should('contain.text', 'Active');
cy.get('@cancelBooking.all').should('have.length', 0);
});
it('the dialog closes with the Escape key', () => {
cy.contains('[data-testid="fila-reserva"]', 'Electric Pro')
.findByRole('button', { name: /Cancel/ })
.click();
cy.findByRole('dialog').should('be.visible');
cy.get('body').type('{esc}');
cy.findByRole('dialog').should('not.exist');
cy.contains('[data-testid="fila-reserva"]', 'Electric Pro').should('contain.text', 'Active');
});
it('the cancellation persists after a reload and frees the bike', () => {
cy.contains('[data-testid="fila-reserva"]', 'Electric Pro')
.findByRole('button', { name: /Cancel/ })
.click();
cy.findByRole('dialog').findByRole('button', { name: /Yes, cancel/ }).click();
cy.findByRole('status').should('contain.text', 'Booking cancelled');
cy.reload();
cy.contains('[data-testid="fila-reserva"]', 'Electric Pro').should('contain.text', 'Cancelled');
// And bici-002 is available again in the catalogue
cy.visit('/');
cy.cardFor('bici-002').should('contain.text', 'Available');
});
it('warns if the cancellation fails on the server', () => {
cy.intercept('PATCH', '**/reservas/*', { statusCode: 500, body: {} }).as('cancelFailure');
cy.contains('[data-testid="fila-reserva"]', 'Electric Pro')
.findByRole('button', { name: /Cancel/ })
.click();
cy.findByRole('dialog').findByRole('button', { name: /Yes, cancel/ }).click();
cy.wait('@cancelFailure');
cy.findByRole('alert').should('contain.text', 'The booking could not be cancelled');
// The status hasn't changed: no optimistic update was reverted incorrectly
cy.contains('[data-testid="fila-reserva"]', 'Electric Pro').should('contain.text', 'Active');
});
});cy.within() limits queries to inside the element, and it's essential with modals: without it, findByRole('button', { name: /Cancel/ }) would also find the button on the row behind it and fail from ambiguity.
The last test verifies something that only shows up on integration: that if the server rejects the cancellation, the UI reverts to its previous state. It's the classic failure of a badly reverted optimistic update, and it produces the worst possible outcome: the user believes they cancelled and they didn't.
- Debugging a failing test
Cypress is, by a wide margin, the tool with the best debugging in the whole module.
| Tool | How it's used | What for |
|---|---|---|
| Time-travel through steps | Hover over each command in the left panel | See the DOM exactly as it was at that instant. The most useful thing of all |
| Automatic screenshots | cypress/screenshots/ in run mode |
See the exact state of the failure in continuous integration |
| Automatic video | cypress/videos/ with video: true |
Reconstruct the whole run |
cy.pause() |
Inserted into the chain | Stops execution and lets you step through command by command |
.debug() |
cy.get('...').debug() |
Stops and exposes the element in the browser console |
cy.log('…') |
Anywhere | Notes in the command log |
| Browser console | Click a command in the panel | Prints the element, request, or value obtained |
it('debugging', () => {
cy.visit('/');
cy.pause(); // stops here
cy.get('[data-testid="tarjeta-bicicleta"]').debug().first().click();
cy.log('Detail page opened');
});The most frequent errors, and how to read them:
| Message | What it means | Fix |
|---|---|---|
Timed out retrying: Expected to find element |
The element never showed up | Correct selector? Was signing in required? Look at the DOM in the previous step |
element is not visible because it has CSS property: display: none |
It's there but hidden | A panel needs opening, or there's a real visibility bug |
element is being covered by another element |
Covered: the failure that justifies Cypress | Fix the layout, not the test |
element has become detached from the DOM |
The element got remounted between the query and the action | A single query (cy.get('ul li')), not a two-step chain |
cy.wait() timed out waiting for route '@alias' |
The request never happened | Does the URL pattern match? Was it really sent? |
- Continuous integration with GitHub Actions
A suite that only runs when someone remembers to protects nothing. This is the minimal workflow that runs both levels on every change:
# .github/workflows/tests.yml
name: Tests
on:
push:
branches: [main]
pull_request:
jobs:
unit:
name: Unit and component tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Static analysis
run: npm run lint
- name: Vitest with coverage
run: npm run coverage
- name: Publish the coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage
path: coverage/
e2e:
name: End to end
runs-on: ubuntu-latest
needs: unit # if the fast ones fail, no minutes get spent on the slow ones
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Build the app
run: npm run build
- name: Cypress against the production build
uses: cypress-io/github-action@v6
with:
# Starts both servers, waits for them to respond, and runs the tests
start: npm run dev:all
wait-on: 'http://localhost:5173, http://localhost:3001'
wait-on-timeout: 120
browser: chrome
- name: Save screenshots if something fails
uses: actions/upload-artifact@v4
if: failure()
with:
name: cypress-screenshots
path: cypress/screenshots
- name: Save the videos
uses: actions/upload-artifact@v4
if: always()
with:
name: cypress-videos
path: cypress/videosThe decisions that make this workflow useful:
needs: unitorders the jobs by cost: the fast tests first. IfvalidateBookingis broken, there's no point spending three minutes of browser time to find that out.npm ciinstead ofnpm installinstalls exactly what's inpackage-lock.json: reproducible and faster.if: failure()on the screenshots only uploads them when they're needed, and they're the first thing to check when investigating a failure that doesn't reproduce locally.- Headless mode by default.
cypress rundoesn't open a window, which is the only option on a continuous integration server.cypress openis only for development. - The official
cypress-io/github-actionaction already caches the Cypress binary — which weighs hundreds of megabytes — between runs.
And a process recommendation: configure the main branch to require this workflow to pass before merging. A green suite nobody is obligated to respect ends up permanently red.
- Cypress vs. Playwright
Playwright, from Microsoft, is the main alternative. It isn't covered in this course, but it's worth knowing the landscape to choose with good judgment.
| Criterion | Cypress | Playwright |
|---|---|---|
| Execution model | Inside the browser, alongside the app | Outside, controlling the browser over a protocol |
| API | Chain of retrying commands, no await |
Standard async/await |
| Browsers | Chrome, Edge, Firefox, WebKit (experimental) | Chromium, Firefox, and WebKit, all first-class |
| Multiple tabs or domains | Limited by design | Full support |
| Parallelism | Requires Cypress Cloud or custom setup | Native and free |
| Speed | Good | Better, especially in parallel |
| Interactive debugging | Excellent: time travel through steps, historical DOM | Good: trace viewer, very powerful but after the fact |
| Learning curve | Gentle; the command queue is confusing at first | More natural for those who know async/await |
| Automatic waiting | Yes | Yes |
| Test generation | No | codegen records the interaction and writes the test |
| Ecosystem maturity | Very high, many plugins | High and growing fast |
| Component testing | Yes (experimental) | Yes (experimental) |
Choice criteria:
- Choose Cypress if the team is starting out with e2e testing and values the debugging experience — the time-travel through steps is hard to beat —, if the app is single-domain and single-tab, or if the plugin ecosystem matters.
- Choose Playwright if you need real cross-browser coverage, if the flow crosses domains or tabs (payment gateways, external OAuth), if free parallelism matters for continuous integration time, or if the team prefers
async/await. - Neither choice is irreversible, and both share the concepts that really matter: stable selectors, isolation between tests, network control, and waiting for a result. What's learned here transfers almost entirely.
For CicloUrbano, Cypress is the choice: single domain, single tab, a team just starting out, and a clear need to diagnose quickly.
- The module's full strategy
flowchart TB
subgraph E2E["🌐 End to end · Cypress · 3 flows · seconds"]
E1["Signing in: redirect, persistence, roles, back button"]
E2["Booking: catalogue → detail → form → API → list → reload"]
E3["Cancelling: modal, PATCH, status, bike freed"]
end
subgraph INT["🔗 Integration · RTL + MSW · the majority · tens of ms"]
I1["CataloguePage: loading, success, 500 error, empty list, retry"]
I2["BookingForm: errors, aria-describedby, happy path"]
I3["Mutations: body sent, invalidation, refresh"]
I4["BikeCard · TypeSelector · StatusBadge"]
I5["Hooks: useToggle, useDebounce, useLocalStorage"]
I6["Routes: params, ?tipo=, navigation, RequireRole"]
end
subgraph UNI["🧪 Unit · Vitest · pure logic · milliseconds"]
U1["validateBooking: 4 rules and every edge case"]
U2["bookingsSlice: lifecycle and business guards"]
U3["Selectors: derivation and memoization"]
U4["classNames · debounce · availability"]
end
subgraph EST["⚡ Static · ESLint · continuous · instant"]
S1["react-hooks: dependencies and the rules of hooks"]
S2["jsx-a11y: accessibility as you type"]
S3["no-focused-tests: no forgotten test.only"]
end
EST --> UNI --> INT --> E2E
| Level | How many | What it protects | When it runs |
|---|---|---|---|
| Static | Continuous | Typos, misused hooks, basic accessibility | While typing and in the CI workflow |
| Unit | Dozens | Business rules and edge cases | On every save (watch mode) |
| Integration | The majority | The wiring between pieces and the UI states | On every save and in CI |
| End to end | Three flows | That the whole app really works | In CI, and before deploying |
And the final check: go back to the nine refactors from module 8. Today, changing BikeList's signature, moving state, rewriting a context's value, or splitting routes into chunks produces an answer in seconds — green or red — instead of a session of clicking around blindly. That was exactly the gap this module came to fill.
Common Mistakes and Tips
- Trying to use
awaitwith Cypress commands. They aren't promises: they're entries in a queue. To work with a value, use.then(). - Storing the result of
cy.getin a variable. It doesn't contain an element. If you need to reuse a query, use.as('alias')and thency.get('@alias'). cy.wait(3000)to "give it time." Always slow and sometimes not enough. Automatic waiting already covers that case; and if you need to wait for a specific request, usecy.wait('@alias').- Chaining queries over lists that get remounted. Produces "element detached from DOM." A single query:
cy.get('ul li'), notcy.get('ul').find('li'). - Signing in through the UI in every test. Multiplies the time and couples every test to the sign-in form. Use
cy.sessionwith itsvalidate. - Not resetting the database between tests. The result starts depending on order, and the hardest kind of flakiness to diagnose shows up. Call
cy.seedData()inbeforeEach. - Simulating the whole network in e2e tests. If you simulate the happy path, the test stops adding anything that 09-04 didn't already give you. The happy path runs against the real API; the alternative paths get simulated.
- Writing twenty e2e tests. The suite becomes slow and flaky and the team ends up disabling it. The critical flows and nothing more; everything else, one level down.
- Using nearby dates in test data. A 2026 date in a rule that rejects the past turns the test into a time bomb. Use dates far in the future, or a controlled clock.
- Tip: when a test fails, look at the DOM from the previous step first. The time-travel through steps usually gives the answer before any other tool, and it often reveals that the failure was two commands back.
- Tip: write
data-testids alongside the component, not when you write the test. That way the anchor is part of the component's design, not a later patch. - Tip: one e2e test per flow, not per assertion. These tests are expensive to spin up; walking through a whole flow and checking things along the way makes much better use of that cost than ten tests that each visit the same page.
Exercises
Exercise 1. Write the end-to-end test for the operator flow: Marc Solé (usr-02) signs in, goes to /taller, marks bici-001 as "in maintenance," and checks that the public catalogue no longer allows booking that bike. Use cy.signInAs, seed the data, and verify persistence with a reload. State which requests you'd observe with cy.intercept and which you wouldn't.
Exercise 2. This test fails intermittently in continuous integration and always passes locally. Identify five problems and rewrite it.
it('booking', () => {
cy.visit('http://localhost:5173/reservas/nueva');
cy.get('.form_a3f9 > div:nth-child(2) > select').select('bici-001');
cy.wait(2000);
cy.get('.submit-button').click();
cy.wait(3000);
cy.get('.notice').should('contain', 'created');
});Exercise 3. The team is debating whether to test the bike type filter (?tipo=electrica) with Cypress, which already has tests in 09-04 with MSW. Argue, using the decision criteria from section 2, whether it deserves an e2e test, and propose what should actually be added instead. Then describe how you'd check with Cypress something that MSW cannot: that clicking "Electric" changes the URL, that the view survives a reload, and that the back button returns to the previous filter.
Solutions
Solution 1.
// cypress/e2e/workshop.cy.js
describe('Operator flow', () => {
beforeEach(() => {
cy.seedData();
cy.signInAs('usr-02'); // Marc Solé, operator role
});
it('putting a bike into maintenance removes it from the public catalogue', () => {
// The update is observed to assert on the body; it is NOT replaced:
// the happy path has to actually reach json-server
cy.intercept('PATCH', '**/bicicletas/bici-001').as('updateBike');
cy.visit('/taller');
cy.findByRole('heading', { name: /Workshop/ }).should('be.visible');
cy.cardFor('bici-001').should('contain.text', 'Available');
cy.cardFor('bici-001')
.findByRole('button', { name: /Send to maintenance/ })
.click();
cy.wait('@updateBike').then(({ request, response }) => {
expect(request.body).to.include({ status: 'mantenimiento' });
expect(response.statusCode).to.eq(200);
});
cy.findByRole('status').should('contain.text', 'Bike sent to maintenance');
cy.cardFor('bici-001').should('contain.text', 'In maintenance');
// The effect on the public catalogue
cy.visit('/');
cy.cardFor('bici-001')
.should('contain.text', 'In maintenance')
.findByRole('button', { name: 'Book' })
.should('be.disabled');
// And it persists: the change reached the server
cy.reload();
cy.cardFor('bici-001').findByRole('button', { name: 'Book' }).should('be.disabled');
});
it('a customer does not see the workshop link and cannot enter', () => {
cy.signInAs('usr-01');
cy.visit('/');
cy.findByRole('navigation').findByRole('link', { name: /Workshop/ }).should('not.exist');
cy.visit('/taller');
cy.url().should('include', '/sin-permisos');
});
});What gets observed and what doesn't:
| Request | cy.intercept? |
Why |
|---|---|---|
PATCH /bicicletas/bici-001 |
Yes, observe with an alias | Need to assert on the body (status: 'mantenimiento') and wait for it to finish |
GET /bicicletas from the catalogue |
No | cy.get's automatic waiting already covers the load; intercepting it would just add noise |
GET /estaciones |
No | It's a detail of the screen, not of the flow being tested |
| The sign-in session | No | cy.session handles it outside this test |
And the response is never replaced in any case: the whole point of this test is precisely to verify that the change reaches the database and shows up on another screen.
Solution 2. The five problems:
- An absolute URL in
cy.visit. It ignores the configuration'sbaseUrland breaks the test in any environment where the port is different. It should becy.visit('/reservas/nueva'). - Selectors by CSS Modules class and by DOM structure.
.form_a3f9is a generated hash that changes on every build, and> div:nth-child(2) > selectbreaks with any layout tweak. It's the most likely cause of the CI failure, where the build differs from the one in development. cy.wait(2000)andcy.wait(3000). Fixed waits: slow locally, insufficient on a loaded continuous integration container. They're the direct cause of the flakiness, and unnecessary besides, because the commands already retry.- No signing in and no seeding the data. Without a session,
/reservas/nuevais protected and redirects to/acceso; and without known data, the result depends on what other tests left behind. - The form isn't filled in completely. Only the bike is chosen: the date, the hours, and the terms are missing, so validation would reject it and the "created" notice would never arrive. The name
it('booking')doesn't say what behaviour is being verified either.
Rewritten:
describe('Creating a booking', () => {
beforeEach(() => {
cy.seedData();
cy.signInAs('usr-01');
});
it('creates the booking and confirms the outcome to the user', () => {
cy.intercept('POST', '**/reservas').as('createBooking');
cy.visit('/reservas/nueva');
cy.findByLabelText('Bike').select('bici-001');
cy.findByLabelText('Booking start').type('2030-06-01T10:00');
cy.findByLabelText('Duration (hours)').clear().type('2');
cy.findByRole('checkbox', { name: /I accept the terms/ }).check();
cy.findByRole('button', { name: 'Create booking' }).click();
cy.wait('@createBooking').its('response.statusCode').should('eq', 201);
cy.findByRole('status').should('contain.text', 'Booking created');
cy.url().should('include', '/reservas');
});
});Solution 3. It doesn't deserve an e2e test, and the criteria from section 2 say so in three ways: it isn't on the path of the money (filtering doesn't create bookings), it can be verified just as well with an integration test — and in fact it already is, in 09-04, verifying the chain URL → useSearchParams → query key → request → list —, and its failure wouldn't be catastrophic: the user would see more bikes than they asked for, not lose money or data.
What should be added instead: nothing new at the e2e level. If there's testing budget to spare, it's much more worthwhile to reinforce the booking flow — for example, booking from the detail page of a bike reached with the filter applied, checking that the filter isn't lost when going back — because that really does cross several screens and no other layer covers it.
That said, there are three things about the filter that MSW cannot check, and that would justify a few lines inside an existing e2e test:
it('the filter lives in the URL and survives browser navigation', () => {
cy.visit('/');
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 5);
// 1) Clicking the filter changes the browser's REAL URL
cy.findByRole('button', { name: 'Electric' }).click();
cy.url().should('include', '?tipo=electrica');
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 2);
// 2) The view survives a full page reload
cy.reload();
cy.url().should('include', '?tipo=electrica');
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 2);
cy.findByRole('button', { name: 'Electric', pressed: true }).should('exist');
// 3) The browser's back button returns to the previous filter
cy.findByRole('button', { name: 'Cargo' }).click();
cy.url().should('include', '?tipo=carga');
cy.go('back');
cy.url().should('include', '?tipo=electrica');
cy.get('[data-testid="tarjeta-bicicleta"]').should('have.length', 2);
});All three checks are impossible with createMemoryRouter: the browser URL doesn't change, there's no reload, and the history isn't the browser's. That's exactly why module 6 argued that putting the filter in the URL made it shareable and navigable; this test is what verifies that promise. It's a good example of the general criterion: a case doesn't get promoted to e2e because it's important, but because that's the only place it can be checked.
Conclusion
This lesson closes Module 9, and CicloUrbano goes from having no safety net at all to having four deliberately overlapping levels.
The essentials of Cypress. An end-to-end test sees what none of the others see: real routing with its URL, its reload, and its back button; real CSS and visibility, including a button covered by another element; session persistence across navigations; a real network against json-server; and several chained screens running the same build that gets deployed. In exchange it costs seconds per test, it's the layer most prone to flakiness, and it demands infrastructure, so the selection criteria are strict: path of the money, several layers crossed, a catastrophic and invisible failure, and nothing that could be checked just as well one level down. In CicloUrbano that's exactly three flows: signing in, booking, and cancelling.
On how it works, what needs to sink in is that cy.get(...) doesn't return an element and await is never used: the body of the it only queues commands, and Cypress runs them afterward, in order, waiting for each one. To work with a value, use .then(). And every query and every should retry until they find what they're looking for, which wipes out 09-04's explicit waits at the root — with the catch that retries apply to the last command in the chain, hence the "element detached from DOM" error and the rule of using a single query. Action commands, on top of that, require the element to be actionable, and that's where the detection of the covered button comes from.
The selectors shift criteria compared to 09-03: data-testid stops being the last resort and becomes the explicit contract for structural anchors that must survive redesigns, combined with Testing Library's findByRole for interactive elements. What's now fixed: cypress.config.js with its baseUrl; cypress/support/commands.js with cy.byTestId, cy.cardFor, cy.seedData, and cy.signInAs; and the three files cypress/e2e/sign-in.cy.js, booking.cy.js, and cancel.cy.js. Startup is handled by start-server-and-test, which polls Vite and json-server until they respond before launching anything.
On the network, the rule is clear: the happy path runs against the real API — if you simulate it, the e2e test adds nothing that 09-04 didn't already give you — and the alternative paths get simulated with cy.intercept, which also lets you observe a request with an alias to assert on its body and status code without replacing it. Isolation rests on three pieces: db.json, resettable from a seed with cy.request; automatic browser isolation between tests; and cy.session with its validate block, which runs the sign-in once and restores it afterward. And debugging — time-travel through steps with the historical DOM, automatic screenshots and video, cy.pause, and .debug() — is the best in the whole module.
With GitHub Actions, both levels run on every change, ordered by cost: linter and Vitest first, Cypress after with needs, screenshots uploaded only when something fails, and the binary cached by the official action. And you now know how to place Playwright on the map: better for multiple browsers, multiple tabs, crossed domains, and free parallelism; Cypress, better for starting out and diagnosing quickly in a single-domain app, which is CicloUrbano's case. The concepts that matter — stable selectors, isolation, network control, waiting for a result — transfer almost entirely between the two.
This closes Module 9. The gap left by module 8 is covered: the nine refactors that could once only be checked by opening the browser now produce an answer in seconds. The full strategy has four levels, and each does what the others can't: static analysis catches the badly declared hook as you type; the unit tests cover validateBooking with every edge case, bookingsSlice's lifecycle and guards, and the selectors' memoization, all without mounting a single component; the integration tests — the bulk of the trophy — test components the way a person uses them, resting on module 3's accessibility, with the project's own renderWithProviders wrapping the real providers and MSW simulating the network to verify loading, success, error, empty list, and retry; and the three end-to-end tests confirm that the whole app really works, with its URL, its session, its database, and its browser. The principle governing all of them is still the one from the first lesson: test visible behaviour, not implementation, so that tests fail when something breaks, and only then.
What comes next changes terrain completely. Up to this point, CicloUrbano has been an app that downloads to the browser and runs there: fast to develop, but with a first paint that waits on JavaScript and content that search engines only half see. Module 10: Advanced Topics attacks exactly that boundary, and the ones around it. You'll see server-side rendering (SSR) with Next.js, where the HTML arrives already built; static site generation (SSG), for content that doesn't change on every visit; Suspense and React Server Components, the model that redefines where each component runs and that reuses everything you know about lazy and loading states; TypeScript with React, which turns into compile-time errors a good part of what today only tests catch — and which, after this module, you'll understand in its exact place in the pyramid: static analysis, the cheapest layer —; and React Native, to carry what you've learned into native mobile apps. The next lesson is Server-Side Rendering (SSR) with Next.js.
React Course
Module 1: Getting Started with React
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
