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
- The test plan: each story at its level
- What we decide not to test, and why
- Project-ready configuration
- The MSW handlers for the CicloUrbano domain
- Unit tests:
validateBooking - Unit tests: the bookings reducer and selectors
- Unit tests: the custom hooks
- Component tests:
BikeCard,TypeSelector, andBookingForm - Integration tests: whole pages with MSW
- End-to-end tests with Cypress
- Coverage: reading the report with judgment
- Continuous integration
- The guided regression: what each level catches
- 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.
- 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 |
- 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.
- 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);
- Unit Tests:
validateBooking
validateBookingvalidateBooking 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.
- 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.
- 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.
- 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' })
);
});
});
- 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();
});
});
- 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.
- Coverage: Reading the Report with Judgment
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.
- 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/videosWhen 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.
- 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
QueryClientbetween 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: falsein 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
awaitonuserEvent. All of its calls are asynchronous. Withoutawait, 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, usequeryBy*. - 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
- What Is React?
- Setting Up the Development Environment
- Hello World in React
- JSX: A JavaScript Syntax Extension
- How React Renders: Virtual DOM and Reconciliation
Module 2: React Components
- Understanding Components
- Function vs Class Components
- Props: Passing Data to Components
- State: Managing Component State
- Styling Components: CSS, Modules and Utilities
Module 3: Working with Events
- Handling Events in React
- Conditional Rendering
- Lists and Keys
- Forms and Controlled Components
- Form Validation and Uncontrolled Components
- Accessibility in Interactive Components
Module 4: Advanced Component Concepts
- Lifting State Up
- Composition vs Inheritance
- React Lifecycle Methods
- Hooks: Introduction and Basic Use
- Error Boundaries: Catching Failures in the UI
Module 5: React Hooks
- The useState Hook
- The useEffect Hook
- The useRef Hook and DOM Access
- The useContext Hook
- The useReducer Hook
- Custom Hooks
Module 6: Routing in React
- Introducing React Router
- Setting Up React Router
- Nested Routes
- Programmatic Navigation
- Protected Routes and Access Control
Module 7: State Management
- Introduction to State Management
- The Context API
- Redux: Introduction and Setup
- Redux: Actions and Reducers
- Redux: Connecting to React
- Server State: Fetching, Caching and Syncing
Module 8: Performance Optimization
- Performance Optimization Techniques in React
- Memoization with React.memo
- The useMemo and useCallback Hooks
- Code Splitting and Lazy Loading
- Measuring Performance with React DevTools Profiler
Module 9: Testing React Applications
- Introduction to Testing
- Unit Testing with Jest
- Component Testing with React Testing Library
- Testing Asynchronous Code and Mocking APIs
- End-to-End Testing with Cypress
Module 10: Advanced Topics
- Server-Side Rendering (SSR) with Next.js
- Static Site Generation (SSG) with Next.js
- Suspense and React Server Components
- TypeScript with React
- React Native: Building Mobile Apps
