Módulo 22: Testing
Objetivos del módulo
- Entender para qué sirve cada tipo de prueba y qué conviene probar en una aplicación React
- Configurar Vitest 5 con jsdom y escribir pruebas legibles con
describe,testyexpect - Usar dobles de prueba:
vi.fn,vi.spyOn,vi.mocky temporizadores falsos - Probar componentes como los usa una persona, con Testing Library y consultas accesibles
- Simular a la persona usuaria con
user-eventy saber por qué es mejor quefireEvent - Esperar lo asíncrono con
findBy,waitFory entender el aviso deact - Simular la API en la red con MSW 3 y conocer lo que cambia respecto a MSW 2
- Probar hooks personalizados, Context, React Router, formularios, TanStack Query, Actions y Suspense
- Medir la cobertura y saber interpretarla
- Escribir pruebas extremo a extremo con Playwright contra la aplicación real
- Depurar una prueba que falla y evitar las pruebas frágiles
💡 Consejo: Las herramientas de este módulo cambian más rápido que React. Vitest 5 y MSW 3 rompen cosas que verás en tutoriales recientes: en MSW desaparece
onUnhandledRequesty en VitestclearMockspasa a estar activo por defecto. Todo el código de este módulo se ha compilado y ejecutado con Vitest 5.0.5, MSW 3.0.3, Testing Library 16.3.3,user-event14.6.8, jest-dom 7.0.1, jsdom 30.1.3 y Playwright 1.64.1.
1. Para qué sirven las pruebas
Una prueba automática es un programa que ejecuta tu código y comprueba que hace lo esperado. El beneficio inmediato no es encontrar errores, sino poder cambiar el código sin miedo. Si tienes 200 pruebas y una refactorización las deja en verde, puedes publicar con bastante tranquilidad. Si no las tienes, cada cambio te obliga a repasar la aplicación a mano, y lo que no repasas se rompe en silencio.
Una prueba también es documentación que no caduca. test('muestra un aviso si la API falla') dice lo que el componente promete, y si deja de ser cierto, la prueba falla.
Tipos de prueba
| Tipo | Qué ejecuta | Herramienta | Velocidad | Confianza que da |
|---|---|---|---|---|
| Estática | Nada: analiza el texto | TypeScript, oxlint | Inmediata | Evita errores de tipos y de reglas |
| Unitaria | Una función o un hook aislado | Vitest | Milisegundos | La lógica es correcta |
| De integración | Varios componentes juntos, con la red simulada | Vitest, Testing Library, MSW | Decenas de ms | Las piezas colaboran bien |
| Extremo a extremo (E2E) | La aplicación real en un navegador real | Playwright | Segundos | El flujo completo funciona |
Figura 1. Cuanto más arriba, más se parece a la realidad, pero también es más lento y más caro de mantener.
En el frontend, el grueso del esfuerzo rinde más en la capa de integración: una función suelta rara vez es donde se rompe una aplicación React. Se rompe en el encaje entre el formulario, el estado, la llamada a la API y el mensaje de error. Por eso aquí la mayoría de las pruebas renderizan un componente con sus hijos reales y solo simulan la red. Las pruebas E2E se reservan para los pocos recorridos que no pueden fallar nunca: iniciar sesión, comprar, dar de alta.
Qué probar y qué no
Prueba el comportamiento que ve quien usa el componente, no su implementación:
| Sí | No |
|---|---|
| Al pulsar “Guardar” aparece el mensaje de éxito | Que setGuardado(true) se ha llamado |
| El botón se desactiva mientras envía | Que el estado interno enviando vale true |
| Si la API responde 500 se ve un aviso | Que el useEffect tiene tal dependencia |
| Un campo inválido muestra su error | Que el componente tiene tal clase CSS |
| La lista se filtra al escribir | Que React ha renderizado dos veces |
El criterio práctico es este: si cambias cómo está hecho un componente sin cambiar lo que hace, ninguna prueba debería romperse. Una prueba que falla sin que haya un error se llama falso positivo, y es lo que hace que un equipo deje de confiar en sus pruebas.
📘 Concepto: Una prueba tiene valor si puede fallar cuando el código está mal. Si tienes dudas, rompe el código a propósito (comenta una línea) y comprueba que la prueba se pone en rojo. Esta costumbre descubre muchas pruebas que no comprueban nada.
2. Prepara el proyecto
Vamos a construir una pieza de la aplicación EMPRESA y a probarla a todos los niveles. Crea el proyecto como en el módulo 01, donde se explica también qué cambia si usas PowerShell:
npm create vite@latest modulo-22 -- --template react-ts
cd modulo-22
npm install
npm install react-router @tanstack/react-query react-hook-form @hookform/resolvers zod
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom msw @vitest/coverage-v8 @playwright/test
| Paquete | Para qué sirve |
|---|---|
vitest | Ejecuta las pruebas y aporta expect, vi y los informes |
jsdom | Un DOM escrito en JavaScript: da document y window sin abrir un navegador |
@testing-library/react | render, renderHook y el enlace con act |
@testing-library/dom | Las consultas (getByRole…). Es dependencia par: hay que instalarla |
@testing-library/user-event | Simula teclado y ratón de forma realista |
@testing-library/jest-dom | Comprobaciones como toBeInTheDocument |
msw | Simula la API en la capa de red |
@vitest/coverage-v8 | Mide la cobertura. Debe tener la misma versión que vitest |
@playwright/test | Pruebas E2E con navegador real |
Descarga la API de ejemplo. Solo la necesitarás para usar la aplicación y para Playwright: las pruebas con Vitest no dependen de ella, y ahí está parte de su valor.
Vitest reutiliza Vite
Vitest no es un sistema aparte. Usa el mismo motor de transformación que Vite, así que entiende TypeScript, JSX, CSS Modules y los alias sin configurar nada más. Por eso es tan rápido arrancando. Crea su configuración:
vitest.config.ts
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/pruebas/setup.ts'],
include: ['src/**/*.test.{ts,tsx}'],
coverage: {
provider: 'v8',
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.test.{ts,tsx}', 'src/pruebas/**', 'src/ejercicios/**', 'src/main.tsx', 'src/tipos.ts'],
reporter: ['text', 'html'],
},
},
})
| Opción | Efecto |
|---|---|
environment: 'jsdom' | Cada fichero de pruebas se ejecuta con un document simulado |
globals | Crea test, afterEach… como globales. Lo necesita Testing Library, como se explica abajo |
setupFiles | Código que corre antes de cada fichero de pruebas |
include | Qué ficheros son pruebas. Lo acotamos a src para que Vitest no ejecute las de Playwright |
Si prefieres un solo fichero, también puedes poner la clave test dentro de vite.config.ts, importando defineConfig de vitest/config.
Añade también en tsconfig.node.json los nuevos ficheros, para que TypeScript compruebe la configuración, y deja include así: ["vite.config.ts", "vitest.config.ts", "playwright.config.ts", "e2e"].
Añade estos guiones a package.json:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"cobertura": "vitest run --coverage",
"e2e": "playwright test"
}
}
npm test deja Vitest en modo vigilancia: al guardar un fichero, repite solo las pruebas afectadas, porque conoce el grafo de importaciones. npm run test:run las ejecuta una vez y termina, que es lo que necesita la integración continua.
globals: true, aunque importes igual
La opción globals: true hace que test, expect y afterEach existan como variables globales, como en Jest. En este módulo los importamos siempre de vitest, porque así se ve de dónde viene cada cosa y TypeScript no necesita configuración extra. Aun así, la opción debe estar activa, por una razón que no es evidente:
⚠️ Importante: Testing Library se engancha a las funciones globales
afterEachybeforeAllal cargarse. Singlobals: true, no las encuentra y pierdes dos cosas en silencio. La primera es la limpieza automática: cadarenderse acumula en eldocumenty la prueba siguiente encuentra dos botones. La segunda es la banderaIS_REACT_ACT_ENVIRONMENT: sin ella, React no muestra el aviso deactde la sección 8, y los errores que señala pasan inadvertidos. Si prefieres no usarglobals, llama acleanupen unafterEachy pon esa bandera atrueensetup.ts.
El fichero de arranque
src/pruebas/setup.ts
import '@testing-library/jest-dom/vitest'
import { afterAll, afterEach, beforeAll } from 'vitest'
import { servidor } from './servidor'
// Levanta la API simulada (sección 9). 'error' hace fallar cualquier
// petición que no tenga manejador, para no dejarla pasar sin darnos cuenta.
beforeAll(() => servidor.listen({ onUnhandledFrame: 'error' }))
// Borra lo añadido en cada prueba con servidor.use()
afterEach(() => servidor.resetHandlers())
afterAll(() => servidor.close())
La primera línea añade a expect las comprobaciones del DOM, tanto en ejecución como en los tipos. Si usas Jest verás @testing-library/jest-dom a secas; con Vitest hace falta la ruta /vitest.
El código que vamos a probar
Primero el modelo y el cliente de la API. Puedes borrar src/App.tsx y src/App.css, porque la aplicación arranca en src/main.tsx (sección 9) con las rutas de la sección 12. Crea estos ficheros:
src/tipos.ts
export interface Empleado {
empId: number
nombre: string
apellidos: string
email: string
oficio: string
salario: number
fechaAlta: string
deptId: number | null
departamento: string | null
}
export interface Departamento {
deptId: number
nombre: string
localidad: string | null
presupuesto: number | null
}
export interface Pagina<T> {
contenido: T[]
pagina: number
tamano: number
total: number
paginas: number
}
export interface Usuario {
email: string
nombre: string
rol: 'ADMIN' | 'USER'
}
src/api.ts
import type { Departamento, Empleado, Pagina, Usuario } from './tipos'
export const API = 'http://localhost:3000/api'
export class ErrorApi extends Error {
status: number
detalles?: Record<string, string>
constructor(status: number, mensaje: string, detalles?: Record<string, string>) {
super(mensaje)
this.status = status
this.detalles = detalles
}
}
async function peticion<T>(ruta: string, opciones?: RequestInit): Promise<T> {
const respuesta = await fetch(`${API}${ruta}`, opciones)
const cuerpo = await respuesta.json()
if (!respuesta.ok) {
throw new ErrorApi(respuesta.status, cuerpo.mensaje, cuerpo.detalles)
}
return cuerpo as T
}
export function listarEmpleados(q = '', signal?: AbortSignal) {
const parametros = new URLSearchParams({ q, tamano: '10' })
return peticion<Pagina<Empleado>>(`/empleados?${parametros}`, { signal })
}
export function obtenerEmpleado(id: string, signal?: AbortSignal) {
return peticion<Empleado>(`/empleados/${id}`, { signal })
}
export function listarDepartamentos() {
return peticion<Pagina<Departamento>>('/departamentos')
}
export function crearDepartamento(datos: Omit<Departamento, 'deptId'>, token: string) {
return peticion<Departamento>('/departamentos', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(datos),
})
}
export function iniciarSesion(email: string, password: string) {
return peticion<{ token: string; usuario: Usuario }>('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
}
3. Primeras pruebas con Vitest
Empieza por lo más sencillo, funciones puras: dan siempre la misma salida para la misma entrada.
src/utilidades/formato.ts
import type { Empleado } from '../tipos'
const moneda = new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR',
maximumFractionDigits: 0,
})
export function formatearSalario(salario: number): string {
return moneda.format(salario)
}
export function nombreCompleto(e: Pick<Empleado, 'nombre' | 'apellidos'>): string {
return `${e.nombre} ${e.apellidos}`
}
export function iniciales(nombre: string): string {
return nombre
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((parte) => parte[0].toUpperCase())
.join('')
}
src/utilidades/formato.test.ts
import { describe, expect, test } from 'vitest'
import { formatearSalario, iniciales, nombreCompleto } from './formato'
describe('nombreCompleto', () => {
test('une nombre y apellidos', () => {
expect(nombreCompleto({ nombre: 'Ana', apellidos: 'García López' })).toBe('Ana García López')
})
})
describe('iniciales', () => {
test.each([
['Ana García', 'AG'],
[' luis martínez ', 'LM'],
['Ana María García López', 'AM'],
['Cher', 'C'],
['', ''],
])('de "%s" salen "%s"', (nombre, esperado) => {
expect(iniciales(nombre)).toBe(esperado)
})
})
describe('formatearSalario', () => {
test('usa formato español y agrupa desde cinco cifras', () => {
// Intl separa el número del símbolo con un espacio duro (U+00A0)
expect(formatearSalario(2500)).toBe('2500 €')
expect(formatearSalario(25000)).toBe('25.000 €')
})
})
Ejecútalas. Los tiempos dependen de tu equipo; los de abajo se midieron en una máquina muy cargada:
npx vitest run
RUN v5.0.5 C:/.../modulo-22
✓ src/utilidades/formato.test.ts (7 tests) 41ms
Test Files 1 passed (1)
Tests 7 passed (7)
Start at 07:30:21
Duration 19.81s (environment 66%, setup 21%, transform 11%, import 1%)
Anatomía de una prueba
test(nombre, función)declara una prueba.ites un alias exacto:it('muestra...'). Elige uno y sé coherente. Un nombre útil dice lo que debe pasar, para que el informe se lea como una especificación.describeagrupa pruebas relacionadas.expect(valor)abre una comprobación y un comparador (matcher) la cierra:toBe,toEqual…- Si una comprobación falla, lanza una excepción y la prueba acaba en rojo. Si no se lanza nada, está en verde. Una prueba sin
expectsiempre pasa, así que no demuestra nada.
Casi todas las pruebas siguen el patrón AAA: preparar (Arrange), actuar (Act) y comprobar (Assert).
⚠️ Importante: Mira la prueba de
formatearSalario. Parece un capricho y es un error real:Intl.NumberFormatsepara la cifra y el€con un espacio duro, no con un espacio normal. Además, enes-ESno agrupa los millares con cuatro cifras:2500queda sin punto. Si escribestoBe('2.500 €'), la prueba falla y el mensaje parece decir que las dos cadenas son iguales. Lo mismo te pasará con las horas: en jsdom y Node el formato depende de la versión de ICU y de la zona horaria.
Comparadores más usados
| Comparador | Comprueba | Ejemplo |
|---|---|---|
toBe | Identidad (Object.is). Para primitivos | expect(total).toBe(3) |
toEqual | Igualdad profunda | expect(obj).toEqual({ a: 1 }) |
toStrictEqual | Igual, y además distingue undefined y clases | expect(obj).toStrictEqual({ a: 1 }) |
toBeNull, toBeUndefined, toBeTruthy | Lo que dicen | expect(x).toBeNull() |
toContain | Un elemento en un array o texto en cadena | expect(lista).toContain('Ana') |
toHaveLength | Longitud | expect(lista).toHaveLength(2) |
toMatch | Expresión regular o subcadena | expect(texto).toMatch(/error/i) |
toMatchObject | Un subconjunto del objeto | expect(e).toMatchObject({ empId: 100 }) |
toThrow | Que lance una excepción | expect(() => f()).toThrow('mal') |
resolves, rejects | El resultado de una promesa | await expect(p).rejects.toThrow() |
not | Invierte | expect(x).not.toBe(1) |
El error más común con objetos es usar toBe: expect({ a: 1 }).toBe({ a: 1 }) falla, porque son dos objetos distintos en memoria. Para objetos y arrays, usa toEqual.
Con promesas, siempre await. Vitest 5 hace fallar la prueba si dejas sin esperar un expect(...).resolves o .rejects. Antes pasaba en silencio, sin comprobar nada.
Preparar y limpiar
import { afterEach, beforeEach } from 'vitest'
beforeEach(() => { /* antes de cada prueba del bloque */ })
afterEach(() => { /* después de cada prueba */ })
Existen también beforeAll y afterAll, que corren una vez por bloque. La regla de oro: cada prueba debe poder ejecutarse sola y en cualquier orden. Si una depende de lo que dejó la anterior, fallarán de forma incomprensible cuando reordenes o filtres. Para ejecutar solo una, usa test.only, o npx vitest run -t "iniciales", y para saltarla temporalmente test.skip. test.todo('...') deja anotada una prueba pendiente.
Instantáneas, con moderación
toMatchInlineSnapshot() guarda el resultado dentro de la prueba la primera vez y lo compara las siguientes.
expect(iniciales('Ana García')).toMatchInlineSnapshot(`"AG"`)
Son útiles para salidas de texto cortas y estables. Una instantánea de todo un componente es una mala idea: nadie la lee, cualquier cambio la rompe y se acaba actualizando con la tecla u sin mirar.
4. Dobles de prueba con vi
Un doble de prueba sustituye a algo real durante la prueba, como un doble de cine. Sirve para aislar el código de algo lento, no determinista o difícil de provocar, como la red, el reloj o un error.
vi.fn: una función espía
vi.fn() crea una función que recuerda cómo se la ha llamado. Es perfecta para las props de tipo función y para los callbacks.
src/utilidades/reintentar.ts
export async function reintentar<T>(
tarea: () => Promise<T>,
intentos = 3,
esperaMs = 1000,
): Promise<T> {
for (let intento = 1; ; intento++) {
try {
return await tarea()
} catch (error) {
if (intento >= intentos) throw error
await new Promise((resolver) => setTimeout(resolver, esperaMs * intento))
}
}
}
src/utilidades/reintentar.test.ts
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { reintentar } from './reintentar'
describe('reintentar', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
test('devuelve el resultado a la primera si no hay error', async () => {
const tarea = vi.fn().mockResolvedValue('ok')
await expect(reintentar(tarea)).resolves.toBe('ok')
expect(tarea).toHaveBeenCalledTimes(1)
})
test('reintenta con espera creciente hasta que funciona', async () => {
const tarea = vi
.fn<() => Promise<string>>()
.mockRejectedValueOnce(new Error('caída'))
.mockRejectedValueOnce(new Error('caída'))
.mockResolvedValue('ok')
const resultado = reintentar(tarea, 3, 1000)
await vi.advanceTimersByTimeAsync(999)
expect(tarea).toHaveBeenCalledTimes(1) // aún no ha pasado el primer segundo
await vi.advanceTimersByTimeAsync(1)
expect(tarea).toHaveBeenCalledTimes(2)
await vi.advanceTimersByTimeAsync(2000) // la segunda espera dura el doble
await expect(resultado).resolves.toBe('ok')
expect(tarea).toHaveBeenCalledTimes(3)
})
test('propaga el error cuando se agotan los intentos', async () => {
const tarea = vi.fn().mockRejectedValue(new Error('caída'))
const resultado = reintentar(tarea, 2, 100)
const comprobacion = expect(resultado).rejects.toThrow('caída')
await vi.runAllTimersAsync()
await comprobacion
expect(tarea).toHaveBeenCalledTimes(2)
})
})
Métodos habituales de un doble:
| Método | Efecto |
|---|---|
mockReturnValue(v) | Siempre devuelve v |
mockResolvedValue(v), mockRejectedValue(e) | Devuelve una promesa resuelta o rechazada |
mockReturnValueOnce, mockResolvedValueOnce… | Solo en la siguiente llamada; se encadenan |
mockImplementation(fn) | Sustituye el cuerpo |
toHaveBeenCalled(), toHaveBeenCalledTimes(n) | Comprueba las llamadas |
toHaveBeenCalledWith(...) | Comprueba los argumentos de alguna llamada |
toHaveBeenCalledExactlyOnceWith(...) | Una sola llamada, con esos argumentos |
La tercera prueba tiene un detalle importante: la comprobación expect(...).rejects se prepara antes de avanzar los temporizadores. Si avanzas primero, la promesa se rechaza sin que nadie la esté escuchando y Node informa de un rechazo sin capturar.
Temporizadores falsos
vi.useFakeTimers() sustituye setTimeout, setInterval, Date y compañía por un reloj que solo avanza cuando tú lo ordenas. Una prueba de un reintento de tres segundos tarda así milisegundos. Las variantes Async (advanceTimersByTimeAsync, runAllTimersAsync) dejan además que se resuelvan las promesas entre temporizador y temporizador. Si tu código mezcla await y setTimeout, como aquí, las necesitas. Con la versión síncrona, el segundo temporizador no llega a existir.
Vuelve siempre a los temporizadores reales en un afterEach. Con vi.setSystemTime(new Date('2026-11-02')) fijas la fecha, algo imprescindible en cualquier cálculo con “hoy”. Lo verás en el ejercicio 1.
vi.spyOn: espiar sin sustituir
vi.spyOn(objeto, 'metodo') envuelve un método existente. Sigue ejecutándose el original, salvo que le pongas una implementación:
const espia = vi.spyOn(console, 'error').mockImplementation(() => {})
// ... código que escribe un error esperado
expect(espia).toHaveBeenCalled()
espia.mockRestore()
Este patrón evita ensuciar la salida cuando estás probando a propósito un error.
vi.mock: sustituir un módulo
vi.mock reemplaza un módulo entero para todos los que lo importen. Vitest eleva la llamada al principio del fichero, antes que los import, así que sirve aunque la escribas después.
src/utilidades/informe.ts
import { listarEmpleados } from '../api'
import { formatearSalario } from './formato'
export async function resumenPlantilla(): Promise<string> {
const { contenido, total } = await listarEmpleados()
if (contenido.length === 0) return 'Sin empleados'
const suma = contenido.reduce((acumulado, e) => acumulado + e.salario, 0)
return `${total} empleados, media ${formatearSalario(suma / contenido.length)}`
}
src/utilidades/informe.test.ts
import { expect, test, vi } from 'vitest'
import { listarEmpleados } from '../api'
import type { Empleado, Pagina } from '../tipos'
import { resumenPlantilla } from './informe'
vi.mock('../api') // cada exportación pasa a ser un vi.fn() que devuelve undefined
function pagina(contenido: Partial<Empleado>[]) {
return { contenido, total: contenido.length } as Pagina<Empleado>
}
test('calcula la media de salarios', async () => {
vi.mocked(listarEmpleados).mockResolvedValue(pagina([{ salario: 2000 }, { salario: 3000 }]))
await expect(resumenPlantilla()).resolves.toBe('2 empleados, media 2500 €')
expect(listarEmpleados).toHaveBeenCalledTimes(1)
})
test('avisa si no hay empleados', async () => {
vi.mocked(listarEmpleados).mockResolvedValue(pagina([]))
await expect(resumenPlantilla()).resolves.toBe('Sin empleados')
expect(listarEmpleados).toHaveBeenCalledTimes(1)
})
vi.mocked no hace nada en ejecución, solo le dice a TypeScript que esa función es un doble. La segunda prueba comprueba toHaveBeenCalledTimes(1) aunque la primera ya llamó a la función: el historial se ha borrado solo, por lo que cuenta la tabla siguiente.
⚠️ Importante: Para simular la API, en React no uses
vi.mocknivi.stubGlobal('fetch')como primera opción. Atan la prueba a cómo está escrito el código: si mañana cambiasfetchpor TanStack Query, se rompen sin que nada falle de verdad. Es mejor simular la red con MSW, como verás en la sección 9. Reservavi.mockpara módulos que no son red, como una biblioteca de gráficos que no funciona en jsdom.
Cambios de Vitest 5 que afectan a estas pruebas
Si vienes de Vitest 3 o 4, o sigues un tutorial anterior, estos cambios te afectan:
| Cambio en Vitest 5 | Consecuencia |
|---|---|
clearMocks es true por defecto | El historial de llamadas se borra antes de cada prueba. Sobran los vi.clearAllMocks() en beforeEach |
vi.mock, vi.unmock y vi.hoisted lanzan un error por debajo del nivel superior | No se pueden llamar dentro de test o describe. Para eso está vi.doMock |
Los resolves y rejects sin await hacen fallar la prueba | Antes la prueba pasaba sin comprobar nada |
test.sequential y describe.sequential desaparecen | Usa la opción { concurrent: false } |
-t compara con el nombre completo unido por ' > ' | -t "bloque prueba" deja de coincidir; escribe -t "bloque > prueba" |
toThrow('') acepta cualquier mensaje | Para exigir un mensaje vacío, usa /^$/ |
| El fichero de configuración ya no se busca en carpetas superiores | Cada proyecto de un monorepo necesita el suyo o --config |
Desaparecen vitest/coverage, vitest/reporters, vitest/snapshot… | Importa desde vitest o vitest/node |
| Exige Vite 6.4 o superior y Node 22.12 o superior | Actualiza antes el entorno |
clearMocks solo borra el historial, no la implementación. El mockResolvedValue de una prueba sigue activo en la siguiente hasta que se sobrescriba. Si quieres restaurar también las implementaciones, activa restoreMocks: true en la configuración, o llama a vi.restoreAllMocks().
5. Testing Library: probar como una persona
Vitest ejecuta las pruebas, pero no sabe nada de React. Testing Library aporta la manera de montar componentes y buscar en el DOM. Su principio guía es:
Cuanto más se parezcan tus pruebas a la forma en que se usa tu software, más confianza te darán.
Por eso no hay API para leer el estado de un componente ni sus props: una persona tampoco puede. Solo ve lo que hay en pantalla.
src/componentes/TarjetaEmpleado.tsx
import type { ReactNode } from 'react'
import type { Empleado } from '../tipos'
import { formatearSalario, nombreCompleto } from '../utilidades/formato'
interface Props {
empleado: Empleado
onBorrar?: (id: number) => void
children?: ReactNode
}
export function TarjetaEmpleado({ empleado, onBorrar, children }: Props) {
const nombre = nombreCompleto(empleado)
return (
<article className="tarjeta" aria-label={nombre}>
<h3>{nombre}</h3>
<p>
{empleado.oficio} · {empleado.departamento ?? 'Sin departamento'}
</p>
<p>Salario: {formatearSalario(empleado.salario)}</p>
{children}
{onBorrar && (
<button
type="button"
aria-label={`Borrar a ${nombre}`}
onClick={() => onBorrar(empleado.empId)}
>
Borrar
</button>
)}
</article>
)
}
Unos datos de ejemplo que compartirán todas las pruebas:
src/pruebas/datos.ts
import type { Departamento, Empleado } from '../tipos'
export const ana: Empleado = {
empId: 100,
nombre: 'Ana',
apellidos: 'García López',
email: 'ana.garcia@empresa.es',
oficio: 'DIRECTOR GENERAL',
salario: 6500,
fechaAlta: '2010-01-15',
deptId: 10,
departamento: 'DIRECCIÓN',
}
export const luis: Empleado = {
empId: 101,
nombre: 'Luis',
apellidos: 'Martínez Ruiz',
email: 'luis.martinez@empresa.es',
oficio: 'DIRECTOR',
salario: 4800,
fechaAlta: '2012-03-01',
deptId: 20,
departamento: 'VENTAS',
}
export const empleados = [ana, luis]
export const departamentos: Departamento[] = [
{ deptId: 10, nombre: 'DIRECCIÓN', localidad: 'Madrid', presupuesto: 500000 },
{ deptId: 20, nombre: 'VENTAS', localidad: 'Madrid', presupuesto: 350000 },
]
export function crearEmpleado(cambios: Partial<Empleado> = {}): Empleado {
return { ...ana, ...cambios }
}
Y la primera prueba de un componente:
src/componentes/TarjetaEmpleado.test.tsx
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, test, vi } from 'vitest'
import { ana, crearEmpleado } from '../pruebas/datos'
import { TarjetaEmpleado } from './TarjetaEmpleado'
describe('TarjetaEmpleado', () => {
test('muestra los datos del empleado', () => {
render(<TarjetaEmpleado empleado={ana} />)
expect(screen.getByRole('heading', { name: 'Ana García López' })).toBeInTheDocument()
expect(screen.getByText('DIRECTOR GENERAL · DIRECCIÓN')).toBeInTheDocument()
expect(screen.getByText(/salario: 6500/i)).toBeInTheDocument()
})
test('indica cuando no tiene departamento', () => {
render(<TarjetaEmpleado empleado={crearEmpleado({ departamento: null })} />)
expect(screen.getByText(/sin departamento/i)).toBeInTheDocument()
})
test('no muestra el botón si no se puede borrar', () => {
render(<TarjetaEmpleado empleado={ana} />)
expect(screen.queryByRole('button', { name: /borrar/i })).not.toBeInTheDocument()
})
test('avisa con el identificador al pulsar Borrar', async () => {
const usuario = userEvent.setup()
const onBorrar = vi.fn()
render(<TarjetaEmpleado empleado={ana} onBorrar={onBorrar} />)
await usuario.click(screen.getByRole('button', { name: 'Borrar a Ana García López' }))
expect(onBorrar).toHaveBeenCalledExactlyOnceWith(100)
})
test('within limita la búsqueda a una tarjeta', () => {
render(<TarjetaEmpleado empleado={ana} onBorrar={() => {}} />)
const tarjeta = screen.getByRole('article', { name: 'Ana García López' })
expect(within(tarjeta).getByRole('button')).toHaveAccessibleName('Borrar a Ana García López')
})
})
render monta el componente en un <div> dentro de document.body, dentro de act, por lo que los efectos ya se han ejecutado cuando devuelve el control. screen da acceso a las consultas sobre todo el document.
La función crearEmpleado es una fábrica: cada prueba indica solo lo que le importa. Así se ve de un vistazo por qué esa prueba tiene ese dato, y añadir un campo nuevo al tipo solo obliga a cambiar un sitio.
Por qué desapareció react-test-renderer 🔄 19.0
🔄 Cambia en React 19.0.
react-test-renderer/shallowse elimina,react-dom/test-utilsconserva soloact(obsoleto) y el resto de utilidades, comoSimulate, desaparece. Si usas una versión anterior: en 18.3 esas utilidades aún existen, pero no las uses; escribe las pruebas con Testing Library, que funciona igual en 18.3 y en 19, e importaactdereacto de@testing-library/react.
Antes era común el renderizado superficial (shallow rendering): montar un componente sin sus hijos y comprobar qué elementos devolvía. Probaba la implementación, no el resultado, y por eso cayó en desuso. Si te encuentras código con shallow(...), es de esa época.
6. Consultas accesibles
Cada consulta se forma con dos partes: cómo se comporta y por qué busca.
El prefijo: getBy, queryBy, findBy
| Prefijo | Si no hay ninguno | Si hay uno | Si hay varios | Asíncrono | Úsalo para |
|---|---|---|---|---|---|
getBy | Lanza un error | Lo devuelve | Lanza un error | No | Algo que debe estar ahora |
queryBy | Devuelve null | Lo devuelve | Lanza un error | No | Comprobar que algo no está |
findBy | Rechaza tras esperar | Lo devuelve | Rechaza | Sí | Algo que aparecerá |
getAllBy | Lanza un error | Array | Array | No | Varios elementos |
queryAllBy | [] | Array | Array | No | Contar, incluso cero |
findAllBy | Rechaza tras esperar | Array | Array | Sí | Varios que aparecerán |
El mensaje de error de getBy es tan bueno que no hace falta comprobar nada más. Pero expect(screen.getByText('x')).not.toBeInTheDocument() no funciona, porque getByText lanza su error antes de llegar a expect. Para ausencias, queryBy.
El criterio: en orden de preferencia
Figura 2. Usa la primera consulta de la lista que encaje con tu elemento.
| Prioridad | Consulta | Encuentra | Ejemplo |
|---|---|---|---|
| 1 | getByRole | Por rol accesible y nombre | getByRole('button', { name: 'Guardar' }) |
| 2 | getByLabelText | Un campo por su etiqueta | getByLabelText('Email') |
| 3 | getByPlaceholderText | Un campo por su marcador | getByPlaceholderText('Buscar...') |
| 4 | getByText | Por texto visible | getByText('Sin resultados') |
| 5 | getByDisplayValue | Un campo por su valor actual | getByDisplayValue('Madrid') |
| 6 | getByAltText, getByTitle | Imágenes, atributo title | getByAltText('Logotipo') |
| Último recurso | getByTestId | Por data-testid | getByTestId('grafico') |
getByRole encabeza la lista por una razón de fondo: usa el árbol de accesibilidad, lo mismo que un lector de pantalla. Si no puedes encontrar tu botón por su rol y su nombre, una persona que usa lector de pantalla tampoco. Tus pruebas se convierten en un control de accesibilidad gratuito. El tema se amplía en el módulo 24.
Roles implícitos
Casi nunca hace falta escribir role, porque el HTML semántico ya lo lleva:
| Elemento | Rol | Elemento | Rol |
|---|---|---|---|
<button> | button | <ul>, <ol> | list |
<a href> | link | <li> | listitem |
<h1>…<h6> | heading | <table> | table |
<input type="text">, <textarea> | textbox | <tr> | row |
<input type="checkbox"> | checkbox | <th> | columnheader |
<input type="radio"> | radio | <td> | cell |
<input type="search"> | searchbox | <nav> | navigation |
<input type="number"> | spinbutton | <main> | main |
<select> | combobox | <article> | article |
<img alt="..."> | img | <form aria-label> | form |
Un <a> sin href no es un link, y un <form> sin nombre accesible no tiene rol form. Para los mensajes dinámicos usa role="alert" (error) y role="status" (estado).
La opción name compara con el nombre accesible, que se calcula así: aria-labelledby, luego aria-label, luego el contenido o la etiqueta asociada. Por eso el botón de arriba, cuyo texto visible es “Borrar”, se llama “Borrar a Ana García López”. Otras opciones útiles de getByRole son level en encabezados, checked en casillas, pressed, expanded, selected y hidden: true para incluir lo oculto.
Comprobaciones de jest-dom
| Comprobación | Significado |
|---|---|
toBeInTheDocument() | Está en el document |
toBeVisible() | Está y no está oculto por display, hidden, etc. |
toBeDisabled(), toBeEnabled() | Estado del control |
toBeRequired(), toBeInvalid(), toBeValid() | Estado de validación |
toHaveTextContent('x') | Contiene ese texto |
toHaveValue('x'), toHaveDisplayValue('x') | Valor del campo |
toBeChecked() | Casilla marcada |
toHaveAttribute('href', '/x') | Atributo |
toHaveClass('activo') | Clase |
toHaveFocus() | Tiene el foco |
toHaveAccessibleName('x'), toHaveAccessibleDescription('x') | Nombre y descripción accesibles |
toHaveFormValues({ ... }) | Valores de todo un formulario |
Dan mensajes de error mucho mejores que expect(boton.disabled).toBe(true).
Leer los errores
Si una consulta falla, el mensaje lista los roles disponibles y el DOM. Esta es la salida real de buscar un botón “Guardar” en una tarjeta que no lo tiene:
TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "Guardar"
Here are the accessible roles:
article:
Name "Ana García López":
<article
aria-label="Ana García López"
class="tarjeta"
/>
--------------------------------------------------
heading:
Name "Ana García López":
<h3 />
--------------------------------------------------
paragraph:
Name "":
<p />
Name "":
<p />
--------------------------------------------------
Ignored nodes: comments, script, style
<body>
<div>
<article
aria-label="Ana García López"
class="tarjeta"
>
<h3>
Ana García López
</h3>
...
Tres herramientas más para depurar:
screen.debug() // imprime el DOM actual
screen.debug(tarjeta, 20000) // solo ese nodo, sin el recorte a 7000 caracteres
logRoles(contenedor) // lista todos los roles y nombres; se importa de la biblioteca
Si dudas de cuál es la consulta adecuada, el DOM del navegador ayuda: la pestaña Accessibility de las herramientas de desarrollo muestra el rol y el nombre de cada nodo.
7. Simular a la persona con user-event
fireEvent lanza un solo evento. Una persona que escribe o pulsa genera muchos. Un clic real son pointerdown, mousedown, focus, pointerup, mouseup y click, en ese orden. user-event reproduce la secuencia completa, igual que un navegador, y respeta las reglas: no se puede escribir en un campo desactivado, ni pulsar un botón con pointer-events: none, y Tab sigue el orden del foco.
src/componentes/FiltrosEmpleados.tsx
import { useState, type SubmitEvent } from 'react'
export interface Filtros {
texto: string
orden: string
soloDirectivos: boolean
}
interface Props {
onAplicar: (filtros: Filtros) => void
}
const vacio: Filtros = { texto: '', orden: 'apellidos', soloDirectivos: false }
export function FiltrosEmpleados({ onAplicar }: Props) {
const [filtros, setFiltros] = useState(vacio)
function enviar(evento: SubmitEvent<HTMLFormElement>) {
evento.preventDefault()
onAplicar(filtros)
}
return (
<form aria-label="Filtros" onSubmit={enviar}>
<label htmlFor="texto">Buscar</label>
<input
id="texto"
value={filtros.texto}
onChange={(e) => setFiltros({ ...filtros, texto: e.target.value })}
/>
<label htmlFor="orden">Ordenar por</label>
<select
id="orden"
value={filtros.orden}
onChange={(e) => setFiltros({ ...filtros, orden: e.target.value })}
>
<option value="apellidos">Apellidos</option>
<option value="-salario">Mayor salario</option>
</select>
<label>
<input
type="checkbox"
checked={filtros.soloDirectivos}
onChange={(e) => setFiltros({ ...filtros, soloDirectivos: e.target.checked })}
/>
Solo directivos
</label>
<button type="submit" disabled={filtros.texto.trim() === ''}>
Aplicar
</button>
<button type="button" onClick={() => setFiltros(vacio)}>
Limpiar
</button>
</form>
)
}
src/componentes/FiltrosEmpleados.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { FiltrosEmpleados } from './FiltrosEmpleados'
function preparar() {
const onAplicar = vi.fn()
const usuario = userEvent.setup()
render(<FiltrosEmpleados onAplicar={onAplicar} />)
return {
usuario,
onAplicar,
buscar: screen.getByRole('textbox', { name: 'Buscar' }),
orden: screen.getByRole('combobox', { name: 'Ordenar por' }),
directivos: screen.getByRole('checkbox', { name: 'Solo directivos' }),
aplicar: screen.getByRole('button', { name: 'Aplicar' }),
}
}
test('envía los filtros elegidos', async () => {
const { usuario, onAplicar, buscar, orden, directivos, aplicar } = preparar()
await usuario.type(buscar, 'garcia')
await usuario.selectOptions(orden, 'Mayor salario')
await usuario.click(directivos)
await usuario.click(aplicar)
expect(onAplicar).toHaveBeenCalledExactlyOnceWith({
texto: 'garcia',
orden: '-salario',
soloDirectivos: true,
})
})
test('no deja aplicar sin texto', async () => {
const { usuario, onAplicar, buscar, aplicar } = preparar()
expect(aplicar).toBeDisabled()
await usuario.type(buscar, ' ')
await usuario.click(aplicar)
expect(onAplicar).not.toHaveBeenCalled()
})
test('Intro en el campo envía el formulario', async () => {
const { usuario, onAplicar, buscar } = preparar()
await usuario.type(buscar, 'ana{Enter}')
expect(onAplicar).toHaveBeenCalledOnce()
})
test('Limpiar devuelve el formulario a su estado inicial', async () => {
const { usuario, buscar, orden, directivos } = preparar()
await usuario.type(buscar, 'garcia')
await usuario.selectOptions(orden, 'Mayor salario')
await usuario.click(directivos)
await usuario.click(screen.getByRole('button', { name: 'Limpiar' }))
expect(buscar).toHaveValue('')
expect(orden).toHaveDisplayValue('Apellidos')
expect(directivos).not.toBeChecked()
})
test('se puede recorrer con el teclado', async () => {
const { usuario, buscar, orden, directivos } = preparar()
await usuario.tab()
expect(buscar).toHaveFocus()
await usuario.tab()
expect(orden).toHaveFocus()
await usuario.tab()
expect(directivos).toHaveFocus()
await usuario.keyboard(' ') // la barra espaciadora marca la casilla
expect(directivos).toBeChecked()
})
La función preparar evita repetir el montaje y deja cada prueba reducida a lo que la distingue. Es mejor que un beforeEach con variables compartidas, porque cada prueba declara lo que usa.
Método de usuario | Qué hace |
|---|---|
click, dblClick, tripleClick, hover, unhover | Ratón |
type(elemento, texto) | Pulsa en el elemento y escribe tecla a tecla |
clear(elemento) | Selecciona y borra |
selectOptions(select, 'texto') | Elige una opción por valor o por texto |
upload(input, fichero) | Adjunta un File |
tab({ shift: true }) | Mueve el foco |
keyboard('{Enter}'), keyboard('{Shift>}a{/Shift}') | Teclas sueltas |
paste('texto') | Pega desde el portapapeles |
⚠️ Importante: Todos estos métodos son asíncronos: lleva siempre
await. Sin él, la prueba sigue y comprueba antes de que el evento se haya producido. Crea ademásuserEvent.setup()antes derender, una vez por prueba. Así comparten estado de teclado y puntero, y queda instalado el portapapeles simulado.
fireEvent sigue teniendo un sitio: los eventos que user-event no simula, como scroll, o fireEvent.submit en un caso concreto. Para todo lo demás, user-event.
8. Esperar lo asíncrono
Casi todo lo interesante en React ocurre después de una promesa. Hay tres herramientas para esperar.
Figura 3. findBy repite la consulta hasta que tiene éxito. Si en un segundo no lo consigue, falla.
| Herramienta | Cuándo usarla |
|---|---|
await screen.findByRole(...) | Esperar a que aparezca algo. Es el caso habitual |
await waitFor(() => expect(...)) | Esperar a que se cumpla una comprobación cualquiera |
await waitForElementToBeRemoved(() => screen.queryByRole(...)) | Esperar a que desaparezca algo. El elemento debe estar presente al empezar |
findBy es getBy dentro de waitFor. Por defecto reintenta cada 50 ms, y también con cada cambio del DOM, hasta 1000 ms. Puedes cambiar el límite por llamada, con findByText('x', {}, { timeout: 3000 }), o para todos con configure({ asyncUtilTimeout: 2000 }).
Reglas para waitFor:
// Mal: findBy ya espera, el waitFor sobra
await waitFor(() => expect(screen.getByRole('alert')).toBeInTheDocument())
// Bien
expect(await screen.findByRole('alert')).toBeInTheDocument()
// Mal: una acción con efectos dentro de waitFor se repite en cada intento
await waitFor(() => usuario.click(boton))
// Mal: varias comprobaciones juntas; si falla la primera, la segunda no informa
await waitFor(() => {
expect(a).toBe(1)
expect(b).toBe(2)
})
// Bien: espera por lo primero y comprueba después
await waitFor(() => expect(a).toBe(1))
expect(b).toBe(2)
Nunca uses una pausa fija como await new Promise(r => setTimeout(r, 500)). Es lenta cuando sobra y falla cuando en la máquina de integración continua falta tiempo.
El aviso de act
Es el mensaje que antes o después te encontrarás:
An update to Tardio inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
/* assert on the output */
This ensures that you're testing the behavior the user would see in the browser.
Learn more at https://react.dev/link/wrap-tests-with-act
act es la función con la que React sabe que está dentro de una prueba: acumula actualizaciones y efectos y los aplica todos antes de seguir, para que tu comprobación vea la pantalla definitiva. render, user-event, findBy y waitFor ya lo usan por dentro. El aviso significa que el estado cambió fuera de act, casi siempre porque la prueba terminó antes de que acabara algo asíncrono.
La solución casi nunca es añadir act a mano, sino esperar lo que el usuario vería. Si el componente sigue cargando al acabar la prueba, añade un await screen.findBy... por el resultado final. Solo necesitas act directo en código que cambia estado por su cuenta, como un temporizador falso o un renderHook, y lo verás en la sección 10.
9. Simular la API con MSW 3
Mock Service Worker intercepta las peticiones en la capa de red. Tu código llama a fetch de verdad, sin saber que la respuesta no viene de ningún servidor.
Figura 4. Cuanto más abajo se corta, más código real se ejecuta. MSW deja activo tu cliente de API, las cabeceras y el análisis del JSON.
Las ventajas sobre simular fetch a mano son muchas. Puedes cambiar fetch por Axios o TanStack Query sin tocar las pruebas. Se comprueba de verdad la URL y el método, porque una ruta mal escrita no coincide. Y los mismos manejadores valen en Vitest, en Node y en el navegador.
Los manejadores
Un manejador (handler) asocia un método y una ruta con una respuesta.
src/pruebas/manejadores.ts
import { http, HttpResponse } from 'msw/http'
import { API } from '../api'
import { departamentos, empleados } from './datos'
function pagina<T>(contenido: T[]) {
return { contenido, pagina: 1, tamano: 10, total: contenido.length, paginas: 1 }
}
export const manejadores = [
http.get(`${API}/empleados`, ({ request }) => {
const q = new URL(request.url).searchParams.get('q')?.toLowerCase() ?? ''
const filtrados = empleados.filter((e) => `${e.nombre} ${e.apellidos}`.toLowerCase().includes(q))
return HttpResponse.json(pagina(filtrados))
}),
http.get(`${API}/empleados/:id`, ({ params }) => {
const empleado = empleados.find((e) => e.empId === Number(params.id))
if (!empleado) {
return HttpResponse.json(
{ status: 404, mensaje: `Empleado ${params.id} no encontrado` },
{ status: 404 },
)
}
return HttpResponse.json(empleado)
}),
http.get(`${API}/departamentos`, () => HttpResponse.json(pagina(departamentos))),
http.post(`${API}/auth/login`, async ({ request }) => {
const { email, password } = (await request.json()) as { email: string; password: string }
if (email === 'ana.garcia@empresa.es' && password === 'secreto') {
return HttpResponse.json({
token: 'token-de-prueba',
usuario: { email, nombre: 'Ana García', rol: 'ADMIN' },
})
}
return HttpResponse.json(
{ status: 401, mensaje: 'Email o contraseña incorrectos' },
{ status: 401 },
)
}),
]
src/pruebas/servidor.ts
import { setupServer } from 'msw/node'
import { manejadores } from './manejadores'
export const servidor = setupServer(...manejadores)
El ciclo de vida es el que ya tienes en setup.ts: listen antes de todas las pruebas, resetHandlers tras cada una y close al final.
Novedades de MSW 3
Si sigues un tutorial de MSW 2, esto es lo que ya no funciona igual:
| MSW 2 | MSW 3 | Notas |
|---|---|---|
import { http, HttpResponse } from 'msw' | import { http, HttpResponse } from 'msw/http' | La documentación indica la subruta. En 3.0.3 la raíz msw aún exporta los dos y funciona, pero sigue la documentación |
import { delay } from 'msw' | import { delay } from 'msw/utils/delay' | Igual con bypass y passthrough |
server.listen({ onUnhandledRequest: 'error' }) | server.listen({ onUnhandledFrame: 'error' }) | Abarca también las conexiones WebSocket |
onUnhandledRequest(request, print) | onUnhandledFrame({ frame, defaults }) | Función con un solo objeto |
graphql.query(...) | graphql.link(url).query(...) | Siempre con un enlace |
| Node 18 o superior | Node 22.12 o superior | Campo engines de MSW 3.0.3 |
⚠️ Importante: TypeScript rechaza
onUnhandledRequestsi escribes el objeto dentro de la llamada, pero en JavaScript, o con el objeto en una variable, no hay aviso. En ejecución la opción se ignora sin más: se aplica el valor por defecto y la petición sale a la red real. Comprueba que has hecho el cambio, porque es la protección más útil de MSW.
Probar los tres estados de una carga
Este es el componente real: busca empleados con un retraso, y muestra los estados de carga, error, vacío y datos. Usa dos hooks que probaremos en la sección 10.
src/hooks/useDebounce.ts
import { useEffect, useState } from 'react'
export function useDebounce<T>(valor: T, retardoMs = 300): T {
const [retrasado, setRetrasado] = useState(valor)
useEffect(() => {
const id = setTimeout(() => setRetrasado(valor), retardoMs)
return () => clearTimeout(id)
}, [valor, retardoMs])
return retrasado
}
src/hooks/useEmpleados.ts
import { useEffect, useState } from 'react'
import { ErrorApi, listarEmpleados } from '../api'
import type { Empleado, Pagina } from '../tipos'
interface Estado {
datos: Pagina<Empleado> | null
error: string | null
cargando: boolean
}
export function useEmpleados(q: string) {
const [estado, setEstado] = useState<Estado>({ datos: null, error: null, cargando: true })
const [intento, setIntento] = useState(0)
useEffect(() => {
const controlador = new AbortController()
let vigente = true
async function cargar() {
setEstado((previo) => ({ ...previo, error: null, cargando: true }))
try {
const datos = await listarEmpleados(q, controlador.signal)
if (vigente) setEstado({ datos, error: null, cargando: false })
} catch (error) {
if (!vigente) return
const mensaje = error instanceof ErrorApi ? error.message : 'No hay conexión con el servidor'
setEstado({ datos: null, error: mensaje, cargando: false })
}
}
cargar()
return () => {
vigente = false
controlador.abort()
}
}, [q, intento])
return { ...estado, recargar: () => setIntento((n) => n + 1) }
}
src/componentes/ListaEmpleados.tsx
import { useState, type ReactNode } from 'react'
import { useDebounce } from '../hooks/useDebounce'
import { useEmpleados } from '../hooks/useEmpleados'
import type { Empleado } from '../tipos'
import { nombreCompleto } from '../utilidades/formato'
interface Props {
accion?: (empleado: Empleado) => ReactNode
}
export function ListaEmpleados({ accion }: Props) {
const [texto, setTexto] = useState('')
const q = useDebounce(texto, 300)
const { datos, error, cargando, recargar } = useEmpleados(q)
return (
<section className="tarjeta" aria-labelledby="titulo-empleados">
<h2 id="titulo-empleados">Empleados</h2>
<label htmlFor="buscar">Buscar empleado</label>
<input
id="buscar"
type="search"
value={texto}
onChange={(e) => setTexto(e.target.value)}
/>
{cargando && <p role="status">Cargando empleados...</p>}
{error && (
<div role="alert">
<p>{error}</p>
<button type="button" onClick={recargar}>
Reintentar
</button>
</div>
)}
{datos && datos.contenido.length === 0 && <p>No hay resultados para «{q}»</p>}
{datos && datos.contenido.length > 0 && (
<>
<ul aria-label="Empleados">
{datos.contenido.map((e) => (
<li key={e.empId}>
{nombreCompleto(e)}, {e.oficio} {accion?.(e)}
</li>
))}
</ul>
<p>Mostrando {datos.contenido.length} de {datos.total}</p>
</>
)}
</section>
)
}
src/componentes/ListaEmpleados.test.tsx
import { render, screen, waitForElementToBeRemoved, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { http, HttpResponse } from 'msw/http'
import { delay } from 'msw/utils/delay'
import { describe, expect, test } from 'vitest'
import { API } from '../api'
import { servidor } from '../pruebas/servidor'
import { ListaEmpleados } from './ListaEmpleados'
describe('ListaEmpleados', () => {
test('muestra la carga y después los empleados', async () => {
render(<ListaEmpleados />)
expect(screen.getByRole('status')).toHaveTextContent('Cargando empleados...')
const lista = await screen.findByRole('list', { name: 'Empleados' })
expect(within(lista).getAllByRole('listitem')).toHaveLength(2)
expect(lista).toHaveTextContent('Ana García López')
expect(screen.queryByRole('status')).not.toBeInTheDocument()
expect(screen.getByText('Mostrando 2 de 2')).toBeInTheDocument()
})
test('avisa si la API devuelve un error', async () => {
servidor.use(
http.get(`${API}/empleados`, () =>
HttpResponse.json({ status: 500, mensaje: 'Error interno' }, { status: 500 }),
),
)
render(<ListaEmpleados />)
expect(await screen.findByRole('alert')).toHaveTextContent('Error interno')
expect(screen.queryByRole('list')).not.toBeInTheDocument()
})
test('avisa si no hay red', async () => {
servidor.use(http.get(`${API}/empleados`, () => HttpResponse.error()))
render(<ListaEmpleados />)
expect(await screen.findByRole('alert')).toHaveTextContent('No hay conexión con el servidor')
})
test('Reintentar vuelve a pedir los datos', async () => {
const usuario = userEvent.setup()
servidor.use(
http.get(`${API}/empleados`, () => HttpResponse.error(), { once: true }),
)
render(<ListaEmpleados />)
const aviso = await screen.findByRole('alert')
await usuario.click(within(aviso).getByRole('button', { name: 'Reintentar' }))
expect(await screen.findByRole('list', { name: 'Empleados' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
test('filtra al escribir y pide solo el texto final', async () => {
const usuario = userEvent.setup()
const peticiones: string[] = []
servidor.events.on('request:start', ({ request }) => {
peticiones.push(new URL(request.url).search)
})
render(<ListaEmpleados />)
await screen.findByRole('list', { name: 'Empleados' })
await usuario.type(screen.getByRole('searchbox', { name: 'Buscar empleado' }), 'luis')
expect(await screen.findByText('Mostrando 1 de 1')).toBeInTheDocument()
expect(screen.getByRole('list', { name: 'Empleados' })).toHaveTextContent('Luis Martínez Ruiz')
expect(peticiones).toEqual(['?q=&tamano=10', '?q=luis&tamano=10'])
servidor.events.removeAllListeners()
})
test('indica cuando la búsqueda no da resultados', async () => {
const usuario = userEvent.setup()
render(<ListaEmpleados />)
await screen.findByRole('list', { name: 'Empleados' })
await usuario.type(screen.getByRole('searchbox'), 'zzz')
expect(await screen.findByText('No hay resultados para «zzz»')).toBeInTheDocument()
})
test('el indicador desaparece aunque la respuesta tarde', async () => {
servidor.use(
http.get(`${API}/empleados`, async () => {
await delay(200)
return HttpResponse.json({ contenido: [], pagina: 1, tamano: 10, total: 0, paginas: 0 })
}),
)
render(<ListaEmpleados />)
await waitForElementToBeRemoved(() => screen.queryByRole('status'))
expect(screen.getByText('No hay resultados para «»')).toBeInTheDocument()
})
})
Lo que debes observar:
servidor.use(...)antepone un manejador para esa prueba, y gana a los generales porque MSW usa el primero que coincide. ElresetHandlersdesetup.tslo retira al acabar.{ once: true }hace que el manejador solo responda una vez. Después se recurre al siguiente, y por eso “Reintentar” funciona. Antes erares.once, cuyo nombre aún asoma en algún comentario de los tipos.HttpResponse.error()simula un fallo de red:fetchrechaza conTypeError, igual que sin conexión. Es distinto de un 500, quefetchresuelve conok: false.delay(ms)introduce un retardo en la respuesta para poder ver la carga.servidor.eventsdeja observar el tráfico. Sirve para comprobar cuántas peticiones hubo, como el debounce. Úsalo con moderación: comprueba lo que se ve antes que las peticiones.
Comprobar lo que se envía
Para una petición de escritura, lee el cuerpo dentro del manejador y guárdalo:
let recibido: unknown
servidor.use(
http.post(`${API}/departamentos`, async ({ request }) => {
recibido = await request.json()
return HttpResponse.json({ deptId: 80 }, { status: 201 })
}),
)
// ... acciones de la prueba
expect(recibido).toEqual({ nombre: 'CALIDAD', localidad: 'Bilbao', presupuesto: 1000 })
También request.headers.get('Authorization') y, en una ruta con :id, el objeto params.
Una petición sin manejador
Con onUnhandledFrame: 'error', olvidarse de un manejador hace fallar la prueba. La salida real al pedir /api/clientes, que no está definido, es esta:
[MSW] Error: intercepted a request without a matching request handler:
• GET /api/clientes
If you still wish to intercept this unhandled request, please create a request handler for it.
Read more: https://mswjs.io/docs/http/intercepting-requests
TypeError: fetch failed
Caused by: InternalError: [MSW] Cannot bypass a request when using the "error" strategy for the "onUnhandledFrame" option.
Las estrategias son 'error', 'warn' (el valor por defecto) y 'bypass'. La petición que no tiene manejador acaba en TypeError: fetch failed, y por eso el código bajo prueba la vive como un fallo de red. Si necesitas algo a medida, pasa una función:
import { HttpNetworkFrame } from 'msw/experimental'
servidor.listen({
onUnhandledFrame({ frame, defaults }) {
if (frame instanceof HttpNetworkFrame && frame.data.request.url.includes('/analitica')) return
defaults.error()
},
})
MSW en el navegador
Los mismos manejadores sirven mientras desarrollas, sin API. En el navegador, MSW registra un Service Worker que atiende las peticiones de la página. Copia su fichero a public una sola vez:
npx msw init public/ --save
src/main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router'
import { ProveedorSesion } from './contexto/sesion'
import './index.css'
import { rutas } from './rutas'
async function arrancar() {
if (import.meta.env.VITE_SIMULAR === 'si') {
const { setupWorker } = await import('msw/browser')
const { manejadores } = await import('./pruebas/manejadores')
await setupWorker(...manejadores).start({ onUnhandledFrame: 'bypass' })
}
const router = createBrowserRouter(rutas)
const clienteQuery = new QueryClient()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={clienteQuery}>
<ProveedorSesion>
<RouterProvider router={router} />
</ProveedorSesion>
</QueryClientProvider>
</StrictMode>,
)
}
arrancar()
Arranca la aplicación simulada con la variable activada. El import dinámico hace que MSW no llegue al paquete de producción, salvo que construyas con la variable activa:
VITE_SIMULAR=si npm run dev
En PowerShell son dos órdenes: $env:VITE_SIMULAR='si'; npm run dev. Sobra onUnhandledFrame: 'bypass' si no te molestan los avisos por las peticiones de Vite y de las fuentes.

Figura 5. La aplicación funcionando con los dos empleados de datos.ts, servidos por el Service Worker de MSW. La API no está en marcha.
MSW 3 trae además un complemento para Vite, msw() de msw/vite, que sirve el fichero del worker sin copiarlo a public y da un módulo virtual virtual:msw con un objeto network. Se apoya en defineNetwork, de msw/experimental, así que todavía es una API experimental. setupWorker, como aquí, es la vía estable.
10. Pruebas de hooks
Un hook no se puede llamar fuera de un componente. renderHook crea un componente mínimo que lo llama y da acceso a lo que devuelve en result.current.
Si un hook solo lo usa un componente, pruébalo a través de ese componente, como en la sección anterior. renderHook tiene sentido para hooks reutilizables, como una biblioteca interna, o cuando la lógica tiene muchas combinaciones.
src/hooks/useSeleccion.ts
import { useCallback, useState } from 'react'
export function useSeleccion() {
const [ids, setIds] = useState<number[]>([])
const alternar = useCallback((id: number) => {
setIds((actuales) =>
actuales.includes(id) ? actuales.filter((x) => x !== id) : [...actuales, id],
)
}, [])
const limpiar = useCallback(() => setIds([]), [])
return { ids, total: ids.length, alternar, limpiar }
}
src/hooks/useSeleccion.test.ts
import { act, renderHook } from '@testing-library/react'
import { expect, test } from 'vitest'
import { useSeleccion } from './useSeleccion'
test('empieza sin nada seleccionado', () => {
const { result } = renderHook(() => useSeleccion())
expect(result.current.ids).toEqual([])
expect(result.current.total).toBe(0)
})
test('alternar añade y quita', () => {
const { result } = renderHook(() => useSeleccion())
act(() => result.current.alternar(100))
act(() => result.current.alternar(101))
expect(result.current.ids).toEqual([100, 101])
act(() => result.current.alternar(100))
expect(result.current.ids).toEqual([101])
})
test('dos cambios en el mismo act se acumulan', () => {
const { result } = renderHook(() => useSeleccion())
act(() => {
result.current.alternar(100)
result.current.alternar(101)
})
expect(result.current.total).toBe(2)
})
test('limpiar vacía la selección y mantiene la función estable', () => {
const { result } = renderHook(() => useSeleccion())
const alternarInicial = result.current.alternar
act(() => result.current.alternar(100))
act(() => result.current.limpiar())
expect(result.current.ids).toEqual([])
expect(result.current.alternar).toBe(alternarInicial)
})
Aquí act sí se usa directamente: quien cambia el estado es la prueba, sin user-event que lo envuelva. Lee siempre result.current después del act, nunca lo desestructures al principio. const { ids } = result.current guarda la instantánea de ese render, igual que un cierre en un componente, y después de actualizar seguirá con el valor antiguo.
Con temporizadores falsos y rerender
rerender vuelve a renderizar el hook, con nuevas props si las indicas en initialProps:
src/hooks/useDebounce.test.ts
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { useDebounce } from './useDebounce'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
test('devuelve el valor inicial sin esperar', () => {
const { result } = renderHook(() => useDebounce('ana'))
expect(result.current).toBe('ana')
})
test('solo cambia cuando deja de cambiar durante el retardo', () => {
const { result, rerender } = renderHook(({ valor }) => useDebounce(valor, 300), {
initialProps: { valor: 'a' },
})
rerender({ valor: 'an' })
act(() => vi.advanceTimersByTime(200))
rerender({ valor: 'ana' })
act(() => vi.advanceTimersByTime(200))
expect(result.current).toBe('a') // el temporizador se ha reiniciado
act(() => vi.advanceTimersByTime(100))
expect(result.current).toBe('ana')
})
test('limpia el temporizador al desmontar', () => {
const { unmount } = renderHook(() => useDebounce('ana'))
expect(vi.getTimerCount()).toBe(1)
unmount()
expect(vi.getTimerCount()).toBe(0)
})
⚠️ Importante: Con temporizadores falsos,
findByywaitForse pueden quedar colgados: esperan consetTimeout, y ese reloj está parado. Testing Library solo sabe adelantar el reloj falso de Jest, porque busca una variable globaljest, y con Vitest la prueba se queda parada hasta agotar el tiempo límite de la propia prueba. Hay dos salidas: usar temporizadores reales en esa prueba, ovi.useFakeTimers({ shouldAdvanceTime: true }), con el que el reloj falso avanza junto con el real. Si además usasuser-event, indícale cómo avanzar el reloj:userEvent.setup({ advanceTimers: vi.advanceTimersByTime }).
Un hook asíncrono
src/hooks/useEmpleados.test.ts
import { act, renderHook, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw/http'
import { expect, test } from 'vitest'
import { API } from '../api'
import { servidor } from '../pruebas/servidor'
import { useEmpleados } from './useEmpleados'
test('carga los empleados', async () => {
const { result } = renderHook(() => useEmpleados(''))
expect(result.current.cargando).toBe(true)
await waitFor(() => expect(result.current.cargando).toBe(false))
expect(result.current.datos?.total).toBe(2)
expect(result.current.error).toBeNull()
})
test('vuelve a pedir datos cuando cambia el texto', async () => {
const { result, rerender } = renderHook(({ q }) => useEmpleados(q), {
initialProps: { q: '' },
})
await waitFor(() => expect(result.current.datos?.total).toBe(2))
rerender({ q: 'ana' })
await waitFor(() => expect(result.current.datos?.total).toBe(1))
})
test('recargar repite la petición tras un error', async () => {
servidor.use(http.get(`${API}/empleados`, () => HttpResponse.error(), { once: true }))
const { result } = renderHook(() => useEmpleados(''))
await waitFor(() => expect(result.current.error).not.toBeNull())
act(() => result.current.recargar())
await waitFor(() => expect(result.current.datos?.total).toBe(2))
expect(result.current.error).toBeNull()
})
11. Probar Context
Un componente que lee un contexto necesita su proveedor. Si no se lo das, en la prueba fallará igual que fallaría en la aplicación.
El proveedor de sesión 🆕 19.0
🆕 Desde React 19.0.
<SesionContext value={...}>no existe en React 18. Si usas una versión anterior: escribe<SesionContext.Provider value={...}>. Las pruebas son idénticas, porque no dependen de esa sintaxis.
src/contexto/sesion.tsx
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
import { iniciarSesion } from '../api'
import type { Usuario } from '../tipos'
interface DatosSesion {
usuario: Usuario
token: string
}
interface Sesion {
usuario: Usuario | null
token: string | null
entrar: (email: string, password: string) => Promise<void>
salir: () => void
}
const SesionContext = createContext<Sesion | null>(null)
interface Props {
children: ReactNode
inicial?: DatosSesion | null
}
export function ProveedorSesion({ children, inicial = null }: Props) {
const [datos, setDatos] = useState(inicial)
const valor = useMemo<Sesion>(
() => ({
usuario: datos?.usuario ?? null,
token: datos?.token ?? null,
entrar: async (email, password) => setDatos(await iniciarSesion(email, password)),
salir: () => setDatos(null),
}),
[datos],
)
return <SesionContext value={valor}>{children}</SesionContext>
}
export function useSesion(): Sesion {
const sesion = useContext(SesionContext)
if (!sesion) throw new Error('useSesion debe usarse dentro de ProveedorSesion')
return sesion
}
export function BarraSesion() {
const { usuario, salir } = useSesion()
if (!usuario) return <p>Invitado</p>
return (
<p>
{usuario.nombre} ({usuario.rol}){' '}
<button type="button" onClick={salir}>
Salir
</button>
</p>
)
}
La prop inicial deja empezar con una sesión ya abierta. Es un punto de entrada para pruebas que además sirve para la aplicación, por ejemplo al restaurar la sesión desde localStorage.
Tres formas de suministrar el contexto
Envolver a mano. Vale para un caso suelto: render(<ProveedorSesion><BarraSesion /></ProveedorSesion>).
La opción wrapper. render(<BarraSesion />, { wrapper: ProveedorSesion }). Sirve igual en renderHook.
Un render propio. Si casi todos los componentes necesitan lo mismo, se crea uno que monte todos los proveedores, con una sesión configurable. Así, si la aplicación añade un proveedor, cambias un solo sitio.
src/pruebas/utilidades.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, type RenderOptions } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactElement, ReactNode } from 'react'
import { ProveedorSesion } from '../contexto/sesion'
import type { Usuario } from '../tipos'
export const admin: Usuario = {
email: 'ana.garcia@empresa.es',
nombre: 'Ana García',
rol: 'ADMIN',
}
interface Opciones extends Omit<RenderOptions, 'wrapper'> {
usuario?: Usuario | null
}
export function crearClienteQuery() {
return new QueryClient({ defaultOptions: { queries: { retry: false } } })
}
export function renderConApp(interfaz: ReactElement, { usuario = null, ...opciones }: Opciones = {}) {
const clienteQuery = crearClienteQuery()
const inicial = usuario ? { usuario, token: 'token-de-prueba' } : null
function Proveedores({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={clienteQuery}>
<ProveedorSesion inicial={inicial}>{children}</ProveedorSesion>
</QueryClientProvider>
)
}
return {
usuario: userEvent.setup(),
...render(interfaz, { wrapper: Proveedores, ...opciones }),
}
}
src/contexto/sesion.test.tsx
import { act, render, renderHook, screen } from '@testing-library/react'
import { expect, test, vi } from 'vitest'
import { admin, renderConApp } from '../pruebas/utilidades'
import { BarraSesion, ProveedorSesion, useSesion } from './sesion'
test('sin sesión muestra Invitado', () => {
render(<BarraSesion />, { wrapper: ProveedorSesion })
expect(screen.getByText('Invitado')).toBeInTheDocument()
})
test('con sesión muestra el nombre y el rol', () => {
renderConApp(<BarraSesion />, { usuario: admin })
expect(screen.getByText(/Ana García \(ADMIN\)/)).toBeInTheDocument()
})
test('Salir cierra la sesión', async () => {
const { usuario } = renderConApp(<BarraSesion />, { usuario: admin })
await usuario.click(screen.getByRole('button', { name: 'Salir' }))
expect(screen.getByText('Invitado')).toBeInTheDocument()
})
test('entrar guarda el usuario y el token', async () => {
const { result } = renderHook(() => useSesion(), { wrapper: ProveedorSesion })
await act(() => result.current.entrar('ana.garcia@empresa.es', 'secreto'))
expect(result.current.usuario?.rol).toBe('ADMIN')
expect(result.current.token).toBe('token-de-prueba')
})
test('useSesion falla fuera del proveedor', () => {
// React escribe el error en la consola: lo silenciamos porque es lo esperado
vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => renderHook(() => useSesion())).toThrow('useSesion debe usarse dentro')
vi.restoreAllMocks()
})
Observa que no se simula useSesion con vi.mock. Se usa el proveedor real con unos datos iniciales. Así se prueba también el proveedor, y una prueba no pasa con un valor que el proveedor nunca daría.
12. Probar React Router
⚠️ Importante: React Router 8 exige
react >= 19.2.7y Node 22.22 o superior, como se explica en el módulo 18. Con una versión anterior de React usa React Router 7, cuya API para estas pruebas es muy parecida, aunque aquí no se ha ejecutado.
Estas son las páginas de la aplicación y su árbol de rutas.
src/componentes/FormularioDepartamento.tsx
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { ErrorApi } from '../api'
const esquema = z.object({
nombre: z.string().trim().min(2, 'Escribe al menos 2 letras'),
localidad: z.string().trim().min(1, 'La localidad es obligatoria'),
presupuesto: z
.number('El presupuesto debe ser un número')
.min(0, 'El presupuesto no puede ser negativo'),
})
export type DatosDepartamento = z.infer<typeof esquema>
interface Props {
onGuardar: (datos: DatosDepartamento) => Promise<void>
}
export function FormularioDepartamento({ onGuardar }: Props) {
const {
register,
handleSubmit,
setError,
formState: { errors, isSubmitting },
} = useForm<DatosDepartamento>({ resolver: zodResolver(esquema) })
async function enviar(datos: DatosDepartamento) {
try {
await onGuardar(datos)
} catch (error) {
if (error instanceof ErrorApi && error.detalles) {
for (const [campo, mensaje] of Object.entries(error.detalles)) {
setError(campo as keyof DatosDepartamento, { message: mensaje })
}
} else {
setError('root', { message: 'No se ha podido guardar. Inténtalo de nuevo.' })
}
}
}
return (
<form aria-label="Nuevo departamento" onSubmit={handleSubmit(enviar)} noValidate>
<div>
<label htmlFor="nombre">Nombre</label>
<input
id="nombre"
aria-invalid={!!errors.nombre}
aria-describedby="error-nombre"
{...register('nombre')}
/>
<p id="error-nombre">{errors.nombre?.message}</p>
</div>
<div>
<label htmlFor="localidad">Localidad</label>
<input
id="localidad"
aria-invalid={!!errors.localidad}
aria-describedby="error-localidad"
{...register('localidad')}
/>
<p id="error-localidad">{errors.localidad?.message}</p>
</div>
<div>
<label htmlFor="presupuesto">Presupuesto</label>
<input
id="presupuesto"
type="number"
aria-invalid={!!errors.presupuesto}
aria-describedby="error-presupuesto"
{...register('presupuesto', { valueAsNumber: true })}
/>
<p id="error-presupuesto">{errors.presupuesto?.message}</p>
</div>
{errors.root && <p role="alert">{errors.root.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Guardando...' : 'Guardar'}
</button>
</form>
)
}
src/componentes/FichaDepartamento.tsx
import { use } from 'react'
import type { Departamento } from '../tipos'
export function FichaDepartamento({ promesa }: { promesa: Promise<Departamento> }) {
const departamento = use(promesa)
return (
<dl>
<dt>Departamento</dt>
<dd>{departamento.nombre}</dd>
<dt>Localidad</dt>
<dd>{departamento.localidad}</dd>
</dl>
)
}
src/paginas.tsx
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import {
isRouteErrorResponse,
Link,
Navigate,
NavLink,
Outlet,
useLoaderData,
useLocation,
useNavigate,
useRouteError,
type LoaderFunctionArgs,
} from 'react-router'
import { crearDepartamento, ErrorApi, listarDepartamentos, obtenerEmpleado } from './api'
import { FormularioDepartamento, type DatosDepartamento } from './componentes/FormularioDepartamento'
import { ListaEmpleados } from './componentes/ListaEmpleados'
import { TarjetaEmpleado } from './componentes/TarjetaEmpleado'
import { BarraSesion, useSesion } from './contexto/sesion'
import type { Empleado } from './tipos'
export function Disposicion() {
return (
<>
<header className="cabecera">
<strong>EMPRESA</strong>
<nav aria-label="Principal">
<NavLink to="/empleados">Empleados</NavLink>
<NavLink to="/departamentos" end>Departamentos</NavLink>
<NavLink to="/departamentos/nuevo">Nuevo departamento</NavLink>
</nav>
<BarraSesion />
</header>
<main>
<Outlet />
</main>
</>
)
}
export function PaginaEmpleados() {
return (
<ListaEmpleados
accion={(e) => (
<Link to={`/empleados/${e.empId}`} aria-label={`Ver ficha de ${e.nombre}`}>
Ver ficha
</Link>
)}
/>
)
}
export function cargarEmpleado({ params, request }: LoaderFunctionArgs) {
return obtenerEmpleado(params.id!, request.signal)
}
export function PaginaDetalle() {
const empleado = useLoaderData<Empleado>()
return (
<>
<TarjetaEmpleado empleado={empleado} />
<Link to="/empleados">Volver al listado</Link>
</>
)
}
export function PaginaError() {
const error = useRouteError()
const mensaje =
error instanceof ErrorApi
? error.message
: isRouteErrorResponse(error)
? `Página no encontrada (${error.status})`
: 'Se ha producido un error inesperado'
return (
<div role="alert">
<h2>Algo ha ido mal</h2>
<p>{mensaje}</p>
<Link to="/empleados">Volver al listado</Link>
</div>
)
}
export function PaginaDepartamentos() {
const { data, isPending, isError } = useQuery({
queryKey: ['departamentos'],
queryFn: listarDepartamentos,
})
if (isPending) return <p role="status">Cargando departamentos...</p>
if (isError) return <p role="alert">No se han podido cargar los departamentos</p>
return (
<table className="tarjeta">
<caption>Departamentos</caption>
<thead>
<tr>
<th scope="col">Nombre</th>
<th scope="col">Localidad</th>
</tr>
</thead>
<tbody>
{data.contenido.map((d) => (
<tr key={d.deptId}>
<td>{d.nombre}</td>
<td>{d.localidad}</td>
</tr>
))}
</tbody>
</table>
)
}
export function RutaProtegida() {
const { usuario } = useSesion()
const ubicacion = useLocation()
if (!usuario) {
return <Navigate to="/login" replace state={{ desde: ubicacion.pathname }} />
}
return <Outlet />
}
export function PaginaNuevoDepartamento() {
const { token } = useSesion()
const navegar = useNavigate()
const clienteQuery = useQueryClient()
async function guardar(datos: DatosDepartamento) {
await crearDepartamento(datos, token!)
await clienteQuery.invalidateQueries({ queryKey: ['departamentos'] })
navegar('/departamentos')
}
return <FormularioDepartamento onGuardar={guardar} />
}
function BotonEntrar() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? 'Entrando...' : 'Entrar'}
</button>
)
}
export function PaginaLogin() {
const { entrar } = useSesion()
const navegar = useNavigate()
const ubicacion = useLocation()
async function accion(_previo: string | null, datos: FormData) {
try {
await entrar(String(datos.get('email')), String(datos.get('password')))
} catch (error) {
return error instanceof ErrorApi ? error.message : 'No hay conexión con el servidor'
}
navegar(ubicacion.state?.desde ?? '/empleados', { replace: true })
return null
}
const [error, accionFormulario] = useActionState(accion, null)
return (
<form action={accionFormulario} aria-label="Inicio de sesión" className="tarjeta">
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="password">Contraseña</label>
<input id="password" name="password" type="password" required />
{error && <p role="alert">{error}</p>}
<BotonEntrar />
</form>
)
}
src/rutas.tsx
import { Navigate, type RouteObject } from 'react-router'
import {
cargarEmpleado,
Disposicion,
PaginaDepartamentos,
PaginaDetalle,
PaginaEmpleados,
PaginaError,
PaginaLogin,
PaginaNuevoDepartamento,
RutaProtegida,
} from './paginas'
export const rutas: RouteObject[] = [
{
path: '/',
element: <Disposicion />,
errorElement: <PaginaError />,
children: [
{ index: true, element: <Navigate to="/empleados" replace /> },
{ path: 'login', element: <PaginaLogin /> },
{ path: 'empleados', element: <PaginaEmpleados /> },
{
path: 'empleados/:id',
loader: cargarEmpleado,
element: <PaginaDetalle />,
errorElement: <PaginaError />,
},
{ path: 'departamentos', element: <PaginaDepartamentos /> },
{
element: <RutaProtegida />,
children: [{ path: 'departamentos/nuevo', element: <PaginaNuevoDepartamento /> }],
},
],
},
]
Añade estas reglas a src/index.css, tras borrar su contenido, para que la aplicación se vea presentable:
:root {
font-family: 'Segoe UI', system-ui, sans-serif;
color: #263238;
background: #f5f6fa;
}
body {
margin: 0;
}
.cabecera {
display: flex;
gap: 1.5rem;
align-items: center;
padding: 0.5rem 1.25rem;
background: #1a237e;
color: #fff;
}
.cabecera nav {
display: flex;
gap: 1rem;
}
.cabecera a {
color: #fff;
}
.cabecera a.active {
font-weight: 700;
}
.cabecera p {
margin: 0 0 0 auto;
}
main {
padding: 1.25rem;
}
.tarjeta {
background: #fff;
border: 1px solid #c5cae9;
border-radius: 10px;
padding: 1rem 1.25rem;
max-width: 680px;
}
label {
display: block;
margin-top: 0.5rem;
}
input {
font: inherit;
padding: 0.3rem 0.5rem;
}
button {
font: inherit;
margin-top: 0.5rem;
}
[role='alert'] {
color: #b71c1c;
}
createMemoryRouter: un router sin navegador
createBrowserRouter lee la barra de direcciones. En una prueba conviene empezar en una URL concreta y sin historial previo, y para eso está createMemoryRouter, que guarda las rutas en memoria. Exportar rutas como un array, separado de main.tsx, permite probar las rutas reales, con sus cargadores y sus errores.
Esta utilidad combina el render propio de la sección anterior con un router en memoria:
src/pruebas/router.tsx
import { createMemoryRouter, RouterProvider, type RouteObject } from 'react-router'
import type { Usuario } from '../tipos'
import { renderConApp } from './utilidades'
interface Opciones {
rutas: RouteObject[]
url?: string
usuario?: Usuario | null
}
export function renderConRouter({ rutas, url = '/', usuario = null }: Opciones) {
const router = createMemoryRouter(rutas, { initialEntries: [url] })
return {
router,
...renderConApp(<RouterProvider router={router} />, { usuario }),
}
}
src/rutas.test.tsx
import { screen, within } from '@testing-library/react'
import { describe, expect, test } from 'vitest'
import { renderConRouter } from './pruebas/router'
import { admin } from './pruebas/utilidades'
import { rutas } from './rutas'
describe('navegación', () => {
test('la raíz redirige a empleados', async () => {
const { router } = renderConRouter({ rutas })
expect(await screen.findByRole('heading', { name: 'Empleados' })).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/empleados')
})
test('el enlace lleva a la ficha, y el cargador trae sus datos', async () => {
const { usuario } = renderConRouter({ rutas, url: '/empleados' })
await usuario.click(await screen.findByRole('link', { name: 'Ver ficha de Luis' }))
const ficha = await screen.findByRole('article', { name: 'Luis Martínez Ruiz' })
expect(within(ficha).getByText(/salario/i)).toHaveTextContent('Salario: 4800')
})
test('marca el enlace activo', async () => {
renderConRouter({ rutas, url: '/departamentos' })
const nav = screen.getByRole('navigation', { name: 'Principal' })
expect(within(nav).getByRole('link', { name: 'Departamentos' })).toHaveAttribute(
'aria-current',
'page',
)
expect(await screen.findByRole('table')).toBeInTheDocument()
})
test('muestra el error si el empleado no existe', async () => {
renderConRouter({ rutas, url: '/empleados/9999' })
expect(await screen.findByRole('alert')).toHaveTextContent('Empleado 9999 no encontrado')
})
test('muestra el error si la ruta no existe', async () => {
renderConRouter({ rutas, url: '/no-existe' })
expect(await screen.findByRole('alert')).toHaveTextContent('Página no encontrada (404)')
})
})
describe('ruta protegida', () => {
test('sin sesión lleva al login', async () => {
const { router } = renderConRouter({ rutas, url: '/departamentos/nuevo' })
expect(await screen.findByRole('form', { name: 'Inicio de sesión' })).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/login')
})
test('con sesión muestra el formulario', async () => {
renderConRouter({ rutas, url: '/departamentos/nuevo', usuario: admin })
expect(await screen.findByRole('form', { name: 'Nuevo departamento' })).toBeInTheDocument()
})
})
Siempre que puedas, comprueba lo que se ve. router.state.location.pathname está para cuando la URL es justo lo que quieres verificar, como en una redirección.
📘 Concepto: Los cargadores reciben un
Requestcon suAbortSignal, y aquí se ejecutan de verdad, dentro de jsdom. Con Vitest 5 y jsdom 30 no necesitan ningún ajuste: las pruebas de arriba lo demuestran concargarEmpleado.
13. Probar formularios
El formulario se probó ya en parte en la sección 7. Ahora va el caso completo: FormularioDepartamento, de la sección 12, que usa React Hook Form y Zod, explicados en el módulo 21. No necesitas conocer esas bibliotecas para probarlo, y eso es lo importante: la prueba no sabe qué biblioteca hay detrás. Si lo reescribieras con Actions, estas pruebas seguirían valiendo.
Una prueba de formulario debe cubrir cuatro cosas: la validación en cliente, el envío correcto, el estado de “enviando” y los errores del servidor.
src/componentes/FormularioDepartamento.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { ErrorApi } from '../api'
import { FormularioDepartamento, type DatosDepartamento } from './FormularioDepartamento'
function preparar(onGuardar = vi.fn<(d: DatosDepartamento) => Promise<void>>()) {
const usuario = userEvent.setup()
render(<FormularioDepartamento onGuardar={onGuardar} />)
return {
usuario,
onGuardar,
nombre: screen.getByRole('textbox', { name: 'Nombre' }),
localidad: screen.getByRole('textbox', { name: 'Localidad' }),
presupuesto: screen.getByRole('spinbutton', { name: 'Presupuesto' }),
guardar: screen.getByRole('button', { name: 'Guardar' }),
}
}
test('muestra los errores de validación y no envía', async () => {
const { usuario, onGuardar, nombre, localidad, guardar } = preparar()
await usuario.click(guardar)
expect(nombre).toHaveAccessibleDescription('Escribe al menos 2 letras')
expect(nombre).toBeInvalid()
expect(localidad).toHaveAccessibleDescription('La localidad es obligatoria')
expect(onGuardar).not.toHaveBeenCalled()
})
test('el presupuesto no puede ser negativo', async () => {
const { usuario, presupuesto, guardar } = preparar()
await usuario.type(presupuesto, '-5')
await usuario.click(guardar)
expect(presupuesto).toHaveAccessibleDescription('El presupuesto no puede ser negativo')
})
test('envía los datos limpios y convertidos', async () => {
const { usuario, onGuardar, nombre, localidad, presupuesto, guardar } = preparar()
await usuario.type(nombre, ' CALIDAD ')
await usuario.type(localidad, 'Bilbao')
await usuario.type(presupuesto, '1000')
await usuario.click(guardar)
expect(onGuardar).toHaveBeenCalledExactlyOnceWith({
nombre: 'CALIDAD',
localidad: 'Bilbao',
presupuesto: 1000,
})
})
test('desactiva el botón mientras guarda', async () => {
let terminar = () => {}
const onGuardar = vi.fn(() => new Promise<void>((resolver) => (terminar = resolver)))
const { usuario, nombre, localidad, presupuesto, guardar } = preparar(onGuardar)
await usuario.type(nombre, 'CALIDAD')
await usuario.type(localidad, 'Bilbao')
await usuario.type(presupuesto, '1000')
await usuario.click(guardar)
expect(await screen.findByRole('button', { name: 'Guardando...' })).toBeDisabled()
terminar()
expect(await screen.findByRole('button', { name: 'Guardar' })).toBeEnabled()
})
test('coloca en su campo los errores 422 del servidor', async () => {
const onGuardar = vi.fn().mockRejectedValue(
new ErrorApi(422, 'Hay datos no válidos', { nombre: 'Ya existe un departamento con ese nombre' }),
)
const { usuario, nombre, localidad, presupuesto, guardar } = preparar(onGuardar)
await usuario.type(nombre, 'VENTAS')
await usuario.type(localidad, 'Madrid')
await usuario.type(presupuesto, '1')
await usuario.click(guardar)
expect(await screen.findByText('Ya existe un departamento con ese nombre')).toBeInTheDocument()
expect(nombre).toBeInvalid()
})
test('muestra un error general si el fallo no es de validación', async () => {
const onGuardar = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))
const { usuario, nombre, localidad, presupuesto, guardar } = preparar(onGuardar)
await usuario.type(nombre, 'CALIDAD')
await usuario.type(localidad, 'Bilbao')
await usuario.type(presupuesto, '1')
await usuario.click(guardar)
expect(await screen.findByRole('alert')).toHaveTextContent('No se ha podido guardar')
})
Para probar el estado intermedio, la cuarta prueba crea una promesa que controlas tú: guarda el resolver en una variable y lo llama cuando interesa. Es mucho más fiable que un delay y sirve para cualquier estado “pendiente”.
El formulario recibe onGuardar por props, y por eso aquí basta un vi.fn(). Una prop es la frontera natural del componente. La integración con la API se prueba en la página, en la sección siguiente.
14. Probar TanStack Query, Actions y Suspense
src/paginas.test.tsx
import { screen } from '@testing-library/react'
import { http, HttpResponse } from 'msw/http'
import { delay } from 'msw/utils/delay'
import { describe, expect, test } from 'vitest'
import { API } from './api'
import { PaginaDepartamentos } from './paginas'
import { departamentos } from './pruebas/datos'
import { renderConRouter } from './pruebas/router'
import { servidor } from './pruebas/servidor'
import { admin, renderConApp } from './pruebas/utilidades'
import { rutas } from './rutas'
describe('PaginaDepartamentos (TanStack Query)', () => {
test('pinta una fila por departamento', async () => {
renderConApp(<PaginaDepartamentos />)
expect(screen.getByRole('status')).toBeInTheDocument()
expect(await screen.findByRole('cell', { name: 'VENTAS' })).toBeInTheDocument()
expect(screen.getAllByRole('row')).toHaveLength(1 + departamentos.length)
})
test('avisa si la petición falla', async () => {
servidor.use(http.get(`${API}/departamentos`, () => HttpResponse.error()))
renderConApp(<PaginaDepartamentos />)
expect(await screen.findByRole('alert')).toHaveTextContent('No se han podido cargar')
})
})
describe('alta de departamento', () => {
async function rellenar(usuario: ReturnType<typeof renderConRouter>['usuario']) {
await usuario.type(await screen.findByRole('textbox', { name: 'Nombre' }), 'CALIDAD')
await usuario.type(screen.getByRole('textbox', { name: 'Localidad' }), 'Bilbao')
await usuario.type(screen.getByRole('spinbutton', { name: 'Presupuesto' }), '1000')
await usuario.click(screen.getByRole('button', { name: 'Guardar' }))
}
test('envía el token, y el listado muestra el nuevo departamento', async () => {
let autorizacion: string | null = null
let cuerpo: unknown
const nuevo = { deptId: 80, nombre: 'CALIDAD', localidad: 'Bilbao', presupuesto: 1000 }
servidor.use(
http.post(`${API}/departamentos`, async ({ request }) => {
autorizacion = request.headers.get('Authorization')
cuerpo = await request.json()
servidor.use(
http.get(`${API}/departamentos`, () =>
HttpResponse.json({ contenido: [...departamentos, nuevo], pagina: 1, tamano: 10, total: 3, paginas: 1 }),
),
)
return HttpResponse.json(nuevo, { status: 201 })
}),
)
const { usuario } = renderConRouter({ rutas, url: '/departamentos/nuevo', usuario: admin })
await rellenar(usuario)
expect(await screen.findByRole('cell', { name: 'CALIDAD' })).toBeInTheDocument()
expect(autorizacion).toBe('Bearer token-de-prueba')
expect(cuerpo).toEqual({ nombre: 'CALIDAD', localidad: 'Bilbao', presupuesto: 1000 })
})
test('muestra los errores 422 de la API en el campo', async () => {
servidor.use(
http.post(`${API}/departamentos`, () =>
HttpResponse.json(
{ status: 422, mensaje: 'Hay datos no válidos', detalles: { nombre: 'Ya existe un departamento con ese nombre' } },
{ status: 422 },
),
),
)
const { usuario } = renderConRouter({ rutas, url: '/departamentos/nuevo', usuario: admin })
await rellenar(usuario)
expect(await screen.findByText('Ya existe un departamento con ese nombre')).toBeInTheDocument()
})
})
describe('login con Actions', () => {
async function entrar(
usuario: ReturnType<typeof renderConRouter>['usuario'],
password: string,
) {
await usuario.type(screen.getByRole('textbox', { name: 'Email' }), 'ana.garcia@empresa.es')
await usuario.type(screen.getByLabelText('Contraseña'), password)
await usuario.click(screen.getByRole('button', { name: 'Entrar' }))
}
test('muestra el mensaje de la API si la contraseña es incorrecta', async () => {
const { usuario } = renderConRouter({ rutas, url: '/login' })
await entrar(usuario, 'mala')
expect(await screen.findByRole('alert')).toHaveTextContent('Email o contraseña incorrectos')
})
test('desactiva el botón mientras la acción está pendiente', async () => {
servidor.use(
http.post(`${API}/auth/login`, async () => {
await delay(100)
return HttpResponse.json({ status: 401, mensaje: 'No' }, { status: 401 })
}),
)
const { usuario } = renderConRouter({ rutas, url: '/login' })
await entrar(usuario, 'secreto')
expect(await screen.findByRole('button', { name: 'Entrando...' })).toBeDisabled()
expect(await screen.findByRole('alert')).toBeInTheDocument()
})
test('tras entrar vuelve a la página que se pedía', async () => {
const { usuario, router } = renderConRouter({ rutas, url: '/departamentos/nuevo' })
await screen.findByRole('form', { name: 'Inicio de sesión' })
await entrar(usuario, 'secreto')
expect(await screen.findByRole('form', { name: 'Nuevo departamento' })).toBeInTheDocument()
expect(router.state.location.pathname).toBe('/departamentos/nuevo')
expect(screen.getByText(/Ana García/)).toBeInTheDocument()
})
})
TanStack Query
Dos reglas, que ya aplica renderConApp, evitan casi todos los problemas de probar TanStack Query (módulo 19):
- Un
QueryClientnuevo por prueba. Si lo compartes, la caché de una prueba se cuela en la siguiente, que recibe datos sin pedirlos. retry: false. Por defecto, una consulta fallida se reintenta tres veces con espera creciente, yfindByRole('alert')agota su segundo antes de ver el error.
En la prueba de alta se ve algo interesante: el manejador del POST instala otro para el GET. Así se simula que el servidor ha cambiado, y comprueba de verdad que invalidateQueries ha vuelto a pedir el listado. Si quitas esa línea de PaginaNuevoDepartamento, la prueba debe ponerse en rojo: compruébalo.
Actions 🆕 19.0
🆕 Desde React 19.0.
useActionState,useFormStatusy<form action={función}>no existen en React 18. Si usas una versión anterior: escribe el login cononSubmit,useStatepara el error y otrouseStatepara el estado pendiente. Las pruebas de esta sección valen sin cambios, porque solo miran la pantalla.
Las Actions del módulo 16 se prueban exactamente igual que cualquier formulario: rellenar, pulsar y esperar. user-event hace clic en el botón, se dispara el submit real, React ejecuta la acción dentro de una transición y findBy espera el resultado. No hay nada específico. La única diferencia con un onSubmit está dentro del componente, que es justo lo que una buena prueba no debe conocer.
Para ver el estado pendiente de useFormStatus, MSW retrasa la respuesta. Sin ese delay podría pasar tan rápido que findBy nunca lo llegase a ver.
Suspense y use 🆕 19.0
🆕 Desde React 19.0.
use(promesa)no existe en React 18. Si usas una versión anterior: carga los datos con un efecto o conuseSuspenseQuery. La parte delazyySuspensefunciona igual en 18.3, y una prueba confindBytambién.
src/componentes/FichaDepartamento.test.tsx
import { act, render, screen } from '@testing-library/react'
import { Suspense } from 'react'
import { expect, test } from 'vitest'
import { departamentos } from '../pruebas/datos'
import { FichaDepartamento } from './FichaDepartamento'
test('muestra el respaldo y luego los datos', async () => {
let resolver = (_: (typeof departamentos)[number]) => {}
const promesa = new Promise<(typeof departamentos)[number]>((r) => (resolver = r))
await act(async () => {
render(
<Suspense fallback={<p role="status">Cargando ficha...</p>}>
<FichaDepartamento promesa={promesa} />
</Suspense>,
)
})
expect(screen.getByRole('status')).toHaveTextContent('Cargando ficha...')
await act(async () => resolver(departamentos[1]))
expect(screen.getByText('VENTAS')).toBeInTheDocument()
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
Es uno de los pocos casos en los que necesitas act directamente, y además en su forma asíncrona. Un render normal suspende dentro de un act síncrono, y React lo advierte: A component suspended inside an act scope, but the act call was not awaited. Con ese render sin esperar, el árbol se queda en el respaldo y findByText('VENTAS') agota el tiempo aunque la promesa ya se haya resuelto. Con await act(async () => ...) React procesa el suspenso y sus reintentos.
La promesa se crea fuera del componente, por la misma razón que en el módulo 15: debe ser estable. Una promesa nueva en cada render de la prueba dejaría el árbol suspendido para siempre.
15. Cobertura
La cobertura mide qué parte del código se ha ejecutado durante las pruebas. Vitest la obtiene del motor V8.
npm run cobertura
Esta es la salida real con todas las pruebas del módulo, ejercicios incluidos. En esta ejecución, el informe de texto solo listó los ficheros que no llegan al 100 %. La duración es la de una máquina muy cargada y ejecutando los ficheros de uno en uno; en un equipo normal son segundos:
Test Files 21 passed (21)
Tests 82 passed (82)
Duration 450.21s (environment 63%, setup 18%, tests 8%, import 8%, worker 1%, transform 3%)
% Coverage report from v8
-------------------|---------|----------|---------|---------|-------------------
File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s
-------------------|---------|----------|---------|---------|-------------------
All files | 99.35 | 94.02 | 100 | 100 |
src | 100 | 90.47 | 100 | 100 |
paginas.tsx | 100 | 88.88 | 100 | 100 | 160-162
src/hooks | 97.29 | 77.77 | 100 | 100 |
useEmpleados.ts | 95.45 | 66.66 | 100 | 100 | 23-25
-------------------|---------|----------|---------|---------|-------------------
| Columna | Significado |
|---|---|
% Stmts | Sentencias ejecutadas |
% Branch | Ramas ejecutadas: cada lado de un if, un ? : o un && |
% Funcs | Funciones llamadas al menos una vez |
% Lines | Líneas ejecutadas |
Uncovered Line #s | Líneas que ninguna prueba ha ejecutado |
La opción html del informe genera coverage/index.html, con el código fuente coloreado. Es lo más útil: abre un fichero y verás en rojo lo que no se ha ejecutado y en amarillo las ramas a medias. Recuerda añadir coverage a .gitignore.

Figura 6. Informe HTML real de las pruebas de este módulo.

Figura 7. Al abrir un fichero se ve cuántas veces se ejecutó cada línea. En amarillo, dos ramas que ninguna prueba recorre: el fallo de red en el login y el login sin página de origen.
Cómo interpretarla
La cobertura dice lo que no se ha probado, pero no dice que lo ejecutado esté bien probado. Una prueba sin expect que renderiza toda la aplicación da un 90 % y no comprueba nada. Por eso:
- Sirve como mapa para buscar huecos, no como nota. Lo que vale es lo que hay en rojo y si importa.
- Perseguir el 100 % sale caro: los últimos puntos suelen ser ramas imposibles que empujan a escribir pruebas artificiales.
- Un objetivo razonable en una aplicación es 70-85 %, y más alto en la lógica de negocio que en la presentación.
Para que la cobertura no baje sin que nadie se dé cuenta, fija umbrales. Si no se alcanzan, vitest run --coverage termina con error y la integración continua falla:
coverage: {
provider: 'v8',
thresholds: { lines: 80, functions: 80, branches: 75, statements: 80 },
},
16. Playwright: extremo a extremo
Una prueba E2E abre un navegador real, carga la aplicación real, que habla con la API real, y actúa como una persona. Comprueba lo que Vitest no puede: que el build funciona, CSS que oculta un botón, cookies, navegación real, y que las piezas encajan de verdad.
| Vitest + Testing Library | Playwright | |
|---|---|---|
| Entorno | jsdom, en Node | Chromium, Firefox, WebKit |
| API | Simulada con MSW | Real, o simulada con page.route |
| Velocidad | Unos ms por prueba | Unos cientos de ms o segundos |
| CSS y diseño | No se aplica de verdad | Se aplica |
| Fragilidad | Baja | Más alta, depende de datos y tiempos |
| Cuántas | Cientos | Unas pocas decenas |
Tras instalar @playwright/test (sección 2), descarga los navegadores:
npx playwright install chromium
playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI, // falla si queda un test.only olvidado
retries: process.env.CI ? 2 : 0,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'http://127.0.0.1:5173',
locale: 'es-ES',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
webServer: [
{
// Ajusta la ruta a la carpeta donde descomprimiste la API
command: 'node ../empresa-api/server.mjs',
url: 'http://localhost:3000/api',
reuseExistingServer: !process.env.CI,
},
{
command: 'npm run dev -- --host 127.0.0.1 --port 5173 --strictPort',
url: 'http://127.0.0.1:5173',
reuseExistingServer: !process.env.CI,
},
],
})
webServer arranca la API y Vite antes de las pruebas y los apaga al acabar. Si ya los tienes en marcha, los reutiliza.
e2e/empleados.spec.ts
import { expect, test } from '@playwright/test'
test.describe('empleados', () => {
test('busca un empleado y abre su ficha', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveURL(/\/empleados$/)
await page.getByRole('searchbox', { name: 'Buscar empleado' }).fill('martinez')
await expect(page.getByRole('list', { name: 'Empleados' }).getByRole('listitem')).toHaveCount(1)
await page.getByRole('link', { name: 'Ver ficha de Luis' }).click()
await expect(page).toHaveURL(/\/empleados\/101$/)
await expect(page.getByRole('heading', { name: 'Luis Martínez Ruiz' })).toBeVisible()
})
test('una ficha inexistente muestra el error', async ({ page }) => {
await page.goto('/empleados/9999')
await expect(page.getByRole('alert')).toContainText('Empleado 9999 no encontrado')
})
test('avisa si la API está caída y permite reintentar', async ({ page }) => {
await page.route('**/api/empleados*', (ruta) => ruta.abort())
await page.goto('/empleados')
const aviso = page.getByRole('alert')
await expect(aviso).toContainText('No hay conexión')
await page.unroute('**/api/empleados*')
await aviso.getByRole('button', { name: 'Reintentar' }).click()
await expect(page.getByRole('list', { name: 'Empleados' })).toBeVisible()
})
})
e2e/sesion.spec.ts
import { expect, test } from '@playwright/test'
test('una ruta protegida exige sesión y después vuelve a ella', async ({ page }) => {
const nombre = `PRUEBA ${Date.now()}`
await page.goto('/departamentos/nuevo')
await expect(page).toHaveURL(/\/login$/)
await page.getByRole('textbox', { name: 'Email' }).fill('ana.garcia@empresa.es')
await page.getByLabel('Contraseña').fill('secreto')
await page.getByRole('button', { name: 'Entrar' }).click()
await expect(page).toHaveURL(/\/departamentos\/nuevo$/)
await page.getByRole('textbox', { name: 'Nombre' }).fill(nombre)
await page.getByRole('textbox', { name: 'Localidad' }).fill('Bilbao')
await page.getByRole('spinbutton', { name: 'Presupuesto' }).fill('1000')
await page.getByRole('button', { name: 'Guardar' }).click()
await expect(page).toHaveURL(/\/departamentos$/)
await expect(page.getByRole('cell', { name: nombre })).toBeVisible()
})
test('rechaza una contraseña incorrecta', async ({ page }) => {
await page.goto('/login')
await page.getByRole('textbox', { name: 'Email' }).fill('ana.garcia@empresa.es')
await page.getByLabel('Contraseña').fill('mala')
await page.getByRole('button', { name: 'Entrar' }).click()
await expect(page.getByRole('alert')).toHaveText('Email o contraseña incorrectos')
await expect(page).toHaveURL(/\/login$/)
})
Esta es la salida real, con las dos pruebas del ejercicio 7 incluidas. El número de procesos cambia según tu equipo:
npm run e2e
Running 7 tests using 2 workers
ok 2 [chromium] › e2e\departamentos.spec.ts:5:1 › una lista vacía solo tiene los encabezados (7.1s)
ok 1 [chromium] › e2e\departamentos.spec.ts:13:1 › muestra la carga mientras llega la respuesta (7.9s)
ok 3 [chromium] › e2e\empleados.spec.ts:4:3 › empleados › busca un empleado y abre su ficha (3.0s)
ok 4 [chromium] › e2e\empleados.spec.ts:17:3 › empleados › una ficha inexistente muestra el error (3.4s)
ok 5 [chromium] › e2e\empleados.spec.ts:23:3 › empleados › avisa si la API está caída y permite reintentar (3.8s)
ok 6 [chromium] › e2e\sesion.spec.ts:3:1 › una ruta protegida exige sesión y después vuelve a ella (3.7s)
ok 7 [chromium] › e2e\sesion.spec.ts:23:1 › rechaza una contraseña incorrecta (3.5s)
7 passed (50.4s)
Las consultas son casi las mismas que en Testing Library, con una diferencia de fondo:
- Un localizador (
page.getByRole(...)) no busca nada al crearlo: es una descripción que se resuelve cada vez que actúas, por lo que no hay elementos “caducados”. - Las acciones esperan solas a que el elemento sea visible, estable y esté habilitado.
clickno necesitaawait waitFor. - Las comprobaciones sobre el localizador reintentan hasta 5 segundos por defecto:
await expect(locator).toBeVisible().
⚠️ Importante: Escribe siempre
await expect(locator).toBeVisible(), y nuncaexpect(await locator.isVisible()).toBe(true). El segundo lee una vez y no reintenta, así que es la fuente clásica de pruebas que fallan a veces. Evita tambiénpage.waitForTimeout.
Datos y aislamiento
Las pruebas E2E comparten una base de datos, y aquí ese problema es de verdad, porque fullyParallel las ejecuta a la vez. Por eso el alta usa un nombre único con Date.now(), ya que la API rechaza los departamentos repetidos con un 422. Sin esto, la prueba pasaría la primera vez y fallaría la segunda.
Para la red, page.route intercepta en el navegador lo que necesites, como en la prueba de la API caída. Provocar ese estado en un servidor real sería mucho más difícil. También puede devolver datos:
await page.route('**/api/departamentos*', (ruta) =>
ruta.fulfill({ json: { contenido: [], pagina: 1, tamano: 10, total: 0, paginas: 0 } }),
)
Si muchas pruebas necesitan haber iniciado sesión, no repitas el formulario en todas. Hazlo una vez en un proyecto de preparación y guarda el estado con page.context().storageState({ path: 'sesion.json' }), que luego se carga con use: { storageState: 'sesion.json' }. Esto solo sirve si la sesión vive en cookies o localStorage. En esta aplicación vive en memoria, porque al recargar se pierde.
Depurar
| Orden | Para qué |
|---|---|
npx playwright test --ui | Interfaz con la línea de tiempo: la mejor forma de trabajar |
npx playwright test --headed | Ver el navegador |
npx playwright test --debug | Paso a paso con el inspector |
npx playwright test -g "busca" | Filtrar por nombre |
npx playwright show-report | Abrir el informe HTML |
npx playwright show-trace traza.zip | Abrir una traza grabada |
npx playwright codegen 127.0.0.1:5173 | Grabar acciones y generar el código |
La traza registra cada acción con el DOM, la red y la consola. Con trace: 'on-first-retry' se graba al reintentar, de modo que cuando algo falla solo en integración continua tienes la grabación.

Figura 8. Informe HTML de Playwright con las siete pruebas superadas.

Figura 9. Captura hecha con page.screenshot tras enviar una contraseña incorrecta. Con screenshot: 'only-on-failure', Playwright guarda una igual en test-results cada vez que falla una prueba.
En integración continua, añade npx playwright install --with-deps chromium antes de ejecutar. El módulo 25 monta el flujo completo.
17. Errores frecuentes y depuración
| Síntoma | Causa | Solución |
|---|---|---|
Found multiple elements en pruebas que pasan por separado | Falta la limpieza: sin globals, Testing Library no desmonta | Activa globals: true o llama a cleanup en afterEach |
document is not defined | Falta environment: 'jsdom' o se usa otro fichero de configuración | Revisa vitest.config.ts |
Invalid Chai property: toBeInTheDocument | No se ha cargado jest-dom | import '@testing-library/jest-dom/vitest' en setup.ts |
Aviso not wrapped in act(...) | La prueba acaba antes que lo asíncrono | Espera con findBy el estado final |
| Prueba que pasa aunque el código esté mal | Falta await o no hay expect real | Rompe el código a propósito para verla fallar |
Unable to find role="button" con un botón visible | Está oculto, o el nombre no es el que crees | Usa screen.debug o logRoles |
findBy agota el tiempo con TanStack Query | Los reintentos de la consulta | retry: false en el cliente de pruebas |
| Datos de otra prueba | QueryClient o manejadores compartidos | Cliente por prueba y resetHandlers |
window.matchMedia is not a function | jsdom no lo implementa | Simúlalo con vi.stubGlobal en setup.ts |
ResizeObserver is not defined | jsdom no lo implementa | Simúlalo igual |
| Peticiones que salen a la red real | Se escribió onUnhandledRequest o falta un manejador | onUnhandledFrame: 'error' |
| La prueba pasa sola y falla con las demás | Estado compartido o temporizadores falsos sin restaurar | Limpia en afterEach |
vi.mock lanza un error dentro de un test | Vitest 5 lo prohíbe | Muévelo arriba o usa vi.doMock |
jsdom no lo implementa todo: no hay diseño (getBoundingClientRect devuelve ceros), ni matchMedia, ni ResizeObserver, ni IntersectionObserver, ni desplazamiento real. Si tu componente depende de ello, simúlalo:
vi.stubGlobal('matchMedia', (consulta: string) => ({
matches: false,
media: consulta,
addEventListener: () => {},
removeEventListener: () => {},
}))
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
Y si lo importante de la prueba es precisamente el diseño, es señal de que corresponde a Playwright.
Para ver una interfaz con todas las pruebas, instala @vitest/ui en la misma versión de Vitest y ejecuta npx vitest --ui.
18. Ejercicios
Las soluciones de Vitest van en src/ejercicios/, y vitest.config.ts ya las recoge con su patrón include. Las de Playwright van en e2e/.
Ejercicio 1: Antigüedad con reloj falso
Escribe antiguedad(fechaAlta: string): number, que devuelve los años completos desde la fecha de alta hasta hoy. Pruébala fijando la fecha actual, incluyendo el día justo antes y el día del aniversario.
Ver solución
src/ejercicios/antiguedad.ts
export function antiguedad(fechaAlta: string, hoy = new Date()): number {
const alta = new Date(fechaAlta)
const anios = hoy.getFullYear() - alta.getFullYear()
const aniversario = new Date(hoy.getFullYear(), alta.getMonth(), alta.getDate())
return hoy < aniversario ? anios - 1 : anios
}
src/ejercicios/antiguedad.test.ts
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
import { antiguedad } from './antiguedad'
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
test.each([
['2026-01-14T12:00:00', 15],
['2026-01-15T12:00:00', 16],
['2026-11-02T12:00:00', 16],
])('el %s lleva %i años', (hoy, esperado) => {
vi.setSystemTime(new Date(hoy))
expect(antiguedad('2010-01-15')).toBe(esperado)
})
new Date() usa el reloj falso. El valor por defecto del parámetro se evalúa al llamar, no al definir la función, por lo que setSystemTime le afecta. Sin fijar la fecha, esta prueba empezaría a fallar en cuanto pasase un año.
Ejercicio 2: Botón de confirmación en dos pasos
Crea BotonConfirmar: al pulsarlo por primera vez, cambia su texto a “¿Seguro?”. La segunda pulsación llama a onConfirmar y vuelve al estado inicial. Si el foco sale del botón, se cancela. Pruébalo sin mirar el estado interno.
Ver solución
src/ejercicios/BotonConfirmar.tsx
import { useState } from 'react'
interface Props {
texto: string
onConfirmar: () => void
}
export function BotonConfirmar({ texto, onConfirmar }: Props) {
const [armado, setArmado] = useState(false)
function pulsar() {
if (armado) onConfirmar()
setArmado(!armado)
}
return (
<button type="button" onClick={pulsar} onBlur={() => setArmado(false)}>
{armado ? '¿Seguro?' : texto}
</button>
)
}
src/ejercicios/BotonConfirmar.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { expect, test, vi } from 'vitest'
import { BotonConfirmar } from './BotonConfirmar'
test('pide confirmación antes de actuar', async () => {
const usuario = userEvent.setup()
const onConfirmar = vi.fn()
render(<BotonConfirmar texto="Borrar" onConfirmar={onConfirmar} />)
await usuario.click(screen.getByRole('button', { name: 'Borrar' }))
expect(onConfirmar).not.toHaveBeenCalled()
await usuario.click(screen.getByRole('button', { name: '¿Seguro?' }))
expect(onConfirmar).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: 'Borrar' })).toBeInTheDocument()
})
test('se cancela si el foco sale del botón', async () => {
const usuario = userEvent.setup()
const onConfirmar = vi.fn()
render(<BotonConfirmar texto="Borrar" onConfirmar={onConfirmar} />)
await usuario.click(screen.getByRole('button', { name: 'Borrar' }))
await usuario.tab()
expect(screen.getByRole('button', { name: 'Borrar' })).toBeInTheDocument()
expect(onConfirmar).not.toHaveBeenCalled()
})
Este caso solo funciona bien con user-event. fireEvent.click no mueve el foco, así que blur nunca se produciría al pulsar otra cosa.
Ejercicio 3: Arregla esta prueba
Esta prueba pasa, pero tiene cuatro problemas. Encuéntralos y reescríbela.
test('login', async () => {
const { container } = render(<Formulario />)
const input = container.querySelector('.campo-email') as HTMLInputElement
fireEvent.change(input, { target: { value: 'ana@empresa.es' } })
fireEvent.click(screen.getByTestId('enviar'))
await new Promise((r) => setTimeout(r, 500))
expect(screen.getByText('Bienvenida') !== null).toBe(true)
})
Ver solución
container.querySelectorpor clase: depende del CSS y no comprueba la accesibilidad. UsagetByRolecon el nombre del campo.getByTestIdpara un botón con texto visible: usagetByRole('button', ...).fireEventen lugar deuser-event.- La pausa fija de 500 ms, y la comprobación artificial, que ni siquiera podría fallar por el
expect, ya quegetByTextlanza su propio error.
src/ejercicios/Bienvenida.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useState } from 'react'
import { expect, test } from 'vitest'
// Componente mínimo equivalente al del enunciado
function Formulario() {
const [enviado, setEnviado] = useState(false)
return (
<form
onSubmit={(e) => {
e.preventDefault()
setTimeout(() => setEnviado(true), 100)
}}
>
<label htmlFor="correo">Email</label>
<input id="correo" className="campo-email" type="email" />
<button type="submit">Enviar</button>
{enviado && <p>Bienvenida</p>}
</form>
)
}
test('saluda tras enviar el email', async () => {
const usuario = userEvent.setup()
render(<Formulario />)
await usuario.type(screen.getByRole('textbox', { name: 'Email' }), 'ana@empresa.es')
await usuario.click(screen.getByRole('button', { name: 'Enviar' }))
expect(await screen.findByText('Bienvenida')).toBeInTheDocument()
})
Ejercicio 4: Lista de departamentos vacía y con error
Con PaginaDepartamentos y MSW, añade una prueba que compruebe que un error 500 muestra el aviso, y otra para un 500 seguido de un éxito tras recargar la página (desmonta y vuelve a montar con otro cliente). Después, modifica el componente para que muestre “No hay departamentos” si la lista está vacía, y pruébalo.
Ver solución
src/ejercicios/ListaDepartamentos.tsx
import { useQuery } from '@tanstack/react-query'
import { listarDepartamentos } from '../api'
export function ListaDepartamentos() {
const { data, isPending, isError } = useQuery({
queryKey: ['departamentos'],
queryFn: listarDepartamentos,
})
if (isPending) return <p role="status">Cargando...</p>
if (isError) return <p role="alert">No se han podido cargar los departamentos</p>
if (data.contenido.length === 0) return <p>No hay departamentos</p>
return (
<ul aria-label="Departamentos">
{data.contenido.map((d) => (
<li key={d.deptId}>{d.nombre}</li>
))}
</ul>
)
}
src/ejercicios/ListaDepartamentos.test.tsx
import { screen } from '@testing-library/react'
import { http, HttpResponse } from 'msw/http'
import { expect, test } from 'vitest'
import { API } from '../api'
import { servidor } from '../pruebas/servidor'
import { renderConApp } from '../pruebas/utilidades'
import { ListaDepartamentos } from './ListaDepartamentos'
const error500 = () =>
HttpResponse.json({ status: 500, mensaje: 'Error interno' }, { status: 500 })
test('muestra un aviso con un 500', async () => {
servidor.use(http.get(`${API}/departamentos`, error500))
renderConApp(<ListaDepartamentos />)
expect(await screen.findByRole('alert')).toBeInTheDocument()
})
test('recupera los datos al volver a montar', async () => {
servidor.use(http.get(`${API}/departamentos`, error500, { once: true }))
const { unmount } = renderConApp(<ListaDepartamentos />)
await screen.findByRole('alert')
unmount()
renderConApp(<ListaDepartamentos />)
expect(await screen.findByRole('list', { name: 'Departamentos' })).toBeInTheDocument()
})
test('indica cuando la lista está vacía', async () => {
servidor.use(
http.get(`${API}/departamentos`, () =>
HttpResponse.json({ contenido: [], pagina: 1, tamano: 10, total: 0, paginas: 0 }),
),
)
renderConApp(<ListaDepartamentos />)
expect(await screen.findByText('No hay departamentos')).toBeInTheDocument()
})
Ejercicio 5: useLocalStorage
Escribe y prueba useLocalStorage<T>(clave, inicial), que lee el valor guardado al montar y lo guarda al cambiar. Si el contenido guardado no es JSON válido, usa el valor inicial.
Ver solución
src/ejercicios/useLocalStorage.ts
import { useCallback, useState } from 'react'
function leer<T>(clave: string, inicial: T): T {
try {
const guardado = localStorage.getItem(clave)
return guardado === null ? inicial : (JSON.parse(guardado) as T)
} catch {
return inicial
}
}
export function useLocalStorage<T>(clave: string, inicial: T) {
const [valor, setValor] = useState(() => leer(clave, inicial))
const guardar = useCallback(
(nuevo: T) => {
localStorage.setItem(clave, JSON.stringify(nuevo))
setValor(nuevo)
},
[clave],
)
return [valor, guardar] as const
}
src/ejercicios/useLocalStorage.test.ts
import { act, renderHook } from '@testing-library/react'
import { beforeEach, expect, test } from 'vitest'
import { useLocalStorage } from './useLocalStorage'
beforeEach(() => {
localStorage.clear()
})
test('usa el valor inicial si no hay nada guardado', () => {
const { result } = renderHook(() => useLocalStorage('tema', 'claro'))
expect(result.current[0]).toBe('claro')
})
test('lee lo guardado', () => {
localStorage.setItem('tema', '"oscuro"')
const { result } = renderHook(() => useLocalStorage('tema', 'claro'))
expect(result.current[0]).toBe('oscuro')
})
test('guarda al cambiar', () => {
const { result } = renderHook(() => useLocalStorage('tema', 'claro'))
act(() => result.current[1]('oscuro'))
expect(result.current[0]).toBe('oscuro')
expect(localStorage.getItem('tema')).toBe('"oscuro"')
})
test('tolera un valor corrupto', () => {
localStorage.setItem('tema', '{no es json')
const { result } = renderHook(() => useLocalStorage('tema', 'claro'))
expect(result.current[0]).toBe('claro')
})
localStorage.clear() en beforeEach es esencial: jsdom conserva localStorage entre las pruebas de un mismo fichero.
Ejercicio 6: Rutas con rol
Crea RutaAdmin, que solo deja pasar a usuarios con rol ADMIN y muestra “No tienes permiso” al resto. Pruébala con renderConRouter y unas rutas mínimas escritas en la propia prueba, para los casos de invitado, USER y ADMIN.
Ver solución
src/ejercicios/RutaAdmin.tsx
import { Navigate, Outlet } from 'react-router'
import { useSesion } from '../contexto/sesion'
export function RutaAdmin() {
const { usuario } = useSesion()
if (!usuario) return <Navigate to="/login" replace />
if (usuario.rol !== 'ADMIN') return <p role="alert">No tienes permiso</p>
return <Outlet />
}
src/ejercicios/RutaAdmin.test.tsx
import { screen } from '@testing-library/react'
import type { RouteObject } from 'react-router'
import { expect, test } from 'vitest'
import { renderConRouter } from '../pruebas/router'
import { admin } from '../pruebas/utilidades'
import { RutaAdmin } from './RutaAdmin'
const rutas: RouteObject[] = [
{ path: '/login', element: <h1>Login</h1> },
{ element: <RutaAdmin />, children: [{ path: '/panel', element: <h1>Panel</h1> }] },
]
test('un invitado va al login', () => {
renderConRouter({ rutas, url: '/panel' })
expect(screen.getByRole('heading', { name: 'Login' })).toBeInTheDocument()
})
test('un usuario normal no tiene permiso', () => {
renderConRouter({ rutas, url: '/panel', usuario: { ...admin, rol: 'USER' } })
expect(screen.getByRole('alert')).toHaveTextContent('No tienes permiso')
expect(screen.queryByRole('heading', { name: 'Panel' })).not.toBeInTheDocument()
})
test('un administrador entra', () => {
renderConRouter({ rutas, url: '/panel', usuario: admin })
expect(screen.getByRole('heading', { name: 'Panel' })).toBeInTheDocument()
})
Con rutas de prueba mínimas, la comprobación se centra en la guarda y no depende de cargadores ni de red. Por eso aquí no hace falta findBy.
Ejercicio 7: Playwright con API simulada
Escribe una prueba E2E que simule con page.route un listado de departamentos vacío y compruebe que la tabla solo tiene la fila de encabezados. Escribe otra en la que la petición tarde 500 ms y se vea el mensaje de carga.
Ver solución
e2e/departamentos.spec.ts
import { expect, test } from '@playwright/test'
const vacia = { contenido: [], pagina: 1, tamano: 10, total: 0, paginas: 0 }
test('una lista vacía solo tiene los encabezados', async ({ page }) => {
await page.route('**/api/departamentos', (ruta) => ruta.fulfill({ json: vacia }))
await page.goto('/departamentos')
await expect(page.getByRole('row')).toHaveCount(1)
})
test('muestra la carga mientras llega la respuesta', async ({ page }) => {
await page.route('**/api/departamentos', async (ruta) => {
await new Promise((resolver) => setTimeout(resolver, 500))
await ruta.fulfill({ json: vacia })
})
await page.goto('/departamentos')
await expect(page.getByRole('status')).toHaveText('Cargando departamentos...')
await expect(page.getByRole('table')).toBeVisible()
})
Ejercicio 8: Prueba de cobertura dirigida
Ejecuta npm run cobertura y abre el informe. En PaginaError, src/paginas.tsx tiene una rama sin probar: el error inesperado. Escribe una prueba que la ejecute con una ruta cuyo cargador lance un Error normal.
Ver solución
src/ejercicios/PaginaError.test.tsx
import { screen } from '@testing-library/react'
import type { RouteObject } from 'react-router'
import { expect, test, vi } from 'vitest'
import { PaginaError } from '../paginas'
import { renderConRouter } from '../pruebas/router'
test('un error desconocido muestra un mensaje genérico sin detalles', async () => {
const rutas: RouteObject[] = [
{
path: '/',
element: <p>Inicio</p>,
errorElement: <PaginaError />,
loader: () => {
throw new Error('detalle interno que no debe verse')
},
},
]
vi.spyOn(console, 'error').mockImplementation(() => {})
renderConRouter({ rutas })
const aviso = await screen.findByRole('alert')
expect(aviso).toHaveTextContent('Se ha producido un error inesperado')
expect(aviso).not.toHaveTextContent('detalle interno')
vi.restoreAllMocks()
})
Ten en cuenta para qué sirve esta prueba, más allá del porcentaje: comprueba que no se filtran detalles internos, y eso sí tiene valor.
Ejercicio 9 (propuesto): Umbrales de cobertura
Añade thresholds a vitest.config.ts con los valores actuales redondeados a la baja. Borra una prueba, comprueba que npm run cobertura termina con error y mira el mensaje. Restáurala después.
Ejercicio 10 (propuesto): Borrado de empleados
Añade a PaginaEmpleados un botón para borrar que use BotonConfirmar y DELETE /api/empleados/:id. Prueba con MSW el borrado correcto, el 403 de un usuario sin rol y el 409 de un empleado con subordinados. Añade una prueba E2E que cree un empleado y lo borre, de modo que no altere los datos de partida.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Ejecutar pruebas | vitest, vitest run | Reutiliza Vite; modo vigilancia por defecto |
| Declarar | describe, test, test.each | Cada prueba es independiente y dice lo que promete |
| Comprobar | expect, toBe, toEqual | await siempre con promesas |
| Doble de función | vi.fn, vi.spyOn | Recuerda sus llamadas; se usa en props y callbacks |
| Doble de módulo | vi.mock | Solo en el nivel superior; no para la red |
| Reloj | vi.useFakeTimers, vi.setSystemTime | Restaura siempre; Async si hay promesas |
| Vitest 5 | clearMocks: true | El historial se borra solo; resolves sin await falla |
| Montar | render, screen, within | Solo se ve lo que ve una persona |
| Buscar | getByRole, queryBy, findBy | get debe estar, query para ausencias, find espera |
| Comprobar el DOM | toBeInTheDocument, toHaveValue | jest-dom, cargado con la ruta /vitest |
| Interactuar | userEvent.setup(), click, type, tab | Secuencia real de eventos; siempre con await |
| Esperar | findBy, waitFor | Sin pausas fijas |
act | Interno en casi todo | El aviso significa que la prueba acabó antes de tiempo |
| Simular la red | http, HttpResponse, setupServer | Se importa de msw/http; corta en la red |
| Cambio por prueba | servidor.use, { once: true } | Gana al general; resetHandlers lo retira |
| Sin manejador | onUnhandledFrame: 'error' | Sustituye a onUnhandledRequest en MSW 3 |
| Hooks | renderHook, act, rerender | Lee result.current tras actualizar |
| Context | wrapper, render propio | Proveedor real con datos iniciales |
| Router | createMemoryRouter, initialEntries | Prueba las rutas reales, con cargadores |
| Query | QueryClient por prueba, retry: false | Sin caché compartida ni reintentos |
| Actions y Suspense | findBy | Se prueban como cualquier formulario |
| Cobertura | vitest run --coverage, thresholds | Mapa de huecos, no una nota |
| E2E | page, getByRole, expect(locator) | Reintenta sola; para los recorridos críticos |
| Red en E2E | page.route, fulfill, abort | Provoca estados difíciles de conseguir |