In the previous lesson CicloUrbano ended up working end to end, and the only check was a manual twenty-step walkthrough with the network tab open. That walkthrough proved the application works today. It proves nothing about tomorrow: the moment someone touches a line inside a mutation's onSettled, nobody is going to repeat the twenty steps, and the failure will reach the user before it reaches us. This lesson turns that manual check into an automated safety net, applying to the project's real code the strategy studied in Module 9. It doesn't re-explain how Testing Library or MSW work: here we decide what gets tested, at which level, and why, and we write the project's full suite.

Contents

  1. The test plan: each story at its level
  2. What we decide not to test, and why
  3. Project-ready configuration
  4. The MSW handlers for the CicloUrbano domain
  5. Unit tests: validateBooking
  6. Unit tests: the bookings reducer and selectors
  7. Unit tests: the custom hooks
  8. Component tests: BikeCard, TypeSelector, and BookingForm
  9. Integration tests: whole pages with MSW
  10. End-to-end tests with Cypress
  11. Coverage: reading the report with judgment
  12. Continuous integration
  13. The guided regression: what each level catches

  1. The Test Plan: Each Story at Its Level

The plan isn't improvised file by file: it starts from the eight user stories written during the project planning and decides, for each one, which level covers it. A well-covered story doesn't need to be tested at all five levels; it needs to be tested at the cheapest level that catches its characteristic failure.

Story Static Unit Component Integration E2E
H1 View the catalogue Linter and exhaustive-deps BikeCard renders model, type, status, and price Yes: skeleton → list → 500 error with retry Included in the booking flow
H2 Filter and search useDebounce TypeSelector notifies of the change Yes: ?tipo=electrica filters the rendered list
H3 Bike detail page Yes: nonexistent id → custom 404
H4 Sign in useLocalStorage SignInForm validates Returns to the originating screen Yes: sign-in.cy.js
H5 Book Yes: full validateBooking BookingForm shows accessible errors Yes: request body and redirect Yes: booking.cy.js
H6 View and cancel bookings Reducer and selectors of bookingsSlice BookingsPanel asks for confirmation Yes: optimistic update + invalidation Yes: cancel.cy.js
H7 Operator changes status Yes: customer at /taller → forbidden Exercise
H8 Stations and fleet StationCard Yes: active tab reflected in the URL

Notice the pattern: the integration column is nearly full and the e2e column is nearly empty. That's exactly the split the testing trophy recommends. Integration tests exercise a whole screen against a mock network, which is where most of this application's real failures live (loading states, cache invalidation, network errors, permissions), and they cost a fraction of what an e2e test costs.

  1. What We Decide Not to Test, and Why

Writing down what doesn't get tested matters as much as writing down what does, because it heads off the endless argument and the guilty conscience.

Not tested Why
D1 Light/dark theme Its failure is visual and obvious at a glance; a test would only check that an attribute changes value
D2 Global notices They're checked in passing by the booking integration tests, where the success notice is part of the assertion
D3 Connection-loss notice It depends on a browser event that's hard to simulate faithfully; the cost outweighs the risk
D4 Fleet summary It's a trivial derived calculation; if it breaks, it shows on screen
CSS class names and layout They change with every design tweak; testing them produces brittle tests that don't catch any functional failure
Render count It's implementation, not behavior. That's what the Profiler is for
The third-party library TanStack Query already has its own tests; we test our use of it

  1. Project-Ready Configuration

The infrastructure is the one set up in Module 9, now settled into the project. First, the test section of vite.config.js:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    // jsdom gives Node a DOM: there's a document, but no real layout or real navigation
    environment: 'jsdom',
    // globals: true lets you use describe/test/expect without importing them in every file
    globals: true,
    // This file runs before every test file
    setupFiles: './src/tests/setup.js',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      // There's no point measuring coverage of things that aren't logic
      exclude: ['src/tests/**', 'src/main.jsx', '**/*.module.css'],
    },
  },
});

The setup file does four things, and each one avoids a whole class of contaminated tests:

// src/tests/setup.js
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './server.js';

// 1. Starts the mock network. onUnhandledRequest: 'error' is the key decision:
//    if a test requests a URL with no handler, it fails instead of hanging.
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));

afterEach(() => {
  cleanup();               // 2. Unmounts what was rendered
  server.resetHandlers();  // 3. Undoes the server.use() calls from the previous test
  localStorage.clear();    // 4. One test's session doesn't leak into the next
});

afterAll(() => server.close());

And the renderWithProviders utility, the piece that makes every test in the project readable: it mounts the component with the real providers, in the same order as main.jsx.

// src/tests/utils.jsx
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createMemoryRouter, RouterProvider } from 'react-router';
import { sessionSlice } from '../features/session/sessionSlice.js';
import { catalogueSlice } from '../features/catalogue/catalogueSlice.js';
import { bookingsSlice } from '../features/bookings/bookingsSlice.js';
import ThemeProvider from '../contexts/ThemeProvider.jsx';
import NoticesProvider from '../contexts/NoticesProvider.jsx';

export function createTestStore(initialState) {
  return configureStore({
    reducer: {
      session: sessionSlice.reducer,
      catalogue: catalogueSlice.reducer,
      bookings: bookingsSlice.reducer,
    },
    preloadedState: initialState,
  });
}

export function createTestClient() {
  return new QueryClient({
    defaultOptions: {
      // No retries: an error test shouldn't have to wait through three failed attempts
      queries: { retry: false, gcTime: Infinity },
      mutations: { retry: false },
    },
  });
}

export function renderWithProviders(element, options = {}) {
  const {
    initialState,
    store = createTestStore(initialState),
    client = createTestClient(),
    route = '/',
    routes = [{ path: '*', element }],
  } = options;

  const router = createMemoryRouter(routes, { initialEntries: [route] });

  const result = render(
    <QueryClientProvider client={client}>
      <Provider store={store}>
        <ThemeProvider>
          <NoticesProvider>
            <RouterProvider router={router} />
          </NoticesProvider>
        </ThemeProvider>
      </Provider>
    </QueryClientProvider>
  );

  return { ...result, store, client, router, user: userEvent.setup() };
}

export * from '@testing-library/react';
export { userEvent };

Each test receives a new store and client, and therefore an empty cache. Sharing them between tests is the number one cause of tests that pass in isolation and fail together.

  1. The MSW Handlers for the CicloUrbano Domain

The handlers replicate the behavior of json-server, including filtering by query parameters, because the production code depends on it.

// src/tests/handlers.js
import { http, HttpResponse } from 'msw';

const API = 'http://localhost:3001';

export const BIKES = [
  { 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 },
];

export const STATIONS = [
  { 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 },
];

export const USERS = [
  { id: 'usr-01', name: 'Ana Ribera', email: '[email protected]', role: 'cliente' },
  { id: 'usr-02', name: 'Marc Solé', email: '[email protected]', role: 'operario' },
];

export const BOOKINGS = [
  { id: 'res-01', bicicletaId: 'bici-002', user: 'usr-01', startDate: '2026-05-04T09:00', hours: 2, status: 'activa' },
];

export const handlers = [
  http.get(`${API}/bicicletas`, ({ request }) => {
    const params = new URL(request.url).searchParams;
    const type = params.get('tipo');
    const stationId = params.get('stationId');
    let result = BIKES;
    if (type) result = result.filter((b) => b.type === type);
    if (stationId) result = result.filter((b) => b.stationId === stationId);
    return HttpResponse.json(result);
  }),

  http.get(`${API}/bicicletas/:bicicletaId`, ({ params }) => {
    const bike = BIKES.find((b) => b.id === params.bicicletaId);
    // We reproduce json-server's real 404: the custom-404 test depends on this
    if (!bike) return new HttpResponse(null, { status: 404 });
    return HttpResponse.json(bike);
  }),

  http.patch(`${API}/bicicletas/:bicicletaId`, async ({ params, request }) => {
    const changes = await request.json();
    const bike = BIKES.find((b) => b.id === params.bicicletaId);
    return HttpResponse.json({ ...bike, ...changes });
  }),

  http.get(`${API}/estaciones`, () => HttpResponse.json(STATIONS)),

  http.get(`${API}/estaciones/:estacionId`, ({ params }) => {
    const station = STATIONS.find((e) => e.id === params.estacionId);
    if (!station) return new HttpResponse(null, { status: 404 });
    return HttpResponse.json(station);
  }),

  http.get(`${API}/usuarios`, () => HttpResponse.json(USERS)),

  http.get(`${API}/reservas`, ({ request }) => {
    const user = new URL(request.url).searchParams.get('user');
    const result = user ? BOOKINGS.filter((r) => r.user === user) : BOOKINGS;
    return HttpResponse.json(result);
  }),

  http.post(`${API}/reservas`, async ({ request }) => {
    const newBooking = await request.json();
    return HttpResponse.json({ ...newBooking, id: 'res-nueva' }, { status: 201 });
  }),

  http.patch(`${API}/reservas/:reservaId`, async ({ params, request }) => {
    const changes = await request.json();
    const booking = BOOKINGS.find((r) => r.id === params.reservaId);
    return HttpResponse.json({ ...booking, ...changes });
  }),
];
// src/tests/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers.js';

export const server = setupServer(...handlers);

  1. Unit Tests: validateBooking

validateBooking is a pure function with five rules, making it the place where a table of cases pays off most per line written. No React, DOM, or network needed here.

// src/utils/validateBooking.test.js
import { describe, test, expect } from 'vitest';
import { validateBooking } from './validateBooking.js';
import { BIKES } from '../tests/handlers.js';

// One clearly future date and one clearly past date, fixed so the test
// doesn't depend on the clock of the day it runs.
const FUTURE = '2027-01-15T10:00';
const PAST = '2020-01-15T10:00';

const VALID = {
  bicicletaId: 'bici-001',
  startDate: FUTURE,
  hours: 2,
  terms: true,
};

describe('validateBooking', () => {
  test('returns no errors with valid data', () => {
    expect(validateBooking(VALID, BIKES)).toEqual({});
  });

  test.each([
    ['no bike selected',        { bicicletaId: '' },                 'bicicletaId'],
    ['nonexistent bike',        { bicicletaId: 'bici-999' },         'bicicletaId'],
    ['bike already rented',     { bicicletaId: 'bici-002' },         'bicicletaId'],
    ['bike under maintenance',  { bicicletaId: 'bici-003' },         'bicicletaId'],
    ['empty date',               { startDate: '' },                  'startDate'],
    ['date in the past',         { startDate: PAST },                'startDate'],
    ['zero hours',                { hours: 0 },                       'hours'],
    ['hours above a full day',   { hours: 25 },                       'hours'],
    ['non-numeric hours',        { hours: 'two' },                    'hours'],
    ['terms not accepted',       { terms: false },                   'terms'],
  ])('flags an error for %s', (_description, changes, expectedField) => {
    const errors = validateBooking({ ...VALID, ...changes }, BIKES);
    // We check WHICH field fails, not the exact wording of the message:
    // the text is copy and will change; the field is the contract.
    expect(errors).toHaveProperty(expectedField);
  });

  test('accepts the valid extremes of the hours range', () => {
    expect(validateBooking({ ...VALID, hours: 1 }, BIKES)).toEqual({});
    expect(validateBooking({ ...VALID, hours: 24 }, BIKES)).toEqual({});
  });

  test('accumulates every error, not just the first one', () => {
    const errors = validateBooking(
      { bicicletaId: '', startDate: PAST, hours: 0, terms: false },
      BIKES
    );
    expect(Object.keys(errors)).toHaveLength(4);
  });
});

The last two tests deliver the most value and are the ones most often forgotten. The range extremes (1 and 24) document that the interval is closed: if someone swaps <= for <, the test catches it. And the error accumulation protects an interface design decision: the form shows every error at once, so an implementation that returned on the first failure would break the screen without breaking any other test.

  1. Unit Tests: the Bookings Reducer and Selectors

A reducer is a pure function (state, action) => state, so it's tested by dispatching actions against a known state, without mounting anything.

// src/features/bookings/bookingsSlice.test.js
import { describe, test, expect } from 'vitest';
import {
  bookingsSlice,
  bookingCreated,
  bookingCancelled,
  selectUserBookings,
  selectBookingsSummary,
} from './bookingsSlice.js';

const { reducer } = bookingsSlice;

const STATE_WITH_ONE = {
  entities: {
    'res-01': { id: 'res-01', bicicletaId: 'bici-002', user: 'usr-01', hours: 2, status: 'activa' },
  },
  ids: ['res-01'],
  submitState: 'idle',
  error: null,
};

describe('bookings reducer', () => {
  test('returns the initial state for an unknown action', () => {
    const state = reducer(undefined, { type: 'action/unknown' });
    expect(state.ids).toEqual([]);
  });

  test('appends the created booking at the end and does not mutate the previous state', () => {
    const newBooking = { id: 'res-02', bicicletaId: 'bici-001', user: 'usr-01', hours: 3, status: 'activa' };
    const next = reducer(STATE_WITH_ONE, bookingCreated(newBooking));

    expect(next.ids).toEqual(['res-01', 'res-02']);
    expect(next.entities['res-02'].hours).toBe(3);
    // Immer lets us write as if we were mutating, but the previous state must stay intact
    expect(STATE_WITH_ONE.ids).toEqual(['res-01']);
  });

  test('cancelling changes the booking status without removing it', () => {
    const next = reducer(STATE_WITH_ONE, bookingCancelled({ id: 'res-01' }));
    expect(next.entities['res-01'].status).toBe('cancelada');
    expect(next.ids).toHaveLength(1);
  });
});

describe('bookings selectors', () => {
  const rootState = { bookings: STATE_WITH_ONE };

  test('filters by user', () => {
    expect(selectUserBookings(rootState, 'usr-01')).toHaveLength(1);
    expect(selectUserBookings(rootState, 'usr-02')).toHaveLength(0);
  });

  test('the summary counts by status', () => {
    expect(selectBookingsSummary(rootState)).toEqual({
      active: 1, confirmed: 0, cancelled: 0,
    });
  });
});

The assertion expect(STATE_WITH_ONE.ids).toEqual(['res-01']) deserves a comment: it checks that Immer is doing its job. It's the test that catches the bug of writing a reducer that genuinely mutates because someone pulled it out of createSlice into a helper function.

  1. Unit Tests: Custom Hooks

Hooks need a component to run them, and that's what renderHook is for. useDebounce also needs control over time.

// src/hooks/useDebounce.test.js
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce.js';

describe('useDebounce', () => {
  beforeEach(() => vi.useFakeTimers());
  afterEach(() => vi.useRealTimers());

  test('returns the initial value immediately', () => {
    const { result } = renderHook(() => useDebounce('urbana', 300));
    expect(result.current).toBe('urbana');
  });

  test('does not update before the delay and does after', () => {
    const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), {
      initialProps: { value: 'urb' },
    });

    rerender({ value: 'urbana' });
    // Just before the threshold, the old value still holds
    act(() => vi.advanceTimersByTime(299));
    expect(result.current).toBe('urb');

    act(() => vi.advanceTimersByTime(1));
    expect(result.current).toBe('urbana');
  });

  test('only applies the last value from a burst of changes', () => {
    const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), {
      initialProps: { value: 'u' },
    });

    rerender({ value: 'ur' });
    act(() => vi.advanceTimersByTime(100));
    rerender({ value: 'urb' });
    act(() => vi.advanceTimersByTime(100));
    rerender({ value: 'urbana' });
    act(() => vi.advanceTimersByTime(300));

    expect(result.current).toBe('urbana');
  });
});
// src/hooks/useLocalStorage.test.js
import { describe, test, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage.js';

describe('useLocalStorage', () => {
  test('uses the default value when the key does not exist', () => {
    const { result } = renderHook(() => useLocalStorage('ciclourbano:tema', 'light'));
    expect(result.current[0]).toBe('light');
  });

  test('persists the value and retrieves it on a later mount', () => {
    const first = renderHook(() => useLocalStorage('ciclourbano:tema', 'light'));
    act(() => first.result.current[1]('dark'));
    first.unmount();

    const second = renderHook(() => useLocalStorage('ciclourbano:tema', 'light'));
    expect(second.result.current[0]).toBe('dark');
  });

  test('does not crash if the stored content is not valid JSON', () => {
    localStorage.setItem('ciclourbano:tema', '{broken');
    const { result } = renderHook(() => useLocalStorage('ciclourbano:tema', 'light'));
    expect(result.current[0]).toBe('light');
  });
});

That last test is what justifies the hook's try/catch. Without it, nobody would remember why it's there, and someone would delete it in the first cleanup pass.

  1. Component Tests: Behavior, Never Implementation

With components the question changes: it's no longer "what does this function return" but "what can the person using this see and do". Role-based queries, explained in React Testing Library, are the direct translation of the accessibility work from lesson 03-06.

// src/components/BikeCard.test.jsx
import { describe, test, expect, vi } from 'vitest';
import { renderWithProviders, screen } from '../tests/utils.jsx';
import BikeCard from './BikeCard.jsx';

function bike(id, model, type, status, pricePerHour) {
  return { id, model, type, status, stationId: 'est-01', pricePerHour };
}

const AVAILABLE = bike('bici-001', 'Classic Urban', 'urbana', 'disponible', 2.5);
const IN_MAINTENANCE = bike('bici-003', 'Cargo Max', 'carga', 'mantenimiento', 5.5);

describe('BikeCard', () => {
  test('shows model, status and price per hour', () => {
    renderWithProviders(<BikeCard bike={AVAILABLE} stationName="Main Square" />);

    expect(screen.getByRole('heading', { name: 'Classic Urban' })).toBeInTheDocument();
    expect(screen.getByText('Available')).toBeInTheDocument();
    expect(screen.getByText(/2\.50/)).toBeInTheDocument();
    expect(screen.getByText('Main Square')).toBeInTheDocument();
  });

  test('offers the book button only if the bike is available', () => {
    const { unmount } = renderWithProviders(<BikeCard bike={AVAILABLE} stationName="Main Square" />);
    expect(screen.getByRole('button', { name: /book/i })).toBeInTheDocument();
    unmount();

    renderWithProviders(<BikeCard bike={IN_MAINTENANCE} stationName="North Park" />);
    expect(screen.queryByRole('button', { name: /book/i })).not.toBeInTheDocument();
  });

  test('notifies the parent with the clicked bike', async () => {
    const onBook = vi.fn();
    const { user } = renderWithProviders(
      <BikeCard bike={AVAILABLE} stationName="Main Square" onBook={onBook} />
    );

    await user.click(screen.getByRole('button', { name: /book/i }));

    expect(onBook).toHaveBeenCalledTimes(1);
    expect(onBook).toHaveBeenCalledWith('bici-001');
  });

  test('does not convey status through color alone', () => {
    renderWithProviders(<BikeCard bike={IN_MAINTENANCE} stationName="North Park" />);
    // The status text must exist, not just the CSS class that paints it
    expect(screen.getByText('In maintenance')).toBeInTheDocument();
  });
});

TypeSelector is a controlled component, and that determines what gets tested: that it notifies, not that it changes internally. Its state no longer exists.

// src/components/TypeSelector.test.jsx
import { describe, test, expect, vi } from 'vitest';
import { renderWithProviders, screen } from '../tests/utils.jsx';
import TypeSelector from './TypeSelector.jsx';

describe('TypeSelector', () => {
  test('marks the type received via props as active', () => {
    renderWithProviders(<TypeSelector selectedType="electrica" onTypeChange={vi.fn()} />);

    expect(screen.getByRole('button', { name: 'Electric' })).toHaveAttribute('aria-pressed', 'true');
    expect(screen.getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'false');
  });

  test('notifies the clicked type and does not decide anything on its own', async () => {
    const onTypeChange = vi.fn();
    const { user } = renderWithProviders(<TypeSelector selectedType="todos" onTypeChange={onTypeChange} />);

    await user.click(screen.getByRole('button', { name: 'Cargo' }));

    expect(onTypeChange).toHaveBeenCalledWith('carga');
    // The clicked button does NOT become active: the parent decides that.
    // This assertion is what documents that the component is controlled.
    expect(screen.getByRole('button', { name: 'Cargo' })).toHaveAttribute('aria-pressed', 'false');
  });
});

And BookingForm, where both validation and its accessibility get checked:

// src/components/BookingForm.test.jsx
import { describe, test, expect, vi } from 'vitest';
import { renderWithProviders, screen } from '../tests/utils.jsx';
import { BIKES } from '../tests/handlers.js';
import BookingForm from './BookingForm.jsx';

describe('BookingForm', () => {
  test('does not submit and shows the errors associated with each field', async () => {
    const onCreateBooking = vi.fn();
    const { user } = renderWithProviders(
      <BookingForm bikes={BIKES} onCreateBooking={onCreateBooking} />
    );

    await user.click(screen.getByRole('button', { name: /confirm booking/i }));

    expect(onCreateBooking).not.toHaveBeenCalled();

    // The error isn't floating loose on the page: it's tied to the field via aria-describedby,
    // which is what makes a screen reader announce it on focus.
    const bikeField = screen.getByLabelText(/bike/i);
    expect(bikeField).toHaveAttribute('aria-invalid', 'true');
    expect(bikeField).toHaveAccessibleDescription(/choose a bike/i);
  });

  test('prevents booking a bike that is not available', async () => {
    const onCreateBooking = vi.fn();
    const { user } = renderWithProviders(
      <BookingForm bikes={BIKES} onCreateBooking={onCreateBooking} />
    );

    await user.selectOptions(screen.getByLabelText(/bike/i), 'bici-003');
    await user.type(screen.getByLabelText(/start/i), '2027-01-15T10:00');
    await user.click(screen.getByLabelText(/terms/i));
    await user.click(screen.getByRole('button', { name: /confirm booking/i }));

    expect(onCreateBooking).not.toHaveBeenCalled();
    expect(screen.getByRole('alert')).toHaveTextContent(/not available/i);
  });

  test('submits the complete data when everything is valid', async () => {
    const onCreateBooking = vi.fn();
    const { user } = renderWithProviders(
      <BookingForm bikes={BIKES} onCreateBooking={onCreateBooking} />
    );

    await user.selectOptions(screen.getByLabelText(/bike/i), 'bici-001');
    await user.type(screen.getByLabelText(/start/i), '2027-01-15T10:00');
    await user.clear(screen.getByLabelText(/hours/i));
    await user.type(screen.getByLabelText(/hours/i), '3');
    await user.click(screen.getByLabelText(/terms/i));
    await user.click(screen.getByRole('button', { name: /confirm booking/i }));

    expect(onCreateBooking).toHaveBeenCalledWith(
      expect.objectContaining({ bicicletaId: 'bici-001', hours: 3, status: 'activa' })
    );
  });
});

  1. Integration Tests: Whole Pages with MSW

This is where the plan concentrates its effort. An integration test mounts the real page, with its real query hooks, against the mock network; it checks the three states the user can end up seeing.

// src/pages/CataloguePage.test.jsx
import { describe, test, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { renderWithProviders, screen, waitFor, waitForElementToBeRemoved } from '../tests/utils.jsx';
import { server } from '../tests/server.js';
import CataloguePage from './CataloguePage.jsx';

const API = 'http://localhost:3001';

describe('CataloguePage', () => {
  test('shows the skeleton and then the five bikes', async () => {
    renderWithProviders(<CataloguePage />);

    // First state: skeleton, not a "Loading…" text
    expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();
    await waitForElementToBeRemoved(() => screen.queryByTestId('esqueleto-pagina'));

    // Second state: the rendered list
    expect(await screen.findAllByTestId('tarjeta-bicicleta')).toHaveLength(5);
    expect(screen.getByRole('heading', { name: 'Electric Pro' })).toBeInTheDocument();
  });

  test('filters by the type that comes in through the URL', async () => {
    renderWithProviders(<CataloguePage />, { route: '/?tipo=electrica' });

    const cards = await screen.findAllByTestId('tarjeta-bicicleta');
    expect(cards).toHaveLength(2);
    expect(screen.getByRole('button', { name: 'Electric' })).toHaveAttribute('aria-pressed', 'true');
  });

  test('offers a retry on a server error, and the retry works', async () => {
    let attempts = 0;
    server.use(
      http.get(`${API}/bicicletas`, () => {
        attempts += 1;
        // The first attempt fails; the second, now with the default handler, succeeds
        if (attempts === 1) return new HttpResponse(null, { status: 500 });
        return HttpResponse.json([
          { id: 'bici-001', model: 'Classic Urban', type: 'urbana', status: 'disponible', stationId: 'est-01', pricePerHour: 2.5 },
        ]);
      })
    );

    const { user } = renderWithProviders(<CataloguePage />);

    // Third state: visible, actionable error
    expect(await screen.findByRole('alert')).toHaveTextContent(/could not be loaded/i);
    await user.click(screen.getByRole('button', { name: /retry/i }));

    expect(await screen.findByRole('heading', { name: 'Classic Urban' })).toBeInTheDocument();
    await waitFor(() => expect(attempts).toBe(2));
  });

  test('search combines with the filter without asking the server again', async () => {
    const { user } = renderWithProviders(<CataloguePage />, { route: '/?tipo=urbana' });
    await screen.findAllByTestId('tarjeta-bicicleta');

    await user.type(screen.getByRole('searchbox', { name: /search/i }), 'Classic');

    await waitFor(() => expect(screen.getAllByTestId('tarjeta-bicicleta')).toHaveLength(2));
  });
});

The full-form test is the one that replaces the twenty manual steps: it checks the request body and the subsequent navigation.

// src/pages/NewBookingPage.test.jsx
import { describe, test, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { renderWithProviders, screen, waitFor } from '../tests/utils.jsx';
import { server } from '../tests/server.js';
import NewBookingPage from './NewBookingPage.jsx';
import BookingsPage from './BookingsPage.jsx';

const API = 'http://localhost:3001';
const CUSTOMER_SESSION = { session: { user: { id: 'usr-01', name: 'Ana Ribera', role: 'cliente' } } };

describe('NewBookingPage', () => {
  test('submits the booking and redirects to my bookings', async () => {
    let receivedBody = null;
    server.use(
      http.post(`${API}/reservas`, async ({ request }) => {
        receivedBody = await request.json();
        return HttpResponse.json({ ...receivedBody, id: 'res-nueva' }, { status: 201 });
      })
    );

    const { user, router } = renderWithProviders(null, {
      initialState: CUSTOMER_SESSION,
      route: '/reservas/nueva',
      routes: [
        { path: '/reservas', element: <BookingsPage /> },
        { path: '/reservas/nueva', element: <NewBookingPage /> },
      ],
    });

    await user.selectOptions(await screen.findByLabelText(/bike/i), 'bici-001');
    await user.type(screen.getByLabelText(/start/i), '2027-01-15T10:00');
    await user.click(screen.getByLabelText(/terms/i));
    await user.click(screen.getByRole('button', { name: /confirm booking/i }));

    // 1. What was sent to the server
    await waitFor(() => expect(receivedBody).toMatchObject({
      bicicletaId: 'bici-001', user: 'usr-01', status: 'activa',
    }));

    // 2. The redirect, checked against the real router
    await waitFor(() => expect(router.state.location.pathname).toBe('/reservas'));

    // 3. The success notice
    expect(screen.getByTestId('aviso')).toHaveTextContent(/booking created/i);
  });

  test('a server failure leaves the form filled in and shows the error', async () => {
    server.use(http.post(`${API}/reservas`, () => new HttpResponse(null, { status: 500 })));

    const { user, router } = renderWithProviders(<NewBookingPage />, {
      initialState: CUSTOMER_SESSION, route: '/reservas/nueva',
    });

    await user.selectOptions(await screen.findByLabelText(/bike/i), 'bici-001');
    await user.type(screen.getByLabelText(/start/i), '2027-01-15T10:00');
    await user.click(screen.getByLabelText(/terms/i));
    await user.click(screen.getByRole('button', { name: /confirm booking/i }));

    expect(await screen.findByRole('alert')).toHaveTextContent(/could not be created/i);
    // What was typed isn't lost: that's the difference between a tolerable error and an unbearable one
    expect(screen.getByLabelText(/bike/i)).toHaveValue('bici-001');
    expect(router.state.location.pathname).toBe('/reservas/nueva');
  });
});

And access control, which is a business rule and not a visual detail:

// src/pages/WorkshopPage.test.jsx
import { describe, test, expect } from 'vitest';
import { renderWithProviders, screen } from '../tests/utils.jsx';
import WorkshopPage from './WorkshopPage.jsx';
import ForbiddenPage from './ForbiddenPage.jsx';
import RequireRole from '../components/RequireRole.jsx';

const routes = [
  { path: '/sin-permisos', element: <ForbiddenPage /> },
  {
    path: '/taller',
    element: (
      <RequireRole allowedRoles={['operario']}>
        <WorkshopPage />
      </RequireRole>
    ),
  },
];

describe('access to /taller', () => {
  test('the operator sees the panel', async () => {
    renderWithProviders(null, {
      initialState: { session: { user: { id: 'usr-02', name: 'Marc Solé', role: 'operario' } } },
      route: '/taller', routes,
    });

    expect(await screen.findByRole('heading', { name: /workshop/i })).toBeInTheDocument();
  });

  test('a customer who navigates in via URL gets "forbidden", not a 404 or a blank screen', async () => {
    renderWithProviders(null, {
      initialState: { session: { user: { id: 'usr-01', name: 'Ana Ribera', role: 'cliente' } } },
      route: '/taller', routes,
    });

    expect(await screen.findByRole('heading', { name: /you do not have permission/i })).toBeInTheDocument();
    expect(screen.queryByRole('heading', { name: /workshop/i })).not.toBeInTheDocument();
  });
});

  1. End-to-End Tests with Cypress

Only three flows, the ones the business cannot afford to have break. Each one starts from a known, seeded state.

// cypress/e2e/booking.cy.js
describe('booking a bike', () => {
  beforeEach(() => {
    cy.seedData();              // Resets db.json from the seed
    cy.signInAs('usr-01');      // Wrapped in cy.session: doesn't repeat the form
  });

  it('creates the booking and shows it in my bookings', () => {
    cy.intercept('GET', '**/bicicletas*').as('catalogue');
    cy.intercept('POST', '**/reservas').as('createBooking');

    cy.visit('/');
    cy.wait('@catalogue');

    // We wait for the element, never for a fixed time
    cy.cardFor('bici-001').findByRole('button', { name: /book/i }).click();

    cy.location('pathname').should('eq', '/reservas/nueva');
    cy.findByLabelText(/start/i).type('2027-01-15T10:00');
    cy.findByLabelText(/hours/i).clear().type('3');
    cy.findByLabelText(/terms/i).check();
    cy.findByRole('button', { name: /confirm booking/i }).click();

    cy.wait('@createBooking').its('response.statusCode').should('eq', 201);

    cy.location('pathname').should('eq', '/reservas');
    cy.byTestId('aviso').should('contain.text', 'Booking created');
    cy.byTestId('fila-reserva').should('have.length', 2);
    cy.byTestId('total-reserva').first().should('contain.text', '7.50');
  });
});
// cypress/e2e/cancel.cy.js
describe('cancelling a booking', () => {
  beforeEach(() => {
    cy.seedData();
    cy.signInAs('usr-01');
  });

  it('asks for confirmation, reflects the change, and persists it after a reload', () => {
    cy.intercept('PATCH', '**/reservas/res-01').as('cancel');

    cy.visit('/reservas');
    cy.byTestId('fila-reserva').should('have.length', 1);
    cy.findByRole('button', { name: /cancel/i }).click();

    // Without confirming, nothing happens: confirmation is part of H6's contract
    cy.findByRole('button', { name: /back/i }).click();
    cy.contains('Active').should('exist');

    cy.findByRole('button', { name: /cancel/i }).click();
    cy.findByRole('button', { name: /yes, cancel/i }).click();

    cy.wait('@cancel');
    cy.contains('Cancelled').should('exist');

    // The reload is what distinguishes a real change from an optimistic illusion
    cy.reload();
    cy.contains('Cancelled').should('exist');
  });
});

That final cy.reload() is the heart of the test. An optimistic update paints the change before the server responds, so without reloading you can't tell a successful write from one that silently rolled back.

  1. Coverage: Reading the Report with Judgment

npm run cobertura
# → report in the console and in coverage/index.html

A typical report for the project at this point:

File                        | % Stmts | % Branch | Uncovered lines
----------------------------|---------|----------|----------------
src/utils/                  |   98.1  |   96.4   |
src/features/               |   91.7  |   84.2   |
src/components/             |   78.3  |   71.0   |
src/pages/                  |   72.6  |   64.8   |
src/hooks/                  |   88.9  |   80.0   |
src/api/client.js           |   61.2  |   45.0   | 48-53, 67-71
----------------------------|---------|----------|----------------
All files                   |   80.4  |   73.1   |

The overall 80% figure says nothing by itself. What matters is where the gaps are:

Gap Does it matter? Why
client.js lines 48-53: timeout Yes It's an error branch the user will hit on a bad network, and nobody has ever exercised it
client.js lines 67-71: missing authorization header Yes It touches security; an untested branch here is an open door
Branches of ThemeProvider No Deliberately excluded as D1 in the plan
Conditional style branches in StationCard No They change with the design; testing them produces brittle tests
RouteErrorPage Partially It deserves a test that checks it doesn't show the technical stack trace to the user

The rule: coverage is not a target, it's a blind-spot detector. Read it to discover branches nobody thought to test, and ignore it when it flags code you deliberately decided not to test.

  1. Continuous Integration

All of the above is only worth something if it runs without anyone having to remember to run it.

# .github/workflows/tests.yml
name: Tests

on:
  push:
    branches: [main]
  pull_request:

jobs:
  static-and-unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm            # Speeds things up a lot; npm's cache is half the job's time
      # ci instead of install: installs exactly what's in package-lock.json
      - run: npm ci
      - run: npm run lint
      - run: npm run formato:comprobar
      - run: npm run cobertura
      - uses: actions/upload-artifact@v4
        if: always()           # Uploads the report even if the tests fail
        with:
          name: coverage
          path: coverage/

  end-to-end:
    runs-on: ubuntu-latest
    needs: static-and-unit   # Don't spend six minutes on e2e if the linter already failed
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      # We test the BUILT application, not the dev server:
      # it's the closest thing to production we can run here.
      - run: npm run build
      - uses: cypress-io/github-action@v6
        with:
          install: false
          start: npm run api, npm run preview
          wait-on: 'http://localhost:4173, http://localhost:3001/bicicletas'
          wait-on-timeout: 90
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: cypress-screenshots
          path: |
            cypress/screenshots
            cypress/videos

When it fails in CI but not locally, the cause is almost always in this list:

Symptom Common cause Solution
Fails only in CI, passes locally Different test order and shared global state Isolate in afterEach; never depend on order
Fails intermittently Waiting on time instead of on an element Replace wait(500) with findBy* or cy.wait('@alias')
Fails with dates Different container time zone Fixed dates in the tests; TZ=Europe/Madrid in the environment
Fails to build Dependency installed locally but not in the lock file npm ci locally to reproduce it
Runs slow and times out No npm cache, or wait-on too short Cache it, and give wait-on-timeout room

And the tip that saves hours: before debugging blind, download the artifacts. Cypress's screenshots and video show the exact screen at the moment of failure.

  1. The Guided Regression: What Each Level Catches

Time to prove the suite is worth it. We're going to break the project on purpose. In the cancel-booking mutation written in the previous lesson, we delete a single line:

// src/features/bookings/useCancelBooking.js
onSettled: (_data, _error, variables) => {
  client.invalidateQueries({ queryKey: keys.bookings.byUser(variables.userId) });
- client.invalidateQueries({ queryKey: keys.bikes.all() });
}

It's a realistic and treacherous bug: the booking cancels fine, the bookings list updates fine, and the bike keeps showing as rented in the catalogue until the user reloads. Nobody notices by looking at the bookings screen. Here's what happens when you run npm run pruebas:todas:

flowchart TD
    F["invalidateQueries for bikes<br/>is deleted"] --> E["Static analysis"]
    F --> U["Unit<br/>validateBooking, reducer, hooks"]
    F --> C["Component<br/>BikeCard, BookingsPanel"]
    F --> I["Integration<br/>whole page + MSW"]
    F --> X["End-to-end<br/>Cypress"]
    E --> EP["PASSES<br/>the syntax is valid"]
    U --> UP["PASSES<br/>no cache involved"]
    C --> CP["PASSES<br/>the component receives props, not cache"]
    I --> IF["FAILS<br/>the catalogue doesn't reflect the change"]
    X --> XF["FAILS<br/>after cancelling, the bike still shows as rented"]

The test that catches it is this one, worth adding to the integration suite for exactly this reason:

test('after cancelling, the catalogue reflects that the bike is available again', async () => {
  const { user } = renderWithProviders(null, {
    initialState: CUSTOMER_SESSION,
    route: '/reservas',
    routes: [
      { path: '/reservas', element: <BookingsPage /> },
      { path: '/', element: <CataloguePage /> },
    ],
  });

  await user.click(await screen.findByRole('button', { name: /cancel/i }));
  await user.click(screen.getByRole('button', { name: /yes, cancel/i }));
  await screen.findByText('Cancelled');

  // We go back to the catalogue: if the bikes cache wasn't invalidated,
  // it will still show "Rented" here and the test fails.
  await user.click(screen.getByRole('link', { name: /catalogue/i }));
  const card = (await screen.findAllByTestId('tarjeta-bicicleta'))
    .find((t) => t.dataset.bicicleta === 'bici-002');
  await waitFor(() => expect(card).toHaveTextContent('Available'));
});

The lesson to take from the table is the one that orders the entire testing effort of a React project:

Level Catches well Is blind to
Static Type errors, badly declared dependencies, misplaced ARIA Any correctly written logic bug
Unit Business rules, calculations, state transitions Everything that happens between pieces
Component Prop contracts, accessibility, callbacks Cache, network, navigation, integration
Integration Cache, invalidation, loading and error states, permissions, navigation Real styles, browser behavior
End-to-end Complete flows, real persistence, CSS that covers a button Edge cases (it would be very expensive to cover them here)

That's why the plan in section 1 has the integration column full: it's the level that catches the failures this project actually produces, and the most cost-effective per minute invested.

Common Mistakes and Tips

  • Sharing the QueryClient between tests. It's the most frequent cause of tests that pass in isolation and fail together: one test's cache answers the next one. A new client per test, always.
  • Leaving retries turned on. With retry: 3, an error test waits through three failed attempts before showing the message and ends up timing out. retry: false in tests.
  • Testing the exact wording of messages. toHaveProperty('hours') survives a message rewrite; toBe('The hours must be between 1 and 24') breaks with every wording tweak. Test the field, not the prose.
  • Forgetting await on userEvent. All of its calls are asynchronous. Without await, the assertion runs before the change and the failure is baffling.
  • Using getBy* to check that something is NOT there. getBy* throws if it doesn't find a match. For absence, use queryBy*.
  • Waiting on time in Cypress. cy.wait(1000) is a recipe for a flaky test. Wait for the request alias or for the element.
  • Chasing 100% coverage. It fills the project with tests that check the obvious and adds no confidence. Chase the untested error branches instead.
  • Golden tip: every time a bug reaches production, write the test that reproduces it first, then fix it. The suite grows exactly where the project actually fails.

Exercises

Exercise 1. Write the integration test for story H3: when visiting /bicicletas/bici-999, the application should show a custom 404 with a link back to the catalogue, not a blank screen or the error stack trace.

Exercise 2. Write the end-to-end test for story H7: operator usr-02 goes to /taller, sends bici-001 to maintenance, and the bike shows as "In maintenance" in the public catalogue without reloading the page.

Exercise 3. Cover one of the important gaps in the coverage report: the timeout branch of src/api/client.js. Test that, when the request takes longer than the limit, the hook exposes an error with an understandable message and the interface offers a retry.

Solutions

Solution 1.

// src/pages/BikeDetailPage.test.jsx
import { describe, test, expect } from 'vitest';
import { renderWithProviders, screen } from '../tests/utils.jsx';
import BikeDetailPage from './BikeDetailPage.jsx';
import CataloguePage from './CataloguePage.jsx';

describe('BikeDetailPage', () => {
  test('a nonexistent id shows a custom 404 with a way out', async () => {
    const { user, router } = renderWithProviders(null, {
      route: '/bicicletas/bici-999',
      routes: [
        { path: '/', element: <CataloguePage /> },
        { path: '/bicicletas/:bicicletaId', element: <BikeDetailPage /> },
      ],
    });

    // Custom message, in a heading: it's a screen, not a stray notice
    expect(await screen.findByRole('heading', { name: /we could not find that bike/i })).toBeInTheDocument();
    // And no technical information leaks to the user
    expect(screen.queryByText(/404/)).not.toBeInTheDocument();
    expect(screen.queryByText(/HttpError/)).not.toBeInTheDocument();

    await user.click(screen.getByRole('link', { name: /back to catalogue/i }));
    expect(router.state.location.pathname).toBe('/');
  });

  test('a valid id shows the detail page', async () => {
    renderWithProviders(<BikeDetailPage />, {
      route: '/bicicletas/bici-001',
      routes: [{ path: '/bicicletas/:bicicletaId', element: <BikeDetailPage /> }],
    });

    expect(await screen.findByRole('heading', { name: 'Classic Urban' })).toBeInTheDocument();
    expect(screen.getByText('Main Square')).toBeInTheDocument();
  });
});

Solution 2.

// cypress/e2e/workshop.cy.js
describe('the operator changes a bike status', () => {
  beforeEach(() => {
    cy.seedData();
    cy.signInAs('usr-02');   // operator
  });

  it('sends a bike to maintenance and it shows in the catalogue', () => {
    cy.intercept('PATCH', '**/bicicletas/bici-001').as('changeStatus');
    cy.intercept('GET', '**/bicicletas*').as('catalogue');

    cy.visit('/taller');
    cy.findByRole('row', { name: /Classic Urban/ })
      .findByRole('button', { name: /send to workshop/i })
      .click();

    cy.wait('@changeStatus').its('request.body').should('deep.include', { status: 'mantenimiento' });

    // The invalidation must bring in a fresh catalogue without reloading the page
    cy.findByRole('link', { name: /catalogue/i }).click();
    cy.wait('@catalogue');
    cy.cardFor('bici-001').should('contain.text', 'In maintenance');
    cy.cardFor('bici-001').findByRole('button', { name: /book/i }).should('not.exist');
  });

  it('a customer never even sees the panel', () => {
    cy.signInAs('usr-01');
    cy.visit('/taller');
    cy.findByRole('heading', { name: /you do not have permission/i }).should('exist');
    cy.byTestId('menu-usuario').should('not.contain.text', 'Workshop');
  });
});

Solution 3.

// src/queries/useBikes.test.jsx
import { describe, test, expect, vi, afterEach } from 'vitest';
import { http } from 'msw';
import { delay } from 'msw';
import { renderWithProviders, screen } from '../tests/utils.jsx';
import { server } from '../tests/server.js';
import CataloguePage from '../pages/CataloguePage.jsx';

const API = 'http://localhost:3001';

describe('API client timeout', () => {
  afterEach(() => vi.useRealTimers());

  test('if the request exceeds the limit, an understandable error is shown and can be retried', async () => {
    // The handler never responds in time: the client's AbortController must cut it off
    server.use(
      http.get(`${API}/bicicletas`, async () => {
        await delay('infinite');
      })
    );

    vi.useFakeTimers({ shouldAdvanceTime: true });
    const { user } = renderWithProviders(<CataloguePage />);

    expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();

    // We advance past the limit configured in client.js (8 s)
    await vi.advanceTimersByTimeAsync(8500);

    const notice = await screen.findByRole('alert');
    // A message for people, not the exception name
    expect(notice).toHaveTextContent(/taking too long/i);
    expect(notice).not.toHaveTextContent(/AbortError/);
    expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();

    // And retrying returns to the normal path
    vi.useRealTimers();
    server.resetHandlers();
    await user.click(screen.getByRole('button', { name: /retry/i }));
    expect(await screen.findAllByTestId('tarjeta-bicicleta')).toHaveLength(5);
  });
});

Conclusion

CicloUrbano no longer depends on someone remembering twenty steps. It has a test plan that assigns each user story to the cheapest level that catches its failure, and that states in writing what has been deliberately left untested. It has unit tests for the business rules — validateBooking with its extremes and its error accumulation, the bookings reducer with the check that Immer doesn't mutate, the hooks with time under control. It has component tests that check what a person sees and can do, resting on the roles and labels that were put in place for accessibility. It has integration tests that mount whole pages against a mock network and cover the three states the user can end up seeing: skeleton, content, and error with retry. It has three end-to-end flows that walk through the real application in a real browser, with the cy.reload() that tells a genuine write apart from an optimistic illusion. And it has all of that running by itself on every change, with screenshots and video when something fails.

The guided regression left the moral in a table: the level that catches the failures this project actually produces — un-invalidated caches, badly chained loading states, permissions that slip through — is integration, and that's where it pays to invest. Deleting a line of invalidateQueries goes unnoticed by the linter, by the unit tests, and by the component tests; it does not go unnoticed by the two tests that mount the real application.

One last step remains, and it's the one that turns all this work into something someone can actually use. The application runs on localhost, with an API that's a JSON file and a dev server nobody should ever expose to the internet. Deploying to Production and Next Steps closes out the project and the course: the production build and its size review, environment variables and what's actually public in a client-side application, the client-side routing problem that makes /reservas return a 404 on a static server and its solution on each platform, the step-by-step deployment, what it would take to have a real API behind it, post-launch monitoring, and the roadmap for where to go from here.

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