Skip to content

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".

HerramientaPapel
VitestEjecuta los tests (describe, it, expect), se integra con Vite
jsdomSimula un navegador (DOM) en Node
@testing-library/reactRenderiza componentes y busca elementos como lo haría un usuario
@testing-library/user-eventSimula interacciones reales (clics, escritura, teclado)
@testing-library/jest-domMatchers 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-dom
js
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 test

Encontrar 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):

  1. getByRole('button', { name: 'Enviar' }) — la mejor; también verifica accesibilidad.
  2. getByLabelText('Email') — inputs con label.
  3. getByPlaceholderText('Buscar...')
  4. getByText('Bienvenido')
  5. getByAltText('Logo') — imágenes.
  6. getByTestId('carrito') — último recurso (data-testid).

Variantes:

PrefijoSi no lo encuentraUso
getBy...Lanza errorEl elemento debe estar
queryBy...Devuelve nullComprobar que no está
findBy...Espera (async) hasta 1 sElementos que aparecen después (fetch)
getAllBy...ArregloVarios 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 errorEstilos y clases CSS
Estados de carga, error y vacíoLibrerías de terceros (ya están probadas)
Lógica de negocio en funciones puras y hooksSnapshots 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 ​

  1. Escribe tests para el componente Invitados de la lección de useState: agrega dos invitados, verifica que aparecen, que no se pueden repetir y que "Quitar" los elimina.
  2. Escribe un test para un componente Buscador que 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; findBy para lo asíncrono.
  • Simula fetch (o usa MSW) y envuelve con proveedores cuando haga falta.
  • Prueba comportamiento y flujos críticos, no detalles internos.

Academia Web — aprende, copia y construye.