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

  1. Accesible por Todos: getByRole, getByLabelText, getByPlaceholderText, getByText
  2. Consultas Semánticas: getByAltText, getByTitle
  3. 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ísticaJest + Testing LibraryEnzymeCypress Component
EnfoqueComportamiento usuarioImplementaciónE2E + Componentes
Curva de AprendizajeBajaMediaMedia
VelocidadRápidaRápidaMás lenta
Navegador RealNo (JSDOM)No
AccesibilidadExcelenteLimitadaBuena
ComunidadMuy GrandeDecrecienteCreciente

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

  1. Instalar Jest y Testing Library en tu proyecto
  2. Comenzar con pruebas simples de componentes
  3. Aprender métodos de consulta accesibles
  4. Practicar pruebas de interacciones de usuario
  5. Agregar reportes de cobertura a tu CI/CD
  6. 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.