En la lección anterior CicloUrbano quedó funcionando de punta a punta, y la única comprobación fue un recorrido manual de veinte pasos con la pestaña de red abierta. Ese recorrido demostró que la aplicación funciona hoy. No demuestra nada sobre mañana: en cuanto alguien toque una línea del onSettled de una mutación, nadie va a repetir los veinte pasos, y el fallo llegará al usuario antes que a nosotros. Esta lección convierte esa comprobación manual en una red de seguridad automática, aplicando al código real del proyecto la estrategia que se estudió en el Módulo 9. No se vuelve a explicar cómo funciona Testing Library ni MSW: aquí se decide qué se prueba, en qué nivel y por qué, y se escribe la suite completa del proyecto.

Contenido

  1. El plan de pruebas: cada historia en su nivel
  2. Lo que se decide no probar, y por qué
  3. Configuración lista para el proyecto
  4. Los manejadores de MSW sobre el dominio de CicloUrbano
  5. Pruebas unitarias: validarReserva
  6. Pruebas unitarias: el reductor y los selectores de reservas
  7. Pruebas unitarias: los hooks propios
  8. Pruebas de componentes: TarjetaBicicleta, SelectorTipo y FormularioReserva
  9. Pruebas de integración: páginas enteras con MSW
  10. Pruebas de extremo a extremo con Cypress
  11. Cobertura: leer el informe con criterio
  12. Integración continua
  13. La regresión guiada: qué caza cada nivel

  1. El plan de pruebas: cada historia en su nivel

El plan no se improvisa fichero a fichero: se parte de las ocho historias de usuario que se escribieron en la planificación del proyecto y se decide, para cada una, qué nivel la cubre. Una historia bien cubierta no necesita estar probada en los cinco niveles; necesita estar probada en el nivel más barato que detecte su fallo característico.

Historia Estática Unitaria Componente Integración E2E
H1 Ver el catálogo Linter y exhaustive-deps TarjetaBicicleta pinta modelo, tipo, estado y precio : esqueleto → lista → error 500 con reintento Incluida en el flujo de reserva
H2 Filtrar y buscar useDebounce SelectorTipo avisa del cambio : ?tipo=electrica filtra la lista pintada
H3 Ficha de bicicleta : id inexistente → 404 propio
H4 Identificarse useAlmacenLocal FormularioAcceso valida Vuelta a la pantalla de origen : acceso.cy.js
H5 Reservar : validarReserva completo FormularioReserva muestra errores accesibles : cuerpo de la petición y redirección : reservar.cy.js
H6 Ver y cancelar reservas Reductor y selectores de sliceReservas PanelReservas pide confirmación : optimista + invalidación : cancelar.cy.js
H7 Operario cambia el estado : cliente en /taller → sin permisos Ejercicio
H8 Estaciones y flota TarjetaEstacion : pestaña activa en la URL

Fíjate en el patrón: la columna de integración está casi llena y la de e2e casi vacía. Es exactamente el reparto que recomienda el trofeo de pruebas. Las de integración prueban una pantalla entera con la red simulada, que es donde vive la mayor parte de los fallos reales de esta aplicación (estados de carga, invalidación de caché, errores de red, permisos), y cuestan una fracción de lo que cuesta una prueba e2e.

  1. Lo que se decide no probar, y por qué

Escribir qué no se prueba es tan importante como escribir qué sí, porque evita la discusión eterna y el cargo de conciencia.

No se prueba Por qué
D1 Tema claro/oscuro Su fallo es visual y evidente al primer vistazo; una prueba solo comprobaría que un atributo cambia de valor
D2 Avisos globales Se comprueban de refilón en las pruebas de integración de reserva, donde el aviso de éxito es parte de la aserción
D3 Aviso de pérdida de conexión Depende de un evento del navegador difícil de simular con fidelidad; el coste supera el riesgo
D4 Resumen de flota Es un cálculo derivado trivial; si falla, se ve en la pantalla
Nombres de clases CSS y maquetación Cambian con cada retoque de diseño; probarlos genera pruebas frágiles que no detectan ningún fallo funcional
Número de renders Es implementación, no comportamiento. Para eso está el Profiler
La biblioteca de terceros TanStack Query ya tiene sus pruebas; nosotros probamos nuestro uso de ella

  1. Configuración lista para el proyecto

La infraestructura es la que se montó en el Módulo 9, ahora consolidada en el proyecto. Primero, la sección test de vite.config.js:

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

export default defineConfig({
  plugins: [react()],
  test: {
    // jsdom da un DOM en Node: hay document, pero no diseño real ni navegación de verdad
    environment: 'jsdom',
    // globals: true permite usar describe/test/expect sin importarlos en cada fichero
    globals: true,
    // Este fichero se ejecuta antes de cada fichero de pruebas
    setupFiles: './src/pruebas/configuracion.js',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'html'],
      // No tiene sentido medir cobertura de lo que no es lógica
      exclude: ['src/pruebas/**', 'src/main.jsx', '**/*.module.css'],
    },
  },
});

El fichero de preparación hace cuatro cosas, y cada una evita una clase entera de pruebas contaminadas:

// src/pruebas/configuracion.js
import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { servidor } from './servidor.js';

// 1. Arranca la red simulada. onUnhandledRequest: 'error' es la decisión clave:
//    si una prueba pide una URL que no tiene manejador, falla en vez de colgarse.
beforeAll(() => servidor.listen({ onUnhandledRequest: 'error' }));

afterEach(() => {
  cleanup();                    // 2. Desmonta lo renderizado
  servidor.resetHandlers();     // 3. Deshace los servidor.use() de la prueba anterior
  localStorage.clear();         // 4. La sesión de una prueba no se filtra a la siguiente
});

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

Y la utilidad renderizar, que es la pieza que hace legibles todas las pruebas del proyecto: monta el componente con los proveedores reales, en el mismo orden que main.jsx.

// src/pruebas/utilidades.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 { sliceSesion } from '../funcionalidades/sesion/sliceSesion.js';
import { sliceCatalogo } from '../funcionalidades/catalogo/sliceCatalogo.js';
import { sliceReservas } from '../funcionalidades/reservas/sliceReservas.js';
import ProveedorTema from '../contextos/ProveedorTema.jsx';
import ProveedorAvisos from '../contextos/ProveedorAvisos.jsx';

export function crearAlmacenDePrueba(estadoInicial) {
  return configureStore({
    reducer: {
      sesion: sliceSesion.reducer,
      catalogo: sliceCatalogo.reducer,
      reservas: sliceReservas.reducer,
    },
    preloadedState: estadoInicial,
  });
}

export function crearClienteDePrueba() {
  return new QueryClient({
    defaultOptions: {
      // Sin reintentos: una prueba de error no debe esperar tres intentos fallidos
      queries: { retry: false, gcTime: Infinity },
      mutations: { retry: false },
    },
  });
}

export function renderizar(elemento, opciones = {}) {
  const {
    estadoInicial,
    almacen = crearAlmacenDePrueba(estadoInicial),
    cliente = crearClienteDePrueba(),
    ruta = '/',
    rutas = [{ path: '*', element: elemento }],
  } = opciones;

  const enrutador = createMemoryRouter(rutas, { initialEntries: [ruta] });

  const resultado = render(
    <QueryClientProvider client={cliente}>
      <Provider store={almacen}>
        <ProveedorTema>
          <ProveedorAvisos>
            <RouterProvider router={enrutador} />
          </ProveedorAvisos>
        </ProveedorTema>
      </Provider>
    </QueryClientProvider>
  );

  return { ...resultado, almacen, cliente, enrutador, usuario: userEvent.setup() };
}

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

Cada prueba recibe un almacén y un cliente nuevos, y por tanto una caché vacía. Compartirlos entre pruebas es la causa número uno de pruebas que pasan sueltas y fallan en conjunto.

  1. Los manejadores de MSW sobre el dominio de CicloUrbano

Los manejadores replican el comportamiento de json-server, incluido el filtrado por parámetros de consulta, porque el código de producción depende de él.

// src/pruebas/manejadores.js
import { http, HttpResponse } from 'msw';

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

export const BICICLETAS = [
  { id: 'bici-001', modelo: 'Urbana Clásica', tipo: 'urbana', estado: 'disponible', estacionId: 'est-01', precioHora: 2.5 },
  { id: 'bici-002', modelo: 'Eléctrica Pro', tipo: 'electrica', estado: 'alquilada', estacionId: 'est-01', precioHora: 4.0 },
  { id: 'bici-003', modelo: 'Carga Max', tipo: 'carga', estado: 'mantenimiento', estacionId: 'est-02', precioHora: 5.5 },
  { id: 'bici-004', modelo: 'Urbana Clásica', tipo: 'urbana', estado: 'disponible', estacionId: 'est-03', precioHora: 2.5 },
  { id: 'bici-005', modelo: 'Eléctrica Pro', tipo: 'electrica', estado: 'disponible', estacionId: 'est-02', precioHora: 4.0 },
];

export const ESTACIONES = [
  { id: 'est-01', nombre: 'Plaza Mayor', barrio: 'Centro', plazas: 20 },
  { id: 'est-02', nombre: 'Parque Norte', barrio: 'Norte', plazas: 15 },
  { id: 'est-03', nombre: 'Estación Central', barrio: 'Ensanche', plazas: 30 },
];

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

export const RESERVAS = [
  { id: 'res-01', bicicletaId: 'bici-002', usuario: 'usr-01', fechaInicio: '2026-05-04T09:00', horas: 2, estado: 'activa' },
];

export const manejadores = [
  http.get(`${API}/bicicletas`, ({ request }) => {
    const parametros = new URL(request.url).searchParams;
    const tipo = parametros.get('tipo');
    const estacionId = parametros.get('estacionId');
    let resultado = BICICLETAS;
    if (tipo) resultado = resultado.filter((b) => b.tipo === tipo);
    if (estacionId) resultado = resultado.filter((b) => b.estacionId === estacionId);
    return HttpResponse.json(resultado);
  }),

  http.get(`${API}/bicicletas/:bicicletaId`, ({ params }) => {
    const bicicleta = BICICLETAS.find((b) => b.id === params.bicicletaId);
    // Reproducimos el 404 real de json-server: la prueba del 404 propio depende de esto
    if (!bicicleta) return new HttpResponse(null, { status: 404 });
    return HttpResponse.json(bicicleta);
  }),

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

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

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

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

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

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

  http.patch(`${API}/reservas/:reservaId`, async ({ params, request }) => {
    const cambios = await request.json();
    const reserva = RESERVAS.find((r) => r.id === params.reservaId);
    return HttpResponse.json({ ...reserva, ...cambios });
  }),
];
// src/pruebas/servidor.js
import { setupServer } from 'msw/node';
import { manejadores } from './manejadores.js';

export const servidor = setupServer(...manejadores);

  1. Pruebas unitarias: validarReserva

validarReserva es una función pura con cinco reglas, y por tanto el sitio donde una tabla de casos rinde más por línea escrita. Aquí no hace falta React, ni DOM, ni red.

// src/utilidades/validarReserva.test.js
import { describe, test, expect } from 'vitest';
import { validarReserva } from './validarReserva.js';
import { BICICLETAS } from '../pruebas/manejadores.js';

// Una fecha claramente futura y otra claramente pasada, fijas para que la prueba
// no dependa del reloj del día en que se ejecute.
const FUTURO = '2027-01-15T10:00';
const PASADO = '2020-01-15T10:00';

const VALIDOS = {
  bicicletaId: 'bici-001',
  fechaInicio: FUTURO,
  horas: 2,
  condiciones: true,
};

describe('validarReserva', () => {
  test('no devuelve errores con datos válidos', () => {
    expect(validarReserva(VALIDOS, BICICLETAS)).toEqual({});
  });

  test.each([
    ['bicicleta sin elegir',        { bicicletaId: '' },                 'bicicletaId'],
    ['bicicleta inexistente',      { bicicletaId: 'bici-999' },         'bicicletaId'],
    ['bicicleta ya alquilada',     { bicicletaId: 'bici-002' },         'bicicletaId'],
    ['bicicleta en mantenimiento', { bicicletaId: 'bici-003' },         'bicicletaId'],
    ['fecha vacía',                { fechaInicio: '' },                 'fechaInicio'],
    ['fecha en el pasado',         { fechaInicio: PASADO },             'fechaInicio'],
    ['cero horas',                 { horas: 0 },                        'horas'],
    ['horas por encima del día',   { horas: 25 },                       'horas'],
    ['horas no numéricas',         { horas: 'dos' },                    'horas'],
    ['condiciones sin aceptar',    { condiciones: false },              'condiciones'],
  ])('marca error en %s', (_descripcion, cambios, campoEsperado) => {
    const errores = validarReserva({ ...VALIDOS, ...cambios }, BICICLETAS);
    // Se comprueba QUÉ campo falla, no el texto exacto del mensaje:
    // el texto es redacción y cambiará; el campo es contrato.
    expect(errores).toHaveProperty(campoEsperado);
  });

  test('acepta los extremos válidos del rango de horas', () => {
    expect(validarReserva({ ...VALIDOS, horas: 1 }, BICICLETAS)).toEqual({});
    expect(validarReserva({ ...VALIDOS, horas: 24 }, BICICLETAS)).toEqual({});
  });

  test('acumula todos los errores, no solo el primero', () => {
    const errores = validarReserva(
      { bicicletaId: '', fechaInicio: PASADO, horas: 0, condiciones: false },
      BICICLETAS
    );
    expect(Object.keys(errores)).toHaveLength(4);
  });
});

Las dos últimas pruebas son las que más valor aportan y las que más se olvidan. Los extremos del rango (1 y 24) documentan que el intervalo es cerrado: si alguien cambia el <= por un <, la prueba lo caza. Y la acumulación de errores protege una decisión de diseño de la interfaz: el formulario muestra todos los errores a la vez, así que una implementación que devolviera al primer fallo rompería la pantalla sin romper ninguna otra prueba.

  1. Pruebas unitarias: el reductor y los selectores de reservas

Un reductor es una función pura (estado, accion) => estado, así que se prueba despachando acciones sobre un estado conocido, sin montar nada.

// src/funcionalidades/reservas/sliceReservas.test.js
import { describe, test, expect } from 'vitest';
import {
  sliceReservas,
  reservaCreada,
  reservaCancelada,
  seleccionarReservasDeUsuario,
  seleccionarResumenReservas,
} from './sliceReservas.js';

const { reducer } = sliceReservas;

const ESTADO_CON_UNA = {
  entidades: {
    'res-01': { id: 'res-01', bicicletaId: 'bici-002', usuario: 'usr-01', horas: 2, estado: 'activa' },
  },
  ids: ['res-01'],
  estadoEnvio: 'inactivo',
  error: null,
};

describe('reductor de reservas', () => {
  test('devuelve el estado inicial ante una acción desconocida', () => {
    const estado = reducer(undefined, { type: 'accion/inexistente' });
    expect(estado.ids).toEqual([]);
  });

  test('añade la reserva creada al final y no muta el estado anterior', () => {
    const nueva = { id: 'res-02', bicicletaId: 'bici-001', usuario: 'usr-01', horas: 3, estado: 'activa' };
    const siguiente = reducer(ESTADO_CON_UNA, reservaCreada(nueva));

    expect(siguiente.ids).toEqual(['res-01', 'res-02']);
    expect(siguiente.entidades['res-02'].horas).toBe(3);
    // Immer permite escribir como si mutáramos, pero el estado previo debe quedar intacto
    expect(ESTADO_CON_UNA.ids).toEqual(['res-01']);
  });

  test('cancelar cambia el estado de la reserva sin eliminarla', () => {
    const siguiente = reducer(ESTADO_CON_UNA, reservaCancelada({ id: 'res-01' }));
    expect(siguiente.entidades['res-01'].estado).toBe('cancelada');
    expect(siguiente.ids).toHaveLength(1);
  });
});

describe('selectores de reservas', () => {
  const estadoRaiz = { reservas: ESTADO_CON_UNA };

  test('filtra por usuario', () => {
    expect(seleccionarReservasDeUsuario(estadoRaiz, 'usr-01')).toHaveLength(1);
    expect(seleccionarReservasDeUsuario(estadoRaiz, 'usr-02')).toHaveLength(0);
  });

  test('el resumen cuenta por estado', () => {
    expect(seleccionarResumenReservas(estadoRaiz)).toEqual({
      activas: 1, confirmadas: 0, canceladas: 0,
    });
  });
});

La aserción expect(ESTADO_CON_UNA.ids).toEqual(['res-01']) merece un comentario: comprueba que Immer está haciendo su trabajo. Es la prueba que caza el error de escribir un reductor que muta de verdad porque alguien lo sacó del createSlice a una función auxiliar.

  1. Pruebas unitarias: los hooks propios

Los hooks necesitan un componente que los ejecute, y para eso está renderHook. useDebounce además necesita controlar el tiempo.

// 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('devuelve el valor inicial de inmediato', () => {
    const { result } = renderHook(() => useDebounce('urbana', 300));
    expect(result.current).toBe('urbana');
  });

  test('no actualiza antes del retardo y sí después', () => {
    const { result, rerender } = renderHook(({ valor }) => useDebounce(valor, 300), {
      initialProps: { valor: 'urb' },
    });

    rerender({ valor: 'urbana' });
    // Justo antes del límite el valor antiguo sigue vigente
    act(() => vi.advanceTimersByTime(299));
    expect(result.current).toBe('urb');

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

  test('solo aplica el último valor de una ráfaga de cambios', () => {
    const { result, rerender } = renderHook(({ valor }) => useDebounce(valor, 300), {
      initialProps: { valor: 'u' },
    });

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

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

describe('useAlmacenLocal', () => {
  test('usa el valor por defecto cuando la clave no existe', () => {
    const { result } = renderHook(() => useAlmacenLocal('ciclourbano:tema', 'claro'));
    expect(result.current[0]).toBe('claro');
  });

  test('persiste el valor y lo recupera en un montaje posterior', () => {
    const primero = renderHook(() => useAlmacenLocal('ciclourbano:tema', 'claro'));
    act(() => primero.result.current[1]('oscuro'));
    primero.unmount();

    const segundo = renderHook(() => useAlmacenLocal('ciclourbano:tema', 'claro'));
    expect(segundo.result.current[0]).toBe('oscuro');
  });

  test('no revienta si el contenido almacenado no es JSON válido', () => {
    localStorage.setItem('ciclourbano:tema', '{roto');
    const { result } = renderHook(() => useAlmacenLocal('ciclourbano:tema', 'claro'));
    expect(result.current[0]).toBe('claro');
  });
});

Esa última prueba es la que justifica el try/catch del hook. Sin ella, nadie recordaría por qué está ahí y alguien lo borraría en la primera limpieza.

  1. Pruebas de componentes: comportamiento, nunca implementación

Con los componentes cambia la pregunta: ya no es «qué devuelve esta función» sino «qué ve y qué puede hacer la persona que usa esto». Las consultas por rol, explicadas en React Testing Library, son la traducción directa del trabajo de accesibilidad de la lección 03-06.

// src/componentes/TarjetaBicicleta.test.jsx
import { describe, test, expect, vi } from 'vitest';
import { renderizar, screen } from '../pruebas/utilidades.jsx';
import TarjetaBicicleta from './TarjetaBicicleta.jsx';

function bicicleta(id, modelo, tipo, estado, precioHora) {
  return { id, modelo, tipo, estado, estacionId: 'est-01', precioHora };
}

const DISPONIBLE = bicicleta('bici-001', 'Urbana Clásica', 'urbana', 'disponible', 2.5);
const EN_TALLER = bicicleta('bici-003', 'Carga Max', 'carga', 'mantenimiento', 5.5);

describe('TarjetaBicicleta', () => {
  test('muestra modelo, estado y precio por hora', () => {
    renderizar(<TarjetaBicicleta bicicleta={DISPONIBLE} nombreEstacion="Plaza Mayor" />);

    expect(screen.getByRole('heading', { name: 'Urbana Clásica' })).toBeInTheDocument();
    expect(screen.getByText('Disponible')).toBeInTheDocument();
    expect(screen.getByText(/2,50/)).toBeInTheDocument();
    expect(screen.getByText('Plaza Mayor')).toBeInTheDocument();
  });

  test('ofrece el botón de reservar solo si la bicicleta está disponible', () => {
    const { unmount } = renderizar(<TarjetaBicicleta bicicleta={DISPONIBLE} nombreEstacion="Plaza Mayor" />);
    expect(screen.getByRole('button', { name: /reservar/i })).toBeInTheDocument();
    unmount();

    renderizar(<TarjetaBicicleta bicicleta={EN_TALLER} nombreEstacion="Parque Norte" />);
    expect(screen.queryByRole('button', { name: /reservar/i })).not.toBeInTheDocument();
  });

  test('avisa al padre con la bicicleta pulsada', async () => {
    const alReservar = vi.fn();
    const { usuario } = renderizar(
      <TarjetaBicicleta bicicleta={DISPONIBLE} nombreEstacion="Plaza Mayor" alReservar={alReservar} />
    );

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

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

  test('no transmite el estado solo con color', () => {
    renderizar(<TarjetaBicicleta bicicleta={EN_TALLER} nombreEstacion="Parque Norte" />);
    // El texto del estado debe existir, no solo la clase CSS que lo pinta
    expect(screen.getByText('En taller')).toBeInTheDocument();
  });
});

SelectorTipo es un componente controlado, y eso determina qué se prueba: que avise, no que cambie por dentro. Su estado ya no existe.

// src/componentes/SelectorTipo.test.jsx
import { describe, test, expect, vi } from 'vitest';
import { renderizar, screen } from '../pruebas/utilidades.jsx';
import SelectorTipo from './SelectorTipo.jsx';

describe('SelectorTipo', () => {
  test('marca como activo el tipo recibido por props', () => {
    renderizar(<SelectorTipo tipoElegido="electrica" alCambiarTipo={vi.fn()} />);

    expect(screen.getByRole('button', { name: 'Eléctricas' })).toHaveAttribute('aria-pressed', 'true');
    expect(screen.getByRole('button', { name: 'Todas' })).toHaveAttribute('aria-pressed', 'false');
  });

  test('avisa del tipo pulsado y no decide nada por su cuenta', async () => {
    const alCambiarTipo = vi.fn();
    const { usuario } = renderizar(<SelectorTipo tipoElegido="todos" alCambiarTipo={alCambiarTipo} />);

    await usuario.click(screen.getByRole('button', { name: 'De carga' }));

    expect(alCambiarTipo).toHaveBeenCalledWith('carga');
    // El botón pulsado NO queda activo: quien decide es el padre.
    // Esta aserción es la que documenta que el componente es controlado.
    expect(screen.getByRole('button', { name: 'De carga' })).toHaveAttribute('aria-pressed', 'false');
  });
});

Y FormularioReserva, donde se comprueba a la vez la validación y su accesibilidad:

// src/componentes/FormularioReserva.test.jsx
import { describe, test, expect, vi } from 'vitest';
import { renderizar, screen } from '../pruebas/utilidades.jsx';
import { BICICLETAS } from '../pruebas/manejadores.js';
import FormularioReserva from './FormularioReserva.jsx';

describe('FormularioReserva', () => {
  test('no envía y muestra los errores asociados a cada campo', async () => {
    const alCrearReserva = vi.fn();
    const { usuario } = renderizar(
      <FormularioReserva bicicletas={BICICLETAS} alCrearReserva={alCrearReserva} />
    );

    await usuario.click(screen.getByRole('button', { name: /confirmar reserva/i }));

    expect(alCrearReserva).not.toHaveBeenCalled();

    // El error no está suelto en la página: está asociado al campo por aria-describedby,
    // que es lo que hace que un lector de pantalla lo anuncie al enfocarlo.
    const campoBicicleta = screen.getByLabelText(/bicicleta/i);
    expect(campoBicicleta).toHaveAttribute('aria-invalid', 'true');
    expect(campoBicicleta).toHaveAccessibleDescription(/elige una bicicleta/i);
  });

  test('impide reservar una bicicleta que no está disponible', async () => {
    const alCrearReserva = vi.fn();
    const { usuario } = renderizar(
      <FormularioReserva bicicletas={BICICLETAS} alCrearReserva={alCrearReserva} />
    );

    await usuario.selectOptions(screen.getByLabelText(/bicicleta/i), 'bici-003');
    await usuario.type(screen.getByLabelText(/inicio/i), '2027-01-15T10:00');
    await usuario.click(screen.getByLabelText(/condiciones/i));
    await usuario.click(screen.getByRole('button', { name: /confirmar reserva/i }));

    expect(alCrearReserva).not.toHaveBeenCalled();
    expect(screen.getByRole('alert')).toHaveTextContent(/no está disponible/i);
  });

  test('envía los datos completos cuando todo es válido', async () => {
    const alCrearReserva = vi.fn();
    const { usuario } = renderizar(
      <FormularioReserva bicicletas={BICICLETAS} alCrearReserva={alCrearReserva} />
    );

    await usuario.selectOptions(screen.getByLabelText(/bicicleta/i), 'bici-001');
    await usuario.type(screen.getByLabelText(/inicio/i), '2027-01-15T10:00');
    await usuario.clear(screen.getByLabelText(/horas/i));
    await usuario.type(screen.getByLabelText(/horas/i), '3');
    await usuario.click(screen.getByLabelText(/condiciones/i));
    await usuario.click(screen.getByRole('button', { name: /confirmar reserva/i }));

    expect(alCrearReserva).toHaveBeenCalledWith(
      expect.objectContaining({ bicicletaId: 'bici-001', horas: 3, estado: 'activa' })
    );
  });
});

  1. Pruebas de integración: páginas enteras con MSW

Aquí es donde el plan concentra el esfuerzo. Una prueba de integración monta la página real, con sus hooks de consulta reales, contra la red simulada; comprueba los tres estados que el usuario puede llegar a ver.

// src/paginas/PaginaCatalogo.test.jsx
import { describe, test, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { renderizar, screen, waitFor, waitForElementToBeRemoved } from '../pruebas/utilidades.jsx';
import { servidor } from '../pruebas/servidor.js';
import PaginaCatalogo from './PaginaCatalogo.jsx';

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

describe('PaginaCatalogo', () => {
  test('muestra el esqueleto y luego las cinco bicicletas', async () => {
    renderizar(<PaginaCatalogo />);

    // Primer estado: esqueleto, no un texto «Cargando…»
    expect(screen.getByTestId('esqueleto-pagina')).toBeInTheDocument();
    await waitForElementToBeRemoved(() => screen.queryByTestId('esqueleto-pagina'));

    // Segundo estado: la lista pintada
    expect(await screen.findAllByTestId('tarjeta-bicicleta')).toHaveLength(5);
    expect(screen.getByRole('heading', { name: 'Eléctrica Pro' })).toBeInTheDocument();
  });

  test('filtra por el tipo que llega en la URL', async () => {
    renderizar(<PaginaCatalogo />, { ruta: '/?tipo=electrica' });

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

  test('ante un error del servidor ofrece reintentar, y el reintento funciona', async () => {
    let intentos = 0;
    servidor.use(
      http.get(`${API}/bicicletas`, () => {
        intentos += 1;
        // El primer intento falla; el segundo, ya con el manejador por defecto, funciona
        if (intentos === 1) return new HttpResponse(null, { status: 500 });
        return HttpResponse.json([
          { id: 'bici-001', modelo: 'Urbana Clásica', tipo: 'urbana', estado: 'disponible', estacionId: 'est-01', precioHora: 2.5 },
        ]);
      })
    );

    const { usuario } = renderizar(<PaginaCatalogo />);

    // Tercer estado: error visible y accionable
    expect(await screen.findByRole('alert')).toHaveTextContent(/no se ha podido cargar/i);
    await usuario.click(screen.getByRole('button', { name: /reintentar/i }));

    expect(await screen.findByRole('heading', { name: 'Urbana Clásica' })).toBeInTheDocument();
    await waitFor(() => expect(intentos).toBe(2));
  });

  test('la búsqueda combina con el filtro sin volver a pedir al servidor', async () => {
    const { usuario } = renderizar(<PaginaCatalogo />, { ruta: '/?tipo=urbana' });
    await screen.findAllByTestId('tarjeta-bicicleta');

    await usuario.type(screen.getByRole('searchbox', { name: /buscar/i }), 'Clásica');

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

La prueba del formulario completo es la que sustituye a los veinte pasos manuales: comprueba el cuerpo de la petición y la navegación posterior.

// src/paginas/PaginaNuevaReserva.test.jsx
import { describe, test, expect } from 'vitest';
import { http, HttpResponse } from 'msw';
import { renderizar, screen, waitFor } from '../pruebas/utilidades.jsx';
import { servidor } from '../pruebas/servidor.js';
import PaginaNuevaReserva from './PaginaNuevaReserva.jsx';
import PaginaReservas from './PaginaReservas.jsx';

const API = 'http://localhost:3001';
const SESION_CLIENTE = { sesion: { usuario: { id: 'usr-01', nombre: 'Ana Ribera', rol: 'cliente' } } };

describe('PaginaNuevaReserva', () => {
  test('envía la reserva y redirige a mis reservas', async () => {
    let cuerpoRecibido = null;
    servidor.use(
      http.post(`${API}/reservas`, async ({ request }) => {
        cuerpoRecibido = await request.json();
        return HttpResponse.json({ ...cuerpoRecibido, id: 'res-nueva' }, { status: 201 });
      })
    );

    const { usuario, enrutador } = renderizar(null, {
      estadoInicial: SESION_CLIENTE,
      ruta: '/reservas/nueva',
      rutas: [
        { path: '/reservas', element: <PaginaReservas /> },
        { path: '/reservas/nueva', element: <PaginaNuevaReserva /> },
      ],
    });

    await usuario.selectOptions(await screen.findByLabelText(/bicicleta/i), 'bici-001');
    await usuario.type(screen.getByLabelText(/inicio/i), '2027-01-15T10:00');
    await usuario.click(screen.getByLabelText(/condiciones/i));
    await usuario.click(screen.getByRole('button', { name: /confirmar reserva/i }));

    // 1. Lo que se envió al servidor
    await waitFor(() => expect(cuerpoRecibido).toMatchObject({
      bicicletaId: 'bici-001', usuario: 'usr-01', estado: 'activa',
    }));

    // 2. La redirección, comprobada sobre el enrutador real
    await waitFor(() => expect(enrutador.state.location.pathname).toBe('/reservas'));

    // 3. El aviso de éxito
    expect(screen.getByTestId('aviso')).toHaveTextContent(/reserva creada/i);
  });

  test('un fallo del servidor deja el formulario relleno y muestra el error', async () => {
    servidor.use(http.post(`${API}/reservas`, () => new HttpResponse(null, { status: 500 })));

    const { usuario, enrutador } = renderizar(<PaginaNuevaReserva />, {
      estadoInicial: SESION_CLIENTE, ruta: '/reservas/nueva',
    });

    await usuario.selectOptions(await screen.findByLabelText(/bicicleta/i), 'bici-001');
    await usuario.type(screen.getByLabelText(/inicio/i), '2027-01-15T10:00');
    await usuario.click(screen.getByLabelText(/condiciones/i));
    await usuario.click(screen.getByRole('button', { name: /confirmar reserva/i }));

    expect(await screen.findByRole('alert')).toHaveTextContent(/no se ha podido crear/i);
    // Lo escrito no se pierde: es la diferencia entre un error tolerable y uno insufrible
    expect(screen.getByLabelText(/bicicleta/i)).toHaveValue('bici-001');
    expect(enrutador.state.location.pathname).toBe('/reservas/nueva');
  });
});

Y el control de acceso, que es una regla de negocio y no un detalle visual:

// src/paginas/PaginaTaller.test.jsx
import { describe, test, expect } from 'vitest';
import { renderizar, screen } from '../pruebas/utilidades.jsx';
import PaginaTaller from './PaginaTaller.jsx';
import PaginaSinPermisos from './PaginaSinPermisos.jsx';
import RequiereRol from '../componentes/RequiereRol.jsx';

const rutas = [
  { path: '/sin-permisos', element: <PaginaSinPermisos /> },
  {
    path: '/taller',
    element: (
      <RequiereRol rolesPermitidos={['operario']}>
        <PaginaTaller />
      </RequiereRol>
    ),
  },
];

describe('acceso a /taller', () => {
  test('el operario ve el panel', async () => {
    renderizar(null, {
      estadoInicial: { sesion: { usuario: { id: 'usr-02', nombre: 'Marc Solé', rol: 'operario' } } },
      ruta: '/taller', rutas,
    });

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

  test('un cliente que entra por URL recibe «sin permisos», no un 404 ni una pantalla en blanco', async () => {
    renderizar(null, {
      estadoInicial: { sesion: { usuario: { id: 'usr-01', nombre: 'Ana Ribera', rol: 'cliente' } } },
      ruta: '/taller', rutas,
    });

    expect(await screen.findByRole('heading', { name: /no tienes permiso/i })).toBeInTheDocument();
    expect(screen.queryByRole('heading', { name: /taller/i })).not.toBeInTheDocument();
  });
});

  1. Pruebas de extremo a extremo con Cypress

Solo tres flujos, los que el negocio no puede permitirse que se rompan. Cada uno arranca de un estado sembrado y conocido.

// cypress/e2e/reservar.cy.js
describe('reservar una bicicleta', () => {
  beforeEach(() => {
    cy.sembrarDatos();              // Reinicia db.json desde la semilla
    cy.accederComo('usr-01');       // Envuelto en cy.session: no repite el formulario
  });

  it('crea la reserva y la muestra en mis reservas', () => {
    cy.intercept('GET', '**/bicicletas*').as('catalogo');
    cy.intercept('POST', '**/reservas').as('crearReserva');

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

    // Se espera al elemento, nunca a un tiempo fijo
    cy.tarjetaDe('bici-001').findByRole('button', { name: /reservar/i }).click();

    cy.location('pathname').should('eq', '/reservas/nueva');
    cy.findByLabelText(/inicio/i).type('2027-01-15T10:00');
    cy.findByLabelText(/horas/i).clear().type('3');
    cy.findByLabelText(/condiciones/i).check();
    cy.findByRole('button', { name: /confirmar reserva/i }).click();

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

    cy.location('pathname').should('eq', '/reservas');
    cy.porTestId('aviso').should('contain.text', 'Reserva creada');
    cy.porTestId('fila-reserva').should('have.length', 2);
    cy.porTestId('total-reserva').first().should('contain.text', '7,50');
  });
});
// cypress/e2e/cancelar.cy.js
describe('cancelar una reserva', () => {
  beforeEach(() => {
    cy.sembrarDatos();
    cy.accederComo('usr-01');
  });

  it('pide confirmación, refleja el cambio y lo persiste tras recargar', () => {
    cy.intercept('PATCH', '**/reservas/res-01').as('cancelar');

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

    // Sin confirmar no pasa nada: la confirmación es parte del contrato de H6
    cy.findByRole('button', { name: /volver/i }).click();
    cy.contains('Activa').should('exist');

    cy.findByRole('button', { name: /cancelar/i }).click();
    cy.findByRole('button', { name: /sí, cancelar/i }).click();

    cy.wait('@cancelar');
    cy.contains('Cancelada').should('exist');

    // La recarga es lo que distingue un cambio real de una ilusión optimista
    cy.reload();
    cy.contains('Cancelada').should('exist');
  });
});

Ese cy.reload() final es el corazón de la prueba. Una actualización optimista pinta el cambio antes de que el servidor responda, así que sin recargar no se distingue una escritura correcta de una que se revirtió en silencio.

  1. Cobertura: leer el informe con criterio

npm run cobertura
# → informe en consola y en coverage/index.html

Un informe típico del proyecto en este punto:

File                        | % Stmts | % Branch | Uncovered lines
----------------------------|---------|----------|----------------
src/utilidades/             |   98.1  |   96.4   |
src/funcionalidades/        |   91.7  |   84.2   |
src/componentes/            |   78.3  |   71.0   |
src/paginas/                |   72.6  |   64.8   |
src/hooks/                  |   88.9  |   80.0   |
src/api/cliente.js          |   61.2  |   45.0   | 48-53, 67-71
----------------------------|---------|----------|----------------
All files                   |   80.4  |   73.1   |

El número global de 80 % no dice nada por sí solo. Lo que importa es dónde están los huecos:

Hueco ¿Importa? Por qué
cliente.js líneas 48-53: tiempo de espera agotado Es una rama de error que el usuario verá en una red mala y que nadie ha ejercitado nunca
cliente.js líneas 67-71: cabecera de autorización ausente Toca seguridad; una rama no probada aquí es una puerta abierta
Ramas de ProveedorTema No Deseable D1, descartado a conciencia en el plan
Ramas de estilo condicional en TarjetaEstacion No Cambian con el diseño; probarlas produce pruebas frágiles
PaginaErrorRuta Parcialmente Merece una prueba que compruebe que no muestra la traza técnica al usuario

La regla: la cobertura no es un objetivo, es un detector de puntos ciegos. Se lee para descubrir ramas que nadie pensó en probar, y se ignora cuando señala código que se decidió no probar.

  1. Integración continua

Todo lo anterior solo vale si se ejecuta sin que nadie se acuerde de ejecutarlo.

# .github/workflows/pruebas.yml
name: Pruebas

on:
  push:
    branches: [main]
  pull_request:

jobs:
  estatica-y-unitarias:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm            # Acelera mucho; la caché de npm es la mitad del tiempo del trabajo
      # ci en lugar de install: instala exactamente lo del 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()           # Sube el informe incluso si las pruebas fallan
        with:
          name: cobertura
          path: coverage/

  extremo-a-extremo:
    runs-on: ubuntu-latest
    needs: estatica-y-unitarias   # No gastes seis minutos de e2e si el linter ya falló
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: npm }
      - run: npm ci
      # Se prueba la aplicación CONSTRUIDA, no el servidor de desarrollo:
      # es lo más parecido a producción que se puede ejecutar aquí.
      - 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-capturas
          path: |
            cypress/screenshots
            cypress/videos

Cuando falla en CI y no en local, la causa está casi siempre en esta lista:

Síntoma Causa habitual Solución
Falla solo en CI, pasa en local Orden de pruebas distinto y estado global compartido Aislar en afterEach; nunca depender del orden
Falla de forma intermitente Espera por tiempo en vez de por elemento Sustituir wait(500) por findBy* o cy.wait('@alias')
Falla con fechas Zona horaria del contenedor distinta Fechas fijas en las pruebas; TZ=Europe/Madrid en el entorno
Falla al construir Dependencia instalada en local pero no en el lock npm ci en local para reproducirlo
Va lento y expira Sin caché de npm o wait-on demasiado corto Caché y wait-on-timeout holgado

Y el consejo que ahorra horas: antes de depurar a ciegas, descarga los artefactos. Las capturas y el vídeo de Cypress muestran la pantalla exacta en el momento del fallo.

  1. La regresión guiada: qué caza cada nivel

Toca demostrar que la suite sirve. Vamos a romper el proyecto a propósito. En la mutación de cancelar reserva escrita en la lección anterior, borramos una sola línea:

// src/funcionalidades/reservas/useCancelarReserva.js
onSettled: (_datos, _error, variables) => {
  cliente.invalidateQueries({ queryKey: claves.reservas.deUsuario(variables.usuarioId) });
- cliente.invalidateQueries({ queryKey: claves.bicicletas.todas() });
}

Es un fallo realista y traicionero: la reserva se cancela bien, la lista de reservas se actualiza bien, y la bicicleta sigue apareciendo como alquilada en el catálogo hasta que el usuario recarga. Nadie lo nota mirando la pantalla de reservas. Esto es lo que ocurre al ejecutar npm run pruebas:todas:

flowchart TD
    F["Se borra invalidateQueries<br/>de bicicletas"] --> E["Análisis estático"]
    F --> U["Unitarias<br/>validarReserva, reductor, hooks"]
    F --> C["De componente<br/>TarjetaBicicleta, PanelReservas"]
    F --> I["De integración<br/>página entera + MSW"]
    F --> X["Extremo a extremo<br/>Cypress"]
    E --> EP["PASA<br/>la sintaxis es válida"]
    U --> UP["PASA<br/>no hay caché en juego"]
    C --> CP["PASA<br/>el componente recibe props, no caché"]
    I --> IF["FALLA<br/>el catálogo no refleja el cambio"]
    X --> XF["FALLA<br/>tras cancelar, la bici sigue alquilada"]

La prueba que lo caza es esta, que conviene añadir al conjunto de integración precisamente por este motivo:

test('al cancelar, el catálogo refleja que la bicicleta vuelve a estar disponible', async () => {
  const { usuario } = renderizar(null, {
    estadoInicial: SESION_CLIENTE,
    ruta: '/reservas',
    rutas: [
      { path: '/reservas', element: <PaginaReservas /> },
      { path: '/', element: <PaginaCatalogo /> },
    ],
  });

  await usuario.click(await screen.findByRole('button', { name: /cancelar/i }));
  await usuario.click(screen.getByRole('button', { name: /sí, cancelar/i }));
  await screen.findByText('Cancelada');

  // Se vuelve al catálogo: si la caché de bicicletas no se invalidó,
  // aquí seguirá viéndose «Alquilada» y la prueba falla.
  await usuario.click(screen.getByRole('link', { name: /catálogo/i }));
  const tarjeta = (await screen.findAllByTestId('tarjeta-bicicleta'))
    .find((t) => t.dataset.bicicleta === 'bici-002');
  await waitFor(() => expect(tarjeta).toHaveTextContent('Disponible'));
});

La lección que se extrae de la tabla es la que ordena todo el esfuerzo de pruebas de un proyecto React:

Nivel Caza bien Es ciego a
Estático Errores de tipo, dependencias mal declaradas, ARIA mal puesto Cualquier fallo de lógica correctamente escrito
Unitario Reglas de negocio, cálculos, transiciones de estado Todo lo que ocurre entre piezas
De componente Contratos de props, accesibilidad, callbacks Caché, red, navegación, integración
De integración Caché, invalidación, estados de carga y error, permisos, navegación Estilos reales, comportamiento del navegador
Extremo a extremo Flujos completos, persistencia real, CSS que tapa un botón Casos límite (sería carísimo cubrirlos aquí)

Por eso el plan de la sección 1 tiene la columna de integración llena: es el nivel que caza los fallos que este proyecto produce de verdad, y el más rentable por minuto invertido.

Errores Comunes y Consejos

  • Compartir el QueryClient entre pruebas. Es la causa más frecuente de pruebas que pasan aisladas y fallan en conjunto: la caché de una prueba responde a la siguiente. Un cliente nuevo por prueba, siempre.
  • Dejar los reintentos activados. Con retry: 3, una prueba de error espera tres intentos fallidos antes de mostrar el mensaje y acaba expirando. retry: false en las pruebas.
  • Probar el texto exacto de los mensajes. toHaveProperty('horas') sobrevive a una reescritura del mensaje; toBe('Las horas deben estar entre 1 y 24') se rompe con cada retoque de redacción. Prueba el campo, no la prosa.
  • Olvidar await en userEvent. Todas sus llamadas son asíncronas. Sin await la aserción se ejecuta antes del cambio y el fallo es desconcertante.
  • Usar getBy* para comprobar que algo NO está. getBy* lanza si no encuentra. Para la ausencia, queryBy*.
  • Esperar por tiempo en Cypress. cy.wait(1000) es la receta de una prueba inestable. Espera por el alias de la petición o por el elemento.
  • Perseguir el 100 % de cobertura. Llena el proyecto de pruebas que comprueban lo obvio y no aporta confianza. Persigue las ramas de error no ejercitadas.
  • Consejo de oro: cada vez que un fallo llegue a producción, escribe primero la prueba que lo reproduce y luego arréglalo. La suite crece exactamente por donde el proyecto falla de verdad.

Ejercicios

Ejercicio 1. Escribe la prueba de integración de la historia H3: al visitar /bicicletas/bici-999, la aplicación debe mostrar un 404 propio con un enlace de vuelta al catálogo, y no una pantalla en blanco ni la traza del error.

Ejercicio 2. Escribe la prueba de extremo a extremo de la historia H7: el operario usr-02 entra en /taller, envía bici-001 a mantenimiento, y la bicicleta aparece «En taller» en el catálogo público sin recargar la página.

Ejercicio 3. Cubre uno de los huecos importantes del informe de cobertura: la rama de tiempo de espera agotado de src/api/cliente.js. Prueba que, cuando la petición tarda más del límite, el hook expone un error con un mensaje comprensible y la interfaz ofrece reintentar.

Soluciones

Solución 1.

// src/paginas/PaginaFichaBicicleta.test.jsx
import { describe, test, expect } from 'vitest';
import { renderizar, screen } from '../pruebas/utilidades.jsx';
import PaginaFichaBicicleta from './PaginaFichaBicicleta.jsx';
import PaginaCatalogo from './PaginaCatalogo.jsx';

describe('PaginaFichaBicicleta', () => {
  test('un identificador inexistente muestra un 404 propio con salida', async () => {
    const { usuario, enrutador } = renderizar(null, {
      ruta: '/bicicletas/bici-999',
      rutas: [
        { path: '/', element: <PaginaCatalogo /> },
        { path: '/bicicletas/:bicicletaId', element: <PaginaFichaBicicleta /> },
      ],
    });

    // Mensaje propio, en un encabezado: es una pantalla, no un aviso perdido
    expect(await screen.findByRole('heading', { name: /no encontramos esa bicicleta/i })).toBeInTheDocument();
    // Y no se filtra información técnica al usuario
    expect(screen.queryByText(/404/)).not.toBeInTheDocument();
    expect(screen.queryByText(/HttpError/)).not.toBeInTheDocument();

    await usuario.click(screen.getByRole('link', { name: /volver al catálogo/i }));
    expect(enrutador.state.location.pathname).toBe('/');
  });

  test('un identificador válido muestra la ficha', async () => {
    renderizar(<PaginaFichaBicicleta />, {
      ruta: '/bicicletas/bici-001',
      rutas: [{ path: '/bicicletas/:bicicletaId', element: <PaginaFichaBicicleta /> }],
    });

    expect(await screen.findByRole('heading', { name: 'Urbana Clásica' })).toBeInTheDocument();
    expect(screen.getByText('Plaza Mayor')).toBeInTheDocument();
  });
});

Solución 2.

// cypress/e2e/taller.cy.js
describe('el operario cambia el estado de una bicicleta', () => {
  beforeEach(() => {
    cy.sembrarDatos();
    cy.accederComo('usr-02');   // operario
  });

  it('envía una bicicleta a mantenimiento y se refleja en el catálogo', () => {
    cy.intercept('PATCH', '**/bicicletas/bici-001').as('cambiarEstado');
    cy.intercept('GET', '**/bicicletas*').as('catalogo');

    cy.visit('/taller');
    cy.findByRole('row', { name: /Urbana Clásica/ })
      .findByRole('button', { name: /enviar a taller/i })
      .click();

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

    // La invalidación debe traer el catálogo fresco sin recargar la página
    cy.findByRole('link', { name: /catálogo/i }).click();
    cy.wait('@catalogo');
    cy.tarjetaDe('bici-001').should('contain.text', 'En taller');
    cy.tarjetaDe('bici-001').findByRole('button', { name: /reservar/i }).should('not.exist');
  });

  it('un cliente no llega ni a ver el panel', () => {
    cy.accederComo('usr-01');
    cy.visit('/taller');
    cy.findByRole('heading', { name: /no tienes permiso/i }).should('exist');
    cy.porTestId('menu-usuario').should('not.contain.text', 'Taller');
  });
});

Solución 3.

// src/consultas/useBicicletas.test.jsx
import { describe, test, expect, vi, afterEach } from 'vitest';
import { http } from 'msw';
import { delay } from 'msw';
import { renderizar, screen } from '../pruebas/utilidades.jsx';
import { servidor } from '../pruebas/servidor.js';
import PaginaCatalogo from '../paginas/PaginaCatalogo.jsx';

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

describe('tiempo de espera del cliente de API', () => {
  afterEach(() => vi.useRealTimers());

  test('si la petición supera el límite, se muestra un error comprensible y se puede reintentar', async () => {
    // El manejador nunca responde a tiempo: el AbortController del cliente debe cortar
    servidor.use(
      http.get(`${API}/bicicletas`, async () => {
        await delay('infinite');
      })
    );

    vi.useFakeTimers({ shouldAdvanceTime: true });
    const { usuario } = renderizar(<PaginaCatalogo />);

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

    // Se avanza más allá del límite configurado en cliente.js (8 s)
    await vi.advanceTimersByTimeAsync(8500);

    const aviso = await screen.findByRole('alert');
    // Mensaje para personas, no el nombre de la excepción
    expect(aviso).toHaveTextContent(/está tardando demasiado/i);
    expect(aviso).not.toHaveTextContent(/AbortError/);
    expect(screen.getByRole('button', { name: /reintentar/i })).toBeInTheDocument();

    // Y el reintento vuelve a la vía normal
    vi.useRealTimers();
    servidor.resetHandlers();
    await usuario.click(screen.getByRole('button', { name: /reintentar/i }));
    expect(await screen.findAllByTestId('tarjeta-bicicleta')).toHaveLength(5);
  });
});

Conclusión

CicloUrbano ya no depende de que alguien recuerde veinte pasos. Tiene un plan de pruebas que asigna cada historia de usuario al nivel más barato que detecta su fallo, y que dice por escrito qué se ha decidido no probar. Tiene pruebas unitarias sobre las reglas de negocio —validarReserva con sus extremos y su acumulación de errores, el reductor de reservas con la comprobación de que Immer no muta, los hooks con el tiempo bajo control—. Tiene pruebas de componente que comprueban lo que ve y hace una persona, apoyándose en los roles y las etiquetas que se pusieron por accesibilidad. Tiene pruebas de integración que montan páginas enteras contra una red simulada y cubren los tres estados que el usuario puede llegar a ver: esqueleto, contenido y error con reintento. Tiene tres flujos de extremo a extremo que recorren la aplicación real en un navegador real, con el cy.reload() que distingue una escritura de verdad de una ilusión optimista. Y tiene todo eso ejecutándose solo en cada cambio, con capturas y vídeo cuando algo falla.

La regresión guiada dejó la moraleja en una tabla: el nivel que caza los fallos que este proyecto produce de verdad —caché sin invalidar, estados de carga mal encadenados, permisos que se cuelan— es el de integración, y ahí es donde conviene invertir. Borrar una línea de invalidateQueries pasa desapercibido para el linter, para las unitarias y para las de componente; y no pasa desapercibido para las dos pruebas que montan la aplicación de verdad.

Queda un último paso, y es el que convierte todo este trabajo en algo que alguien pueda usar. La aplicación funciona en localhost, con una API que es un fichero JSON y un servidor de desarrollo que nadie debería exponer a internet. Despliegue a Producción y Siguientes Pasos cierra el proyecto y el curso: la construcción para producción y su revisión de tamaño, las variables de entorno y qué es realmente público en una aplicación de cliente, el problema del enrutamiento en cliente que hace que /reservas devuelva un 404 en un servidor estático y su solución en cada plataforma, el despliegue paso a paso, qué haría falta para tener una API de verdad detrás, la monitorización después del lanzamiento y la hoja de ruta para seguir a partir de aquí.

Curso de React

Módulo 1: Introducción a React

Módulo 2: Componentes de React

Módulo 3: Trabajando con Eventos

Módulo 4: Conceptos Avanzados de Componentes

Módulo 5: Hooks de React

Módulo 6: Enrutamiento en React

Módulo 7: Gestión del Estado

Módulo 8: Optimización del Rendimiento

Módulo 9: Pruebas en React

Módulo 10: Temas Avanzados

Módulo 11: Proyecto: Construyendo una Aplicación Completa

© Copyright 2026. Todos los derechos reservados