Tema
Testing
Los tests te dan confianza para cambiar código sin romper lo que ya funciona, y son algo que los clientes y equipos serios valoran. Aprenderás a probar componentes de React con Vitest y React Testing Library, simulando lo que hace un usuario real: leer la pantalla, hacer clic y escribir.
Concepto
La filosofía de React Testing Library (RTL):
Cuanto más se parezcan tus tests a la forma en que se usa tu software, más confianza te darán.
Es decir: no pruebes detalles internos (el nombre de un estado, qué hook usaste). Prueba lo que ve y hace el usuario: "si escribo mi email y hago clic en Enviar, aparece el mensaje de éxito".
| Herramienta | Papel |
|---|---|
| Vitest | Ejecuta los tests (describe, it, expect), se integra con Vite |
| jsdom | Simula un navegador (DOM) en Node |
| @testing-library/react | Renderiza componentes y busca elementos como lo haría un usuario |
| @testing-library/user-event | Simula interacciones reales (clics, escritura, teclado) |
| @testing-library/jest-dom | Matchers extra: toBeInTheDocument, toBeDisabled, toHaveValue |
Instalación
bash
npm i -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-domjs
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true, // describe, it, expect sin importar
setupFiles: './src/test/setup.js',
css: true
}
})js
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
// Desmonta los componentes después de cada test
afterEach(() => cleanup())json
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"coverage": "vitest run --coverage"
}
}Tu primer test
jsx
import { useState } from 'react'
export default function Contador({ inicial = 0 }) {
const [n, setN] = useState(inicial)
return (
<div>
<p>Valor: {n}</p>
<button onClick={() => setN(n + 1)}>Sumar</button>
<button onClick={() => setN(0)} disabled={n === 0}>Reiniciar</button>
</div>
)
}jsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import Contador from './Contador'
describe('Contador', () => {
it('muestra el valor inicial', () => {
render(<Contador inicial={5} />)
expect(screen.getByText('Valor: 5')).toBeInTheDocument()
})
it('suma 1 al hacer clic en "Sumar"', async () => {
const user = userEvent.setup()
render(<Contador />)
await user.click(screen.getByRole('button', { name: 'Sumar' }))
await user.click(screen.getByRole('button', { name: 'Sumar' }))
expect(screen.getByText('Valor: 2')).toBeInTheDocument()
})
it('el botón Reiniciar está deshabilitado en 0 y reinicia el valor', async () => {
const user = userEvent.setup()
render(<Contador inicial={3} />)
const reiniciar = screen.getByRole('button', { name: /reiniciar/i })
expect(reiniciar).toBeEnabled()
await user.click(reiniciar)
expect(screen.getByText('Valor: 0')).toBeInTheDocument()
expect(reiniciar).toBeDisabled()
})
})bash
npm testEncontrar elementos: queries
Usa las queries en este orden de prioridad (de más a menos parecidas a cómo percibe un usuario la página):
getByRole('button', { name: 'Enviar' })— la mejor; también verifica accesibilidad.getByLabelText('Email')— inputs conlabel.getByPlaceholderText('Buscar...')getByText('Bienvenido')getByAltText('Logo')— imágenes.getByTestId('carrito')— último recurso (data-testid).
Variantes:
| Prefijo | Si no lo encuentra | Uso |
|---|---|---|
getBy... | Lanza error | El elemento debe estar |
queryBy... | Devuelve null | Comprobar que no está |
findBy... | Espera (async) hasta 1 s | Elementos que aparecen después (fetch) |
getAllBy... | Arreglo | Varios elementos |
jsx
expect(screen.queryByText('Error')).not.toBeInTheDocument()
const mensaje = await screen.findByText('¡Guardado!')
expect(screen.getAllByRole('listitem')).toHaveLength(3)TIP
screen.debug() imprime el HTML actual en la consola. Muy útil cuando un test no encuentra un elemento. Y Testing Playground te sugiere la mejor query.
Probar formularios
jsx
import { useState } from 'react'
export default function FormLogin({ onLogin }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
function enviar(e) {
e.preventDefault()
if (!email.includes('@')) return setError('Email inválido')
if (password.length < 6) return setError('La contraseña debe tener al menos 6 caracteres')
setError('')
onLogin({ email, password })
}
return (
<form onSubmit={enviar}>
<label htmlFor="email">Email</label>
<input id="email" value={email} onChange={(e) => setEmail(e.target.value)} />
<label htmlFor="password">Contraseña</label>
<input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
{error && <p role="alert">{error}</p>}
<button type="submit">Entrar</button>
</form>
)
}jsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import FormLogin from './FormLogin'
function preparar() {
const onLogin = vi.fn() // función espía
const user = userEvent.setup()
render(<FormLogin onLogin={onLogin} />)
return { onLogin, user }
}
describe('FormLogin', () => {
it('envía los datos cuando son válidos', async () => {
const { onLogin, user } = preparar()
await user.type(screen.getByLabelText('Email'), 'ana@correo.com')
await user.type(screen.getByLabelText('Contraseña'), 'secreto123')
await user.click(screen.getByRole('button', { name: 'Entrar' }))
expect(onLogin).toHaveBeenCalledWith({ email: 'ana@correo.com', password: 'secreto123' })
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('muestra un error con email inválido y no envía', async () => {
const { onLogin, user } = preparar()
await user.type(screen.getByLabelText('Email'), 'ana')
await user.type(screen.getByLabelText('Contraseña'), 'secreto123')
await user.click(screen.getByRole('button', { name: 'Entrar' }))
expect(screen.getByRole('alert')).toHaveTextContent('Email inválido')
expect(onLogin).not.toHaveBeenCalled()
})
it('se puede enviar con Enter', async () => {
const { onLogin, user } = preparar()
await user.type(screen.getByLabelText('Email'), 'ana@correo.com')
await user.type(screen.getByLabelText('Contraseña'), 'secreto123{Enter}')
expect(onLogin).toHaveBeenCalledOnce()
})
})Probar componentes que piden datos
Simula fetch para no depender de internet ni de un servidor real:
jsx
import { useEffect, useState } from 'react'
export default function ListaUsuarios() {
const [usuarios, setUsuarios] = useState([])
const [estado, setEstado] = useState('cargando')
useEffect(() => {
fetch('https://jsonplaceholder.typicode.com/users')
.then((r) => {
if (!r.ok) throw new Error()
return r.json()
})
.then((d) => { setUsuarios(d); setEstado('ok') })
.catch(() => setEstado('error'))
}, [])
if (estado === 'cargando') return <p>Cargando…</p>
if (estado === 'error') return <p role="alert">No se pudieron cargar los usuarios</p>
return <ul>{usuarios.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
}jsx
import { render, screen } from '@testing-library/react'
import { vi, afterEach } from 'vitest'
import ListaUsuarios from './ListaUsuarios'
afterEach(() => vi.restoreAllMocks())
it('muestra los usuarios al cargar', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
json: async () => [{ id: 1, name: 'Ana' }, { id: 2, name: 'Luis' }]
})
render(<ListaUsuarios />)
expect(screen.getByText('Cargando…')).toBeInTheDocument()
expect(await screen.findByText('Ana')).toBeInTheDocument()
expect(screen.getAllByRole('listitem')).toHaveLength(2)
})
it('muestra un error si la petición falla', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: false })
render(<ListaUsuarios />)
expect(await screen.findByRole('alert')).toHaveTextContent('No se pudieron cargar')
})MSW
Para apps grandes, Mock Service Worker (msw) intercepta las peticiones a nivel de red con handlers que imitan tu API. Así no tienes que simular fetch en cada test y los mismos mocks sirven en desarrollo.
Probar con proveedores (Router, Query, Context)
Si un componente usa Link, useQuery o un contexto, necesita sus proveedores. Crea un ayudante:
jsx
// src/test/utils.jsx
import { render } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
export function renderConProveedores(ui, { ruta = '/' } = {}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } } // sin reintentos en tests
})
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[ruta]}>{ui}</MemoryRouter>
</QueryClientProvider>
)
}jsx
import { renderConProveedores } from '../test/utils'
renderConProveedores(<Navbar />, { ruta: '/productos' })Probar custom hooks
jsx
import { renderHook, act } from '@testing-library/react'
import { useContador } from './useContador'
it('incrementa respetando el máximo', () => {
const { result } = renderHook(() => useContador(1, { max: 2 }))
act(() => result.current.incrementar())
act(() => result.current.incrementar())
expect(result.current.valor).toBe(2)
})Qué probar (y qué no)
| ✅ Prueba | ❌ Evita |
|---|---|
| Flujos de usuario importantes (login, agregar al carrito, checkout) | Detalles de implementación (nombres de estados) |
| Validaciones y mensajes de error | Estilos y clases CSS |
| Estados de carga, error y vacío | Librerías de terceros (ya están probadas) |
| Lógica de negocio en funciones puras y hooks | Snapshots enormes que nadie revisa |
Para pruebas de extremo a extremo en un navegador real (con tu backend), usa Playwright.
Buena práctica
Escribe tests para los flujos que, si se rompen, le cuestan dinero al cliente: registro, pago, formularios de contacto. No persigas el 100 % de cobertura.
Error común
Olvidar await en las acciones de user-event (await user.click(...)) o usar getBy para algo que aparece tras un fetch. Usa findBy para elementos asíncronos.
Ejercicios
- Escribe tests para el componente
Invitadosde la lección de useState: agrega dos invitados, verifica que aparecen, que no se pueden repetir y que "Quitar" los elimina. - Escribe un test para un componente
Buscadorque filtre una lista de frutas al escribir.
Ver solución
jsx
// 1. Invitados.test.jsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Invitados } from './Invitados'
describe('Invitados', () => {
it('agrega, evita duplicados y quita invitados', async () => {
const user = userEvent.setup()
render(<Invitados />)
const input = screen.getByPlaceholderText('Nombre')
const agregar = screen.getByRole('button', { name: 'Agregar' })
await user.type(input, 'Ana')
await user.click(agregar)
await user.type(input, 'Luis')
await user.click(agregar)
expect(screen.getAllByRole('listitem')).toHaveLength(2)
await user.type(input, 'ana')
expect(screen.getByText('Ya está en la lista')).toBeInTheDocument()
expect(agregar).toBeDisabled()
const [quitarAna] = screen.getAllByRole('button', { name: 'Quitar' })
await user.click(quitarAna)
expect(screen.queryByText(/^Ana/)).not.toBeInTheDocument()
expect(screen.getByText('Total: 1')).toBeInTheDocument()
})
})jsx
// 2. Buscador.jsx + test
import { useState } from 'react'
const FRUTAS = ['Manzana', 'Mango', 'Pera', 'Plátano']
export function Buscador() {
const [q, setQ] = useState('')
const visibles = FRUTAS.filter((f) => f.toLowerCase().includes(q.toLowerCase()))
return (
<>
<label htmlFor="q">Buscar</label>
<input id="q" value={q} onChange={(e) => setQ(e.target.value)} />
<ul>{visibles.map((f) => <li key={f}>{f}</li>)}</ul>
{visibles.length === 0 && <p>Sin resultados</p>}
</>
)
}
// Buscador.test.jsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Buscador } from './Buscador'
it('filtra las frutas al escribir', async () => {
const user = userEvent.setup()
render(<Buscador />)
await user.type(screen.getByLabelText('Buscar'), 'ma')
expect(screen.getAllByRole('listitem').map((li) => li.textContent)).toEqual(['Manzana', 'Mango'])
await user.clear(screen.getByLabelText('Buscar'))
await user.type(screen.getByLabelText('Buscar'), 'kiwi')
expect(screen.getByText('Sin resultados')).toBeInTheDocument()
})Resumen
- Vitest + jsdom + React Testing Library + user-event forman el kit estándar.
- Busca elementos como un usuario:
getByRole,getByLabelText,getByText;findBypara lo asíncrono. - Simula
fetch(o usa MSW) y envuelve con proveedores cuando haga falta. - Prueba comportamiento y flujos críticos, no detalles internos.