En la ingeniería frontend moderna, el valor de las pruebas automatizadas no se mide por porcentajes estériles de cobertura de código (Code Coverage), sino por el grado de certeza y confianza (Confidence) que aportan durante los refactorings continuos y las actualizaciones del ecosistema.
El enfoque histórico de inspeccionar las tripas del componente (estado de hooks, props internas de Fiber, espías sobre métodos privados) provocaba el conocido síndrome de "pruebas frágiles":
useState a useReducer. La experiencia del usuario final es impecable, pero 40 pruebas fallan estrepitosamente, exigiendo horas de mantenimiento manual.La filosofía de React Testing Library (RTL) rechaza verificar detalles de implementación (Implementation Details). RTL interactúa con el DOM virtual exactamente igual que un ser humano o una tecnología de asistencia (lector de pantalla): mediante roles semánticos ARIA, contenido de texto visible y secuencias físicas de eventos del navegador.
Combinado con el ejecutor de pruebas de alto rendimiento Vitest y el interceptor de red Mock Service Worker (MSW v2), se obtiene la arquitectura de pruebas de referencia para React 19.
En lugar de simular métodos internos de React, la prueba ejecuta una simulación aislada del navegador en memoria:
[Usuario / Lector de Pantalla]
│
▼ (Interacción: Clic, Tecleado de caracteres, Pulsación de tecla Tab)
[@testing-library/user-event]
│
▼ (Despacho del flujo completo de eventos del navegador: pointer, focus, mouse, key)
[Entorno DOM Virtual: happy-dom / jsdom]
│
▼ (Ejecuta renderizado y planifica microtareas)
[React 19 Concurrent Scheduler (act / async transitions)]
│
▼ (Peticiones interceptadas a nivel de socket sin salir a la red física)
[Mock Service Worker (MSW v2) Network Layer]
│
▼ (Aserciones de estado mediante selectores semánticos ARIA)
[vitest / @testing-library/jest-dom matchers]Inicializa userEvent.setup(), configura los handlers de MSW y monta el componente mediante render() con sus providers de contexto.
Utiliza getByRole y getByLabelText para comprobar estados accesibles iniciales (botones deshabilitados, inputs vacíos).
Reproduce secuencias realistas: user.type(), user.click(), user.tab() despachando todos los eventos del navegador.
Espera mutaciones del DOM y retirada de loaders mediante findByRole, findByText o waitForElementToBeRemoved.
Confirma la invocación de callbacks recibidos por props, reseteos de formulario o cambios de navegación.
¿Qué consulta de React Testing Library tiene la mayor prioridad y ofrece la máxima resistencia ante refactorizaciones al probar un botón de envío?
useActionState), bloqueo de botones, validación de campos y feedback optimista (useOptimistic).Tab.AbortController.userEvent.setup() inicializa el despachador de eventos; MSW activa interceptores a nivel de socket.
Monta el árbol de componentes de React 19 en happy-dom con los providers requeridos (QueryClient, Theme).
user.type() rellena campos generando la secuencia real carácter a carácter de keydown/input/keyup.
La petición fetch es interceptada en el socket de Node.js devolviendo un JSON determinista.
Espera la aparición del banner de éxito en el árbol de accesibilidad mediante findByRole('alert').
Para habilitar React 19 y extender los matchers de Vitest, se configura un archivo global (setupTests.ts):
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'happy-dom', // o 'jsdom'
setupFiles: ['./src/test/setupTests.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/test/'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, beforeAll, afterAll } from 'vitest';
import { server } from './mocks/server';
// Iniciar Mock Service Worker antes de ejecutar la suite
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Restablecer handlers dinámicos y limpiar el DOM virtual tras cada test
afterEach(() => {
cleanup();
server.resetHandlers();
});
// Cerrar el servidor de interceptación al concluir la suite
afterAll(() => server.close());user-event reemplazó a fireEvent#Llamar a fireEvent.click(button) se limita a despachar un evento sintético 'click'. En un navegador real, la interacción del usuario genera una cadena interdependiente de eventos:
pointerover $\to$ pointerenter $\to$ mousemovepointerdown $\to$ mousedownfocusin, focus)pointerup $\to$ mouseup $\to$ clickEl paquete @testing-library/user-event ejecuta esta secuencia física completa, evaluando validaciones de foco, listeners de blur y propagación de eventos:
// ❌ Enfoque obsoleto: clic sintético sin ciclo de vida del navegador
fireEvent.click(screen.getByRole('button', { name: /enviar/i }));
// ✅ Estándar moderno: secuencia real de interacciones físicas
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /enviar/i }));¿Por qué se prefiere Mock Service Worker (MSW v2) frente a global.fetch = vi.fn() en pruebas con React Testing Library?
A continuación se presenta una prueba de producción para un formulario de cambio de contraseña, que cubre validaciones en cliente, interacciones de usuario, error 422 del servidor y guardado exitoso:
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/mocks/server';
import { ChangePasswordForm } from '../ui/ChangePasswordForm';
describe('<ChangePasswordForm />', () => {
it('renderiza el formulario en un estado inicial accesible', () => {
render(<ChangePasswordForm />);
expect(screen.getByRole('heading', { level: 2, name: /cambio de contraseña/i })).toBeInTheDocument();
expect(screen.getByLabelText(/contraseña actual/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^nueva contraseña/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /guardar contraseña/i })).toBeEnabled();
});
it('muestra errores de validación en cliente al enviar campos vacíos', async () => {
const user = userEvent.setup();
render(<ChangePasswordForm />);
const submitBtn = screen.getByRole('button', { name: /guardar contraseña/i });
await user.click(submitBtn);
// findByText espera la aparición del error en el DOM en la cola de microtareas
const currentPassError = await screen.findByText(/introduce tu contraseña actual/i);
const newPassError = screen.getByText(/la contraseña debe tener al menos 8 caracteres/i);
expect(currentPassError).toBeInTheDocument();
expect(newPassError).toBeInTheDocument();
});
it('gestiona la respuesta de error 422 del servidor (contraseña actual errónea)', async () => {
// Sobrescribir el handler de MSW localmente para el escenario negativo
server.use(
http.post('/api/auth/change-password', () => {
return HttpResponse.json(
{ message: 'La contraseña actual no es correcta' },
{ status: 422 }
);
})
);
const user = userEvent.setup();
render(<ChangePasswordForm />);
await user.type(screen.getByLabelText(/contraseña actual/i), 'WrongOldPassword123!');
await user.type(screen.getByLabelText(/^nueva contraseña/i), 'SuperSecurePass2026!');
await user.click(screen.getByRole('button', { name: /guardar contraseña/i }));
// El botón pasa al estado deshabilitado durante el envío
expect(screen.getByRole('button', { name: /guardando\.\.\./i })).toBeDisabled();
// Comprobar banner de error con rol alert
const serverAlert = await screen.findByRole('alert');
expect(serverAlert).toHaveTextContent(/la contraseña actual no es correcta/i);
});
it('guarda con éxito la nueva contraseña y limpia los campos del formulario', async () => {
server.use(
http.post('/api/auth/change-password', async ({ request }) => {
const body = (await request.json()) as { oldPass: string; newPass: string };
if (body.newPass === 'SuperSecurePass2026!') {
return HttpResponse.json({ success: true }, { status: 200 });
}
return HttpResponse.json({ success: false }, { status: 400 });
})
);
const user = userEvent.setup();
render(<ChangePasswordForm />);
const oldInput = screen.getByLabelText(/contraseña actual/i);
const newInput = screen.getByLabelText(/^nueva contraseña/i);
await user.type(oldInput, 'ValidOldPass123!');
await user.type(newInput, 'SuperSecurePass2026!');
await user.click(screen.getByRole('button', { name: /guardar contraseña/i }));
// Esperar mensaje de éxito
const successNotice = await screen.findByText(/contraseña actualizada con éxito/i);
expect(successNotice).toBeInTheDocument();
// Los campos de entrada deben haberse limpiado
expect(oldInput).toHaveValue('');
expect(newInput).toHaveValue('');
});
});Seleccionar nodos mediante clases CSS (container.querySelector('.btn-primary')) acopla rígidamente la prueba a los estilos visuales.
// ❌ Antipatrón: selector frágil que no verifica la accesibilidad (a11y)
const btn = container.querySelector('.btn-primary');
expect(btn).toBeTruthy();
// ✅ Solución correcta: consulta por rol semántico accesible y nombre visible
const btn = screen.getByRole('button', { name: /enviar/i });
expect(btn).toBeInTheDocument();En React Testing Library, los métodos render, userEvent y waitFor ya están envueltos internamente en act(...). Añadir llamadas manuales adicionales ensucia el código y oculta transiciones asíncronas no resueltas.
// ❌ Antipatrón: envoltorio manual act innecesario
await act(async () => {
render(<UserProfile />);
});
// ✅ Solución correcta: RTL gestiona automáticamente la cola de microtareas
render(<UserProfile />);
const profileName = await screen.findByRole('heading', { name: /alejandro/i });Envolver comprobaciones sincrónicas en waitFor(() => expect(syncValue).toBe(true)) introduce esperas artificiales por polling que ralentizan la ejecución de la suite.
// ❌ Antipatrón: waitFor sobre un elemento renderizado síncronamente
await waitFor(() => {
expect(screen.getByText(/encabezado/i)).toBeInTheDocument();
});
// ✅ Solución correcta: aserción sincrónica directa
expect(screen.getByText(/encabezado/i)).toBeInTheDocument();
// Para elementos que dependen de peticiones asíncronas, utiliza directamente findBy:
const asyncItem = await screen.findByText(/elemento dinámico/i);