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

  1. What an end-to-end test sees that the others don't
  2. What it costs, and which flows are worth that cost
  3. Installation and project structure
  4. Starting the app and the API before the tests
  5. Anatomy of a Cypress test
  6. The asynchronous, retrying nature: why await isn't used
  7. Robust selectors: the data-testid contract
  8. Assertions with should and chaining
  9. Controlling the network with cy.intercept
  10. Initial state and isolation between tests
  11. cy.session: not repeating sign-in
  12. Flow 1: signing in
  13. Flow 2: booking a bike
  14. Flow 3: cancelling a booking
  15. Debugging a failing test
  16. Continuous integration with GitHub Actions
  17. Cypress vs. Playwright
  18. The module's full strategy

  1. 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.

  1. 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:

  1. Signing in. Without this, nothing else matters. It crosses the form, Redux, the redirect, and persistence.
  2. Booking a bike. It's the path of the money: catalogue, filter, detail page, form, mutation, invalidation, and navigation.
  3. 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).

  1. Installation and project structure

npm install -D cypress
npx cypress open        # the first run scaffolds the folder 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.
npm install -D @testing-library/cypress

  1. 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:

npm install -D start-server-and-test npm-run-all
{
  "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.

  1. 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

  1. The asynchronous, retrying nature: why await isn't used

This 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.

  1. Robust selectors: the data-testid contract

In 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.

  1. Assertions with should and chaining

Cypress 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.

  1. Controlling the network with cy.intercept

cy.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.

  1. 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);
    });
  });
});
beforeEach(() => {
  cy.seedData();
});

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.

  1. cy.session: not repeating sign-in

Signing 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.

  1. 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.

  1. 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. validateBooking rejects 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.

  1. 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.

  1. 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?

  1. 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/videos

The decisions that make this workflow useful:

  • needs: unit orders the jobs by cost: the fast tests first. If validateBooking is broken, there's no point spending three minutes of browser time to find that out.
  • npm ci instead of npm install installs exactly what's in package-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 run doesn't open a window, which is the only option on a continuous integration server. cypress open is only for development.
  • The official cypress-io/github-action action 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.

  1. 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.

  1. 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 await with Cypress commands. They aren't promises: they're entries in a queue. To work with a value, use .then().
  • Storing the result of cy.get in a variable. It doesn't contain an element. If you need to reuse a query, use .as('alias') and then cy.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, use cy.wait('@alias').
  • Chaining queries over lists that get remounted. Produces "element detached from DOM." A single query: cy.get('ul li'), not cy.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.session with its validate.
  • 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() in beforeEach.
  • 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:

  1. An absolute URL in cy.visit. It ignores the configuration's baseUrl and breaks the test in any environment where the port is different. It should be cy.visit('/reservas/nueva').
  2. Selectors by CSS Modules class and by DOM structure. .form_a3f9 is a generated hash that changes on every build, and > div:nth-child(2) > select breaks with any layout tweak. It's the most likely cause of the CI failure, where the build differs from the one in development.
  3. cy.wait(2000) and cy.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.
  4. No signing in and no seeding the data. Without a session, /reservas/nueva is protected and redirects to /acceso; and without known data, the result depends on what other tests left behind.
  5. 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

Module 2: React Components

Module 3: Working with Events

Module 4: Advanced Component Concepts

Module 5: React Hooks

Module 6: Routing in React

Module 7: State Management

Module 8: Performance Optimization

Module 9: Testing React Applications

Module 10: Advanced Topics

Module 11: Project: Building a Complete Application

© Copyright 2026. All rights reserved