Introducción a Jest & Testing Library
Jest y Testing Library representan el estándar moderno para probar aplicaciones React. Juntos, proporcionan un ecosistema poderoso y amigable para desarrolladores que permite escribir pruebas mantenibles que se enfocan en el comportamiento del usuario en lugar de detalles de implementación.
Jest es un framework integral de pruebas de JavaScript desarrollado por Facebook, que incluye test runners integrados, biblioteca de aserciones, capacidades de mocking y herramientas de cobertura de código. React Testing Library (parte de la familia Testing Library) fomenta probar componentes de la forma en que los usuarios interactúan con ellos, promoviendo mejores prácticas de pruebas y suites de pruebas más resilientes.
¿Por Qué Elegir Jest & Testing Library?
Ventajas Clave:
- Cero Configuración: Funciona out-of-the-box con Create React App
- Pruebas Centradas en el Usuario: Las pruebas se enfocan en el comportamiento del usuario, no en la implementación
- Ejecución Rápida: Ejecución paralela de pruebas con caché inteligente
- Ecosistema Rico: Matchers, utilidades y plugins comunitarios extensos
- Enfoque en Accesibilidad: Capacidades de pruebas de accesibilidad integradas
- Excelente DX: Mensajes de error claros y herramientas de depuración útiles
- Soporte Universal: Funciona con React, Vue, Angular, Svelte y JavaScript vanilla
Comenzando con Jest
Instalación y Configuración
# Para nuevos proyectos React (Jest incluido)
npx create-react-app mi-app
# Para proyectos existentes
npm install --save-dev jest @testing-library/react @testing-library/jest-dom
npm install --save-dev @testing-library/user-event
# Soporte TypeScript
npm install --save-dev @types/jest
Configuración de Jest
// jest.config.js
module.exports = {
// Entorno de prueba
testEnvironment: 'jsdom',
// Archivos de configuración
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
// Rutas de módulos
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
},
// Configuración de cobertura
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/index.js',
'!src/**/*.test.{js,jsx,ts,tsx}',
],
coverageThresholds: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
// Transformar archivos
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
},
// Patrones de coincidencia de pruebas
testMatch: [
'**/__tests__/**/*.(test|spec).(js|jsx|ts|tsx)',
'**/*.(test|spec).(js|jsx|ts|tsx)',
],
};
Conceptos Fundamentales de Jest
Estructura Básica de Prueba
// sum.js
export function sum(a, b) {
return a + b;
}
// sum.test.js
import { sum } from './sum';
describe('función sum', () => {
test('suma 1 + 2 para igualar 3', () => {
expect(sum(1, 2)).toBe(3);
});
test('suma números negativos correctamente', () => {
expect(sum(-1, -2)).toBe(-3);
});
test('maneja cero', () => {
expect(sum(0, 5)).toBe(5);
});
});
Matchers de Jest
describe('Matchers de Jest', () => {
// Igualdad
test('toBe vs toEqual', () => {
const obj = { name: 'Juan' };
expect(obj).toEqual({ name: 'Juan' }); // Igualdad profunda
expect(obj).not.toBe({ name: 'Juan' }); // Igualdad de referencia
});
// Truthiness
test('matchers de truthiness', () => {
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(0).toBeFalsy();
});
// Números
test('matchers de números', () => {
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(10).toBeLessThan(20);
expect(0.1 + 0.2).toBeCloseTo(0.3);
});
// Cadenas
test('matchers de cadenas', () => {
expect('hola mundo').toMatch(/mundo/);
expect('hola').not.toMatch(/adiós/);
expect('equipo').toContain('equi');
});
// Arrays e iterables
test('matchers de arrays', () => {
const frutas = ['manzana', 'banana', 'naranja'];
expect(frutas).toContain('banana');
expect(frutas).toHaveLength(3);
expect(frutas).toEqual(expect.arrayContaining(['manzana', 'banana']));
});
// Objetos
test('matchers de objetos', () => {
const usuario = {
name: 'Juan',
age: 30,
email: 'juan@ejemplo.com',
};
expect(usuario).toHaveProperty('name');
expect(usuario).toHaveProperty('age', 30);
expect(usuario).toMatchObject({ name: 'Juan' });
});
// Excepciones
test('matchers de excepciones', () => {
function lanzarError() {
throw new Error('Algo salió mal');
}
expect(lanzarError).toThrow();
expect(lanzarError).toThrow('Algo salió mal');
expect(lanzarError).toThrow(Error);
});
});
Pruebas Asíncronas
// Promesas
test('obtiene datos de usuario', () => {
return fetchUser(1).then((user) => {
expect(user.name).toBe('Juan Pérez');
});
});
// Async/Await
test('obtiene datos de usuario con async/await', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Juan Pérez');
});
// Resolves/Rejects
test('obtención de usuario se resuelve', async () => {
await expect(fetchUser(1)).resolves.toHaveProperty('name');
});
test('obtención de usuario rechaza con error', async () => {
await expect(fetchUser(999)).rejects.toThrow('Usuario no encontrado');
});
Fundamentos de React Testing Library
Pruebas Básicas de Componentes
// Button.jsx
export function Button({ onClick, children, disabled }) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
}
// Button.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Componente Button', () => {
test('renderiza botón con texto', () => {
render(<Button>Haz clic</Button>);
expect(screen.getByRole('button', { name: /haz clic/i })).toBeInTheDocument();
});
test('llama onClick cuando se hace clic', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Haz clic</Button>);
const button = screen.getByRole('button');
await user.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('no llama onClick cuando está deshabilitado', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick} disabled>Haz clic</Button>);
const button = screen.getByRole('button');
await user.click(button);
expect(handleClick).not.toHaveBeenCalled();
});
});
Consulta de Elementos
Prioridad de Consulta (Orden Recomendado):
- Accesible por Todos:
getByRole
,getByLabelText
,getByPlaceholderText
,getByText
- Consultas Semánticas:
getByAltText
,getByTitle
- Test IDs:
getByTestId
(último recurso)
import { render, screen, within } from '@testing-library/react';
describe('Métodos de consulta', () => {
test('consultas getBy', () => {
render(<LoginForm />);
// Por rol (MEJOR)
const button = screen.getByRole('button', { name: /iniciar sesión/i });
// Por texto de etiqueta
const emailInput = screen.getByLabelText(/email/i);
// Por placeholder
const searchInput = screen.getByPlaceholderText(/buscar/i);
// Por contenido de texto
const heading = screen.getByText(/bienvenido/i);
// Por test ID (ÚLTIMO RECURSO)
const element = screen.getByTestId('elemento-personalizado');
expect(button).toBeInTheDocument();
});
test('queryBy para elementos que podrían no existir', () => {
render(<Notification show={false} />);
// Retorna null si no se encuentra (no lanza error)
const message = screen.queryByText(/notificación/i);
expect(message).not.toBeInTheDocument();
});
test('findBy para elementos asíncronos', async () => {
render(<AsyncComponent />);
// Espera a que aparezca el elemento (retorna promesa)
const data = await screen.findByText(/datos cargados/i);
expect(data).toBeInTheDocument();
});
});
Interacciones de Usuario
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('interacciones de usuario', async () => {
const user = userEvent.setup();
render(<ContactForm />);
// Escribir en input
const nameInput = screen.getByLabelText(/nombre/i);
await user.type(nameInput, 'Juan Pérez');
expect(nameInput).toHaveValue('Juan Pérez');
// Limpiar input
await user.clear(nameInput);
expect(nameInput).toHaveValue('');
// Hacer clic en botón
const submitButton = screen.getByRole('button', { name: /enviar/i });
await user.click(submitButton);
// Doble clic
await user.dblClick(submitButton);
// Seleccionar opción
const select = screen.getByLabelText(/país/i);
await user.selectOptions(select, 'España');
expect(select).toHaveValue('España');
// Marcar checkbox
const checkbox = screen.getByRole('checkbox', { name: /acepto/i });
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
Patrones Avanzados de Pruebas
Pruebas de Formularios
// ContactForm.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ContactForm } from './ContactForm';
describe('ContactForm', () => {
test('envía formulario con datos válidos', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<ContactForm onSubmit={onSubmit} />);
// Llenar formulario
await user.type(screen.getByLabelText(/nombre/i), 'Juan Pérez');
await user.type(screen.getByLabelText(/email/i), 'juan@ejemplo.com');
await user.type(screen.getByLabelText(/mensaje/i), 'Hola mundo');
// Enviar
await user.click(screen.getByRole('button', { name: /enviar/i }));
// Verificar envío
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'Juan Pérez',
email: 'juan@ejemplo.com',
message: 'Hola mundo',
});
});
});
test('muestra errores de validación para campos vacíos', async () => {
const user = userEvent.setup();
render(<ContactForm />);
// Enviar formulario vacío
await user.click(screen.getByRole('button', { name: /enviar/i }));
// Verificar mensajes de error
expect(await screen.findByText(/nombre requerido/i)).toBeInTheDocument();
expect(screen.getByText(/email requerido/i)).toBeInTheDocument();
});
});
Pruebas de Accesibilidad
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('Pruebas de accesibilidad', () => {
test('LoginForm no tiene violaciones de accesibilidad', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('botón tiene aria-label apropiado', () => {
render(<IconButton icon="trash" aria-label="Eliminar elemento" />);
const button = screen.getByRole('button', { name: /eliminar elemento/i });
expect(button).toHaveAccessibleName('Eliminar elemento');
});
});
Estrategias de Mocking en Jest
Mocking de Funciones
// Mock simple
const mockCallback = jest.fn();
mockCallback('test');
expect(mockCallback).toHaveBeenCalledWith('test');
// Implementación de mock
const mockFn = jest.fn((x) => x * 2);
expect(mockFn(5)).toBe(10);
// Valores de retorno mock
const mock = jest.fn();
mock.mockReturnValue(42);
mock.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValue(3);
// Promesas resueltas/rechazadas mock
const asyncMock = jest.fn();
asyncMock.mockResolvedValue({ data: 'éxito' });
asyncMock.mockRejectedValue(new Error('Fallido'));
Mocking de Módulos
// api.js
export const fetchUser = (id) => {
return fetch(`/api/users/${id}`).then((res) => res.json());
};
// component.test.js
import { fetchUser } from './api';
jest.mock('./api');
test('carga datos de usuario', async () => {
fetchUser.mockResolvedValue({ name: 'Juan', email: 'juan@ejemplo.com' });
render(<UserProfile userId={1} />);
expect(await screen.findByText(/juan/i)).toBeInTheDocument();
expect(fetchUser).toHaveBeenCalledWith(1);
});
Mejores Prácticas
1. Probar Comportamiento del Usuario, No Implementación
// ❌ Malo: Probar detalles de implementación
test('llama handleSubmit cuando se envía el formulario', () => {
const handleSubmit = jest.fn();
const { getByRole } = render(<Form onSubmit={handleSubmit} />);
// Probando prop directamente
});
// ✅ Bueno: Probar interacción de usuario
test('envía formulario cuando usuario hace clic en enviar', async () => {
const user = userEvent.setup();
render(<Form />);
await user.click(screen.getByRole('button', { name: /enviar/i }));
expect(await screen.findByText(/éxito/i)).toBeInTheDocument();
});
2. Usar Consultas Accesibles
// ❌ Evitar: Usar test IDs
screen.getByTestId('boton-enviar');
// ✅ Preferir: Usar consultas accesibles
screen.getByRole('button', { name: /enviar/i });
3. Evitar Probar Internos de Biblioteca
// ❌ Malo: Probar estado
expect(component.state.isLoading).toBe(false);
// ✅ Bueno: Probar salida renderizada
expect(screen.queryByText(/cargando/i)).not.toBeInTheDocument();
Comparación con Otras Herramientas
Característica | Jest + Testing Library | Enzyme | Cypress Component |
---|---|---|---|
Enfoque | Comportamiento usuario | Implementación | E2E + Componentes |
Curva de Aprendizaje | Baja | Media | Media |
Velocidad | Rápida | Rápida | Más lenta |
Navegador Real | No (JSDOM) | No | Sí |
Accesibilidad | Excelente | Limitada | Buena |
Comunidad | Muy Grande | Decreciente | Creciente |
Conclusión
Jest y Testing Library se han convertido en el estándar de facto para pruebas modernas de React debido a su enfoque en probar el comportamiento del usuario, excelente experiencia de desarrollador y conjunto de características comprehensivo. Al fomentar pruebas que interactúan con componentes como lo harían los usuarios, ayudan a crear suites de pruebas más mantenibles y resilientes.
Jest & Testing Library son Perfectos Para:
- Aplicaciones React de cualquier tamaño
- Equipos que priorizan la accesibilidad
- Proyectos que requieren ejecución rápida de pruebas
- Desarrolladores nuevos en pruebas
- Desarrollo de bibliotecas de componentes
Próximos Pasos
- Instalar Jest y Testing Library en tu proyecto
- Comenzar con pruebas simples de componentes
- Aprender métodos de consulta accesibles
- Practicar pruebas de interacciones de usuario
- Agregar reportes de cobertura a tu CI/CD
- Explorar MSW para mocking de API
Enfócate en probar lo que los usuarios ven y hacen, y tu suite de pruebas permanecerá valiosa a medida que tu aplicación evoluciona.