Históricamente, las pruebas de componentes en el ecosistema React se apoyaban en bibliotecas como Enzyme, que promovían la metodología de shallow rendering y la inspección directa de las estructuras internas de los componentes: verificar el estado (wrapper.state()), inspeccionar las props pasadas a nodos hijo, o invocar métodos privados de instancias de clases.
Este enfoque tenía un defecto arquitectónico fundamental: un fuerte acoplamiento (tight coupling) de los tests con los detalles de implementación. Al refactorizar un componente (por ejemplo, migrar de un componente de clase a hooks, dividir un hook monolítico en varios sub-hooks, o cambiar la estructura del árbol DOM interno), los tests fallaban, aunque el comportamiento visual y el contrato público del componente permanecieran 100% correctos. Esto generaba el problema de los falsos positivos (false positives), que restaba valor a la automatización.
Las Pruebas Orientadas al Comportamiento (Behavior-Driven Testing) son el núcleo de la filosofía de React Testing Library (RTL), articulada por Kent C. Dodds:
«The more your tests resemble the way your software is used, the more confidence they can give you.»
(Cuanto más se parecen tus pruebas a la forma en que se usa tu software, más confianza pueden darte.)
En los estándares del desarrollo web moderno y React 19, la interfaz se prueba en la intersección del árbol DOM y el árbol de accesibilidad (Accessibility Tree). Esto garantiza que una prueba exitosa valide no solo la lógica de negocio, sino también la accesibilidad digital (a11y) de la interfaz para todos los tipos de usuarios.
En el paradigma de React Testing Library, el componente se monta en un entorno DOM real o virtual (jsdom o happy-dom). El test runner (Vitest 4+) no tiene acceso directo a las variables internas de los closures de React Fiber, las referencias useRef ni el estado interno de useState.
En su lugar, el test interactúa con la interfaz a través de dos canales principales:
@testing-library/user-event.+-----------------------------------------------------------------------+
| Usuario / Lector de Pantalla |
+-----------------------------------------------------------------------+
│
▼ (Percepción vía UI & ARIA)
+-----------------------------------------------------------------------+
| Accessibility Tree (AOM) & DOM Tree |
| - role: "button", name: "Finalizar compra" |
| - role: "textbox", name: "Correo electrónico" |
| - role: "status", aria-live: "polite" |
+-----------------------------------------------------------------------+
▲
│ (Selectores semánticos de RTL)
+-----------------------------------------------------------------------+
| React Testing Library (screen.getByRole / findBy) |
+-----------------------------------------------------------------------+
▲
│ (Aislamiento: render de caja negra)
+-----------------------------------------------------------------------+
| React 19 Component (Fiber / Hooks / Private State) |
+-----------------------------------------------------------------------+Al escribir pruebas, es fundamental clasificar los elementos arquitectónicos de un componente:
| Categoría | Detalles de Implementación (NO PROBAMOS) | Contrato Público (SÍ PROBAMOS) |
| :--- | :--- | :--- |
| Estado | Nombres de variables en useState, orden de llamadas a hooks | Texto que aparece en el DOM al cambiar el estado |
| Handlers | Nombres de funciones como handleFormSubmit, onClickInternal | La reacción del sistema a un clic físico en un botón |
| Marcado | <div> anidados, clases utilitarias de Tailwind | La etiqueta semántica <button> o <form aria-label="..."> |
| Peticiones | Instancia del cliente Axios o referencia a global.fetch | Cambio en la UI al recibir una respuesta HTTP o un error |
RTL impone una prioridad estricta para la selección de elementos. Los selectores de baja prioridad solo deben usarse cuando las capacidades semánticas del estándar WAI-ARIA se hayan agotado.
+-----------------------------------------------------------------------+
| PRIORIDAD 1: Accesible para todos (Accessible to Everyone) |
| ├── getByRole(role, { name, pressed, expanded, hidden }) |
| ├── getByLabelText(text, { selector }) |
| ├── getByPlaceholderText(text) |
| ├── getByText(text, { selector }) |
| └── getByDisplayValue(value) |
+-----------------------------------------------------------------------+
│
▼
+-----------------------------------------------------------------------+
| PRIORIDAD 2: Atributos Visuales Semánticos |
| ├── getByAltText(altText) |
| └── getByTitle(titleText) |
+-----------------------------------------------------------------------+
│
▼
+-----------------------------------------------------------------------+
| PRIORIDAD 3: Último Recurso (Escape Hatch) |
| └── getByTestId('checkout-submit-btn') |
+-----------------------------------------------------------------------+get, query, find#Cada selector existe en 6 variantes que determinan su sincronía y su comportamiento ante la ausencia o múltiple presencia de elementos:
| Prefijo | 0 coincidencias | 1 coincidencia | 2+ coincidencias | Sincronía | Uso típico |
| :--- | :--- | :--- | :--- | :--- | :--- |
| getBy* | Lanza excepción | Devuelve nodo | Lanza excepción | Síncrono | Buscar un elemento existente |
| queryBy* | null | Devuelve nodo | Lanza excepción | Síncrono | Verificar la ausencia de un elemento |
| findBy* | Excepción (Timeout) | Devuelve nodo | Lanza excepción | Promise (async) | Esperar la aparición de un nodo asíncrono |
| getAllBy* | Lanza excepción | Array [node] | Array [node1, ...] | Síncrono | Seleccionar listas de elementos |
| queryAllBy* | Array vacío [] | Array [node] | Array [node1, ...] | Síncrono | Verificar la ausencia de un grupo de nodos |
| findAllBy* | Excepción (Timeout) | Array [node] | Array [node1, ...] | Promise (async) | Esperar que se renderice una lista |
screen y las Utilidades de Depuración#En lugar de desestructurar métodos del resultado de render(), el estándar prescribe usar el objeto global screen, vinculado a document.body:
import { render, screen, logRoles } from '@testing-library/react';
// Renderizar el árbol
const { container } = render(<CheckoutForm />);
// Imprimir el estado actual del DOM en la consola del test runner
screen.debug();
// Imprimir los roles semánticos ARIA disponibles para el contenedor dado
logRoles(container);getByRole y Cómputo del Accessible Name#Al buscar con getByRole, el segundo argumento es un objeto de opciones con el campo name. El Nombre Accesible (Accessible Name) lo calcula el navegador según la especificación W3C: a partir del contenido de texto, el atributo aria-label, o la asociación aria-labelledby.
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState } from 'react';
interface OrderItem {
readonly id: string;
readonly title: string;
readonly price: number;
}
interface OrderProps {
readonly items: readonly OrderItem[];
readonly onCheckout: (total: number) => void;
}
function OrderSummary({ items, onCheckout }: OrderProps) {
const [promoApplied, setPromoApplied] = useState(false);
const total = items.reduce((sum, item) => sum + item.price, 0);
const finalPrice = promoApplied ? total * 0.9 : total;
return (
<section aria-labelledby="order-heading">
<h2 id="order-heading">Resumen del pedido</h2>
<ul aria-label="Lista de artículos">
{items.map((item) => (
<li key={item.id}>
<span>{item.title}</span> — <span>{item.price} €</span>
</li>
))}
</ul>
<label>
<input
type="checkbox"
checked={promoApplied}
onChange={(e) => setPromoApplied(e.target.checked)}
/>
Aplicar descuento del 10%
</label>
<div role="status" aria-live="polite">
Total a pagar: {finalPrice} €
</div>
<button
type="button"
disabled={items.length === 0}
onClick={() => onCheckout(finalPrice)}
>
Confirmar y pagar
</button>
</section>
);
}
describe('OrderSummary Component', () => {
const mockItems: readonly OrderItem[] = [
{ id: '1', title: 'React 19 Architecture Guide', price: 49 },
{ id: '2', title: 'Vitest Deep Dive', price: 39 },
];
it('encuentra correctamente los elementos a través del árbol ARIA y calcula el precio total', async () => {
const user = userEvent.setup();
const handleCheckout = vi.fn();
render(<OrderSummary items={mockItems} onCheckout={handleCheckout} />);
// 1. Buscar encabezados estructurales y listas por rol y nombre accesible
expect(
screen.getByRole('region', { name: 'Resumen del pedido' })
).toBeInTheDocument();
expect(
screen.getByRole('list', { name: 'Lista de artículos' })
).toBeInTheDocument();
// 2. Buscar elementos interactivos del formulario
const discountCheckbox = screen.getByRole('checkbox', {
name: /aplicar descuento del 10%/i,
});
expect(discountCheckbox).not.toBeChecked();
// 3. Verificar la región de estado dinámica
expect(screen.getByRole('status')).toHaveTextContent('Total a pagar: 88 €');
// 4. Interactuar mediante evento de usuario
await user.click(discountCheckbox);
expect(discountCheckbox).toBeChecked();
expect(screen.getByRole('status')).toHaveTextContent('Total a pagar: 79.2 €');
// 5. Activar el botón de finalizar pedido
const submitBtn = screen.getByRole('button', {
name: /confirmar y pagar/i,
});
await user.click(submitBtn);
expect(handleCheckout).toHaveBeenCalledTimes(1);
expect(handleCheckout).toHaveBeenCalledWith(79.2);
});
});pressed, expanded)#Los componentes de acordeones, listas desplegables y botones de alternancia deben comunicar su estado a las tecnologías de asistencia mediante los atributos aria-expanded, aria-pressed y aria-selected. RTL permite filtrar las queries por estos atributos.
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState, useId } from 'react';
interface CollapsibleProps {
readonly title: string;
readonly children: React.ReactNode;
}
function CollapsibleFilter({ title, children }: CollapsibleProps) {
const [isExpanded, setIsExpanded] = useState(false);
const panelId = useId();
return (
<div className="filter-wrapper">
<button
type="button"
aria-expanded={isExpanded}
aria-controls={panelId}
onClick={() => setIsExpanded((prev) => !prev)}
>
{title}
</button>
{isExpanded && (
<div id={panelId} role="region" aria-label={title}>
{children}
</div>
)}
</div>
);
}
describe('CollapsibleFilter Accessibility States', () => {
it('sincroniza el atributo aria-expanded con la visibilidad física del contenido', async () => {
const user = userEvent.setup();
render(
<CollapsibleFilter title="Categorías de productos">
<p>Electrónica, Libros, Software</p>
</CollapsibleFilter>
);
// Estado inicial: botón contraído
const toggleButton = screen.getByRole('button', {
name: 'Categorías de productos',
expanded: false,
});
expect(toggleButton).toBeInTheDocument();
// La región de contenido está ausente en el DOM
expect(
screen.queryByRole('region', { name: 'Categorías de productos' })
).not.toBeInTheDocument();
// Expandir el panel
await user.click(toggleButton);
// Verificar el cambio de estado ARIA a expanded: true
expect(
screen.getByRole('button', {
name: 'Categorías de productos',
expanded: true,
})
).toBeInTheDocument();
// Verificar la visibilidad de la región
expect(
screen.getByRole('region', { name: 'Categorías de productos' })
).toHaveTextContent('Electrónica, Libros, Software');
});
});queryBy* vs. waitForElementToBeRemoved#Un error común de los principiantes es usar getBy* para verificar la desaparición de un nodo. El método getBy* lanza inmediatamente una excepción fatal. Para la verificación síncrona de ausencia se usa queryBy*, y para rastrear la desaparición asíncrona, waitForElementToBeRemoved.
import { describe, it, expect } from 'vitest';
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState } from 'react';
function DismissibleBanner({ message }: { readonly message: string }) {
const [visible, setVisible] = useState(true);
if (!visible) return null;
return (
<aside role="alert">
<span>{message}</span>
<button
type="button"
aria-label="Cerrar notificación"
onClick={() => setVisible(false)}
>
×
</button>
</aside>
);
}
describe('DismissibleBanner LifeCycle', () => {
it('elimina correctamente la alerta del DOM al hacer clic en el botón de cierre', async () => {
const user = userEvent.setup();
render(<DismissibleBanner message="Tu sesión expira en 5 minutos" />);
// 1. Confirmar la presencia de la alerta mediante getByRole
const alertElement = screen.getByRole('alert');
expect(alertElement).toHaveTextContent('Tu sesión expira en 5 minutos');
const closeBtn = screen.getByRole('button', {
name: 'Cerrar notificación',
});
// 2. Iniciar el cierre
await user.click(closeBtn);
// 3. Verificación síncrona de ausencia: queryByRole devuelve null
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// 4. Alternativa para animaciones/transiciones asíncronas:
// await waitForElementToBeRemoved(() => screen.queryByRole('alert'));
});
});logRoles#Cuando un test falla con «Unable to find an accessible element with the role...», la utilidad logRoles permite imprimir el árbol AOM calculado para comprender cómo las tecnologías de asistencia perciben el elemento.
import { describe, it, expect } from 'vitest';
import { render, screen, logRoles } from '@testing-library/react';
import React from 'react';
function UserNavigation() {
return (
<header>
<nav aria-label="Menú principal">
<a href="/profile">Mi Perfil</a>
<a href="/settings">Ajustes</a>
<button type="button" aria-label="Cerrar sesión">
<svg aria-hidden="true" focusable="false"><path d="M0 0h24v24H0z" /></svg>
</button>
</nav>
</header>
);
}
describe('UserNavigation Diagnostic', () => {
it('imprime el árbol de roles para afinar los selectores con precisión', () => {
const { container } = render(<UserNavigation />);
// Imprime en stdout la estructura de roles:
// banner:
// navigation "Menú principal":
// link "Mi Perfil"
// link "Ajustes"
// button "Cerrar sesión"
logRoles(container);
// Selector preciso basado en el log
expect(screen.getByRole('navigation', { name: 'Menú principal' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cerrar sesión' })).toBeInTheDocument();
});
});Usar container.querySelector('.btn-primary') o container.getElementsByTagName('input') acopla fuertemente el test a los estilos de presentación y la maquetación. Si un diseñador reemplaza la clase con una utilidad Tailwind o envuelve el botón en un <span> auxiliar, el test falla aunque el componente siga funcionando correctamente.
// ❌ ANTIPATRÓN: Acoplamiento a una clase CSS de presentación
const button = container.querySelector('.submit-button-active');
expect(button).toBeDefined();
// ✅ SOLUCIÓN: Query por rol semántico y nombre accesible
const button = screen.getByRole('button', { name: /guardar/i });
expect(button).toBeInTheDocument();El atributo data-testid es invisible para los usuarios y los lectores de pantalla. Si un desarrollador elimina accidentalmente el texto de un botón o rompe el foco con el teclado, un test con getByTestId seguirá pasando, dejando pasar un defecto de accesibilidad crítico a producción.
// ❌ ANTIPATRÓN: Oculta defectos de semántica y accesibilidad
render(<div onClick={handleClick} data-testid="fake-submit">Enviar</div>);
const el = screen.getByTestId('fake-submit'); // ¡El test pasa, pero esto no es un botón!
// ✅ SOLUCIÓN: Obliga a usar HTML accesible
render(<button type="submit">Enviar</button>);
const el = screen.getByRole('button', { name: /enviar/i });La familia de métodos getBy* lanza inmediatamente una TestingLibraryElementError si el elemento no se encuentra en el DOM. La construcción expect(screen.getByText('Error')).not.toBeInTheDocument() fallará en la propia llamada a getByText, sin llegar siquiera al matcher expect.
// ❌ ANTIPATRÓN: El test falla con una excepción no manejada en getBy
expect(screen.getByText('Error de autorización')).not.toBeInTheDocument();
// ✅ SOLUCIÓN: queryBy devuelve null cuando el nodo está ausente
expect(screen.queryByText('Error de autorización')).not.toBeInTheDocument();Los intentos de acceder a variables internas mediante wrapper.state() (un hábito de Enzyme) o la inspección de _reactFiber privan al test de resistencia al refactoring. Solo se deben probar los cambios en el DOM renderizado.
// ❌ ANTIPATRÓN: Verifica el estado privado del componente
expect(componentInstance.state.isOpen).toBe(true);
// ✅ SOLUCIÓN: Verifica la reacción de la UI al cambio de estado
expect(screen.getByRole('dialog', { name: /editar/i })).toBeVisible();Determinar el rol semántico del elemento en el Accessibility Tree: button, textbox, heading, link, dialog, status, listbox.
Determinar cómo el elemento es anunciado por un lector de pantalla: el texto dentro del tag, aria-label, un encabezado h1-h6 o un label asociado con htmlFor.
Usar getByRole para verificar presencia, queryByRole para verificar ausencia, y findByRole para espera asíncrona.
Añadir opciones para verificar estados interactivos: { expanded: true, pressed: false, hidden: false }.
Solo si el nodo es un canvas puramente dinámico o un SVG complejo, usar data-testid.