En el entorno del navegador, la interacción física del usuario con la interfaz nunca se reduce a un evento aislado. Cuando un usuario hace clic en un botón, presiona una tecla o pega texto desde el portapapeles, el navegador desencadena una compleja cascada de eventos del ciclo de vida del DOM (Event Dispatch Pipeline), que incluye fases de captura (capture phase), propagación (bubbling phase), desplazamiento del foco y cambio del elemento activo (document.activeElement).
La utilidad histórica fireEvent de @testing-library/dom era simplemente una delgada capa sobre el método nativo node.dispatchEvent(new Event(type)). La llamada fireEvent.click(button) despacha exclusivamente un evento sintético click al nodo. Al hacerlo, el motor del navegador:
pointerdown, mousedown).pointer-events: none del elemento.focusin, focus, blur, focusout.Space o Enter.Por este motivo, las pruebas con fireEvent creaban una falsa sensación de seguridad: un formulario con lógica de validación ligada a onBlur pasaba los tests, pero fallaba catastróficamente en producción ante usuarios reales porque fireEvent.change nunca desplazaba el foco.
@testing-library/user-event versión 14+ resuelve este problema de raíz. Modela una pila de navegador virtual: gestiona un puntero virtual (ratón, pantalla táctil), un teclado virtual, el portapapeles y sincroniza el estado del DOM en estricta conformidad con las especificaciones de eventos de la W3C.
En user-event v14+, la interacción se articula en torno a una instancia de sesión creada mediante userEvent.setup(). Esta llamada inicializa el estado interno del controlador de dispositivos de entrada virtuales: la posición actual del cursor, el foco, las teclas modificadoras presionadas (Shift, Alt, Control, Meta) y las capturas activas del puntero (pointer capture).
+-----------------------------------------------------------------------+
| userEvent.click(submitButton) |
+-----------------------------------------------------------------------+
│
▼ (1. Puntero sobre el elemento)
+-----------------------------------------------------------------------+
| pointerover -> mouseover -> pointerenter -> mouseenter |
+-----------------------------------------------------------------------+
│
▼ (2. Presión del botón del ratón)
+-----------------------------------------------------------------------+
| pointerdown -> mousedown |
+-----------------------------------------------------------------------+
│
▼ (3. Transición de foco)
+-----------------------------------------------------------------------+
| Elemento previo: blur -> focusout |
| Nuevo elemento: focusin -> focus |
| Actualización: document.activeElement = submitButton |
+-----------------------------------------------------------------------+
│
▼ (4. Liberación y disparo de acción)
+-----------------------------------------------------------------------+
| pointerup -> mouseup -> click |
+-----------------------------------------------------------------------+fireEvent vs userEvent#| Criterio | fireEvent | userEvent v14+ |
| :--- | :--- | :--- |
| Principio Operativo | Llamada individual node.dispatchEvent(event) | Cascada de eventos del navegador según especificaciones W3C |
| Gestión del Foco | El foco no cambia, document.activeElement intacto | Transferencia automática de foco, despacha blur y focus |
| Entrada de Texto | Sobrescribe instantáneamente value con input/change | Entrada carácter por carácter: keydown -> keypress -> input -> keyup |
| Verificación de Interactividad | Se ejecuta incluso en elementos disabled | Bloquea acciones en elementos deshabilitados o invisibles |
| Sincronía | Llamada síncrona (fireEvent.click(...)) | Promesa asíncrona (await user.click(...)) |
userEvent.setup()#Todas las interacciones deben comenzar invocando userEvent.setup(). La creación de la instancia debe realizarse antes de montar el componente mediante render():
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('demuestra la inicialización estándar de userEvent', async () => {
// Configuración de la sesión de usuario con opciones personalizadas
const user = userEvent.setup({
delay: null, // Desactiva retardos artificiales para máxima velocidad de ejecución
});
render(<SearchInput />);
const input = screen.getByRole('textbox', { name: /buscar/i });
// Todos los métodos de la instancia user son asíncronos
await user.type(input, 'Next.js 15');
});UserEvent#user.click(element): Ciclo completo de clic (pointerdown, focus, mousedown, pointerup, mouseup, click).user.dblClick(element) / user.tripleClick(element): Doble y triple clic con selección de texto.user.type(element, text, options): Escritura secuencial de caracteres emitiendo eventos de teclado por cada glifo.user.clear(element): Limpia un campo de texto o textarea seleccionando el contenido y pulsando Backspace.user.tab(options): Desplaza el foco mediante tabulación respetando tabIndex (options.shift: true para navegación inversa).user.keyboard(text): Emulación de pulsaciones de bajo nivel: user.keyboard('{Enter}'), user.keyboard('{Escape}'), user.keyboard('[ShiftLeft>]a[/ShiftLeft]').user.upload(input, file): Carga de archivos en un <input type="file">.user.selectOptions(element, values) / user.deselectOptions(element, values): Selección de opciones en <select>.user.paste(text): Pega texto desde el portapapeles en el elemento activo emitiendo clipboardData.onBlur y Envío#Los formularios reales evalúan la validez de los campos al perder el foco (onBlur), formatean la entrada en tiempo real y envían los datos al presionar la tecla Enter. fireEvent es incapaz de poner a prueba esta lógica con precisión, mientras que userEvent la reproduce con exactitud.
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 RegistrationData {
readonly email: string;
readonly username: string;
}
interface RegistrationFormProps {
readonly onSubmit: (data: RegistrationData) => void;
}
function RegistrationForm({ onSubmit }: RegistrationFormProps) {
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [emailError, setEmailError] = useState<string | null>(null);
const handleEmailBlur = () => {
if (!email.includes('@')) {
setEmailError('Introduzca una dirección de correo válida');
} else {
setEmailError(null);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!emailError && email && username) {
onSubmit({ email, username });
}
};
return (
<form onSubmit={handleSubmit} aria-label="Formulario de registro">
<div>
<label htmlFor="reg-email">Correo</label>
<input
id="reg-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
onBlur={handleEmailBlur}
aria-invalid={Boolean(emailError)}
aria-describedby={emailError ? 'email-err' : undefined}
/>
{emailError && (
<span id="email-err" role="alert">
{emailError}
</span>
)}
</div>
<div>
<label htmlFor="reg-username">Nombre de usuario</label>
<input
id="reg-username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<button type="submit">Registrarse</button>
</form>
);
}
describe('RegistrationForm with userEvent', () => {
it('valida el blur en navegación con tabulador y envía el formulario', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<RegistrationForm onSubmit={handleSubmit} />);
const emailInput = screen.getByRole('textbox', { name: /correo/i });
const usernameInput = screen.getByRole('textbox', { name: /nombre de usuario/i });
const submitBtn = screen.getByRole('button', { name: /registrarse/i });
// 1. Introducción de correo inválido
await user.type(emailInput, 'invalid-email');
// 2. Presionar Tab desplaza el foco al siguiente campo (disparando onBlur en el email)
await user.tab();
expect(usernameInput).toHaveFocus();
// 3. Validación ejecutada por onBlur
expect(
screen.getByRole('alert')
).toHaveTextContent('Introduzca una dirección de correo válida');
expect(emailInput).toBeInvalid();
// 4. Regreso mediante Shift+Tab, limpieza e introducción de dirección válida
await user.tab({ shift: true });
expect(emailInput).toHaveFocus();
await user.clear(emailInput);
await user.type(emailInput, '[email protected]');
// 5. Transición al username e introducción de datos
await user.tab();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(emailInput).toBeValid();
await user.type(usernameInput, 'alex_lead');
// 6. Envío del formulario presionando Enter directamente desde el input
await user.keyboard('{Enter}');
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(handleSubmit).toHaveBeenCalledWith({
email: '[email protected]',
username: 'alex_lead',
});
});
});Un diálogo modal accesible bajo la especificación WAI-ARIA debe retener el foco en su interior al pulsar Tab y cerrarse al pulsar la tecla Escape.
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useEffect, useRef } from 'react';
interface ModalProps {
readonly isOpen: boolean;
readonly onClose: () => void;
readonly title: string;
}
function AccessibleModal({ isOpen, onClose, title }: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null);
const firstButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!isOpen) return;
// Foco en el primer elemento interactivo al abrir
firstButtonRef.current?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
return;
}
if (e.key === 'Tab' && modalRef.current) {
const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
ref={modalRef}
>
<h2 id="modal-title">{title}</h2>
<button ref={firstButtonRef} type="button">
Acción 1
</button>
<button type="button">
Acción 2
</button>
<button type="button" onClick={onClose} aria-label="Cerrar ventana">
Cancelar
</button>
</div>
);
}
describe('AccessibleModal Keyboard Navigation', () => {
it('retiene el foco dentro de la ventana (Focus Trap) y se cierra con Escape', async () => {
const user = userEvent.setup();
const handleClose = vi.fn();
render(
<div>
<button type="button">Botón externo de página</button>
<AccessibleModal
isOpen={true}
onClose={handleClose}
title="Confirmar Acción"
/>
</div>
);
const action1Btn = screen.getByRole('button', { name: 'Acción 1' });
const action2Btn = screen.getByRole('button', { name: 'Acción 2' });
const cancelBtn = screen.getByRole('button', { name: 'Cerrar ventana' });
// 1. Al abrirse, el foco se posiciona automáticamente en el primer botón
expect(action1Btn).toHaveFocus();
// 2. Navegación Tab hacia adelante
await user.tab();
expect(action2Btn).toHaveFocus();
await user.tab();
expect(cancelBtn).toHaveFocus();
// 3. Tab desde el último elemento retorna cíclicamente el foco al primero
await user.tab();
expect(action1Btn).toHaveFocus();
// 4. Shift+Tab desde el primer elemento retorna al último
await user.tab({ shift: true });
expect(cancelBtn).toHaveFocus();
// 5. Cierre del modal con la tecla Escape
await user.keyboard('{Escape}');
expect(handleClose).toHaveBeenCalledTimes(1);
});
});Los desplegables requieren navegación fluida con flechas del teclado (ArrowDown, ArrowUp), selección mediante Enter y cierre con Escape.
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 Option {
readonly id: string;
readonly label: string;
}
interface CustomSelectProps {
readonly options: readonly Option[];
readonly onSelect: (option: Option) => void;
}
function CustomSelect({ options, onSelect }: CustomSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen) {
if (e.key === 'ArrowDown' || e.key === 'Enter') {
e.preventDefault();
setIsOpen(true);
}
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
setHighlightedIndex((prev) => (prev + 1) % options.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlightedIndex((prev) => (prev - 1 + options.length) % options.length);
} else if (e.key === 'Enter') {
e.preventDefault();
const selected = options[highlightedIndex];
if (selected) {
onSelect(selected);
setIsOpen(false);
}
} else if (e.key === 'Escape') {
e.preventDefault();
setIsOpen(false);
}
};
return (
<div>
<button
type="button"
aria-haspopup="listbox"
aria-expanded={isOpen}
onClick={() => setIsOpen((prev) => !prev)}
onKeyDown={handleKeyDown}
>
Seleccione un framework
</button>
{isOpen && (
<ul role="listbox" tabIndex={-1}>
{options.map((opt, idx) => (
<li
key={opt.id}
role="option"
aria-selected={idx === highlightedIndex}
>
{opt.label}
</li>
))}
</ul>
)}
</div>
);
}
describe('CustomSelect Arrow Keys Navigation', () => {
const options: readonly Option[] = [
{ id: 'react', label: 'React 19' },
{ id: 'next', label: 'Next.js 15' },
{ id: 'vitest', label: 'Vitest 4' },
];
it('permite seleccionar opciones mediante teclas de flecha y Enter', async () => {
const user = userEvent.setup();
const handleSelect = vi.fn();
render(<CustomSelect options={options} onSelect={handleSelect} />);
const trigger = screen.getByRole('button', { name: 'Seleccione un framework' });
trigger.focus();
// 1. Apertura de la lista con flecha hacia abajo
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('listbox')).toBeInTheDocument();
const optionElements = screen.getAllByRole('option');
expect(optionElements[0]).toHaveAttribute('aria-selected', 'true');
// 2. Desplazamiento del resaltado
await user.keyboard('{ArrowDown}');
expect(optionElements[1]).toHaveAttribute('aria-selected', 'true');
// 3. Selección de la opción pulsando Enter
await user.keyboard('{Enter}');
expect(handleSelect).toHaveBeenCalledWith({ id: 'next', label: 'Next.js 15' });
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
});user.upload()#La validación de subida de archivos exige la correcta generación del objeto FileList y el evento change con la información del archivo.
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 FileUploadProps {
readonly onUpload: (file: File) => void;
}
function AvatarUploader({ onUpload }: FileUploadProps) {
const [fileName, setFileName] = useState<string | null>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setFileName(file.name);
onUpload(file);
}
};
return (
<div>
<label htmlFor="avatar-file">Subir avatar</label>
<input
id="avatar-file"
type="file"
accept="image/png, image/jpeg"
onChange={handleChange}
/>
{fileName && <p>Archivo seleccionado: {fileName}</p>}
</div>
);
}
describe('AvatarUploader', () => {
it('procesa correctamente la carga de archivos mediante user.upload()', async () => {
const user = userEvent.setup();
const handleUpload = vi.fn();
render(<AvatarUploader onUpload={handleUpload} />);
const input = screen.getByLabelText(/subir avatar/i) as HTMLInputElement;
const file = new File(['avatar-raw-bytes'], 'avatar.png', {
type: 'image/png',
});
await user.upload(input, file);
expect(input.files?.[0]).toBe(file);
expect(input.files?.item(0)?.name).toBe('avatar.png');
expect(handleUpload).toHaveBeenCalledTimes(1);
expect(screen.getByText('Archivo seleccionado: avatar.png')).toBeInTheDocument();
});
});Invocar userEvent.click() directamente (sintaxis legada v13) recrea un contexto de controlador efímero en cada ejecución. Esto descarta el estado de modificadores activos, el foco virtual y la posición del cursor.
// ❌ ANTIPATRÓN (v13): El estado de foco y controlador se pierde entre llamadas
await userEvent.click(button);
// ✅ SOLUCIÓN (v14+): Instancia de sesión unificada para toda la prueba
const user = userEvent.setup();
await user.click(button);Todos los métodos de userEvent devuelven promesas porque programan microtareas asíncronas y retardos físicos del navegador. Omitir await provoca que las comprobaciones expect se evalúen antes de que el evento culmine su procesamiento en React.
// ❌ ANTIPATRÓN: El evento no completará su procesamiento antes del assert
user.type(input, 'test');
expect(input).toHaveValue('test'); // ¡Fallo!
// ✅ SOLUCIÓN: await obligatorio en cada paso
await user.type(input, 'test');
expect(input).toHaveValue('test');fireEvent.change(input, { target: { value: 'abc' } }) sobrescribe bruscamente la propiedad value en un solo paso. No emite los eventos keydown, keypress, keyup ni emula la entrada secuencial, impidiendo que las máscaras de entrada (como teléfonos) y validaciones por pulsación funcionen.
// ❌ ANTIPATRÓN: La máscara de entrada no reaccionará a pulsaciones atómicas
fireEvent.change(phoneInput, { target: { value: '34912345678' } });
// ✅ SOLUCIÓN: Entrada carácter por carácter con eventos de teclado reales
await user.type(phoneInput, '34912345678');Cuando se activan temporizadores falsos (vi.useFakeTimers()), los métodos de userEvent pueden quedar suspendidos indefinidamente porque dependen de temporizadores nativos para emular retardos de escritura. Debe proporcionarse una función de avance a setup().
// ❌ ANTIPATRÓN: La prueba fallará por timeout
vi.useFakeTimers();
const user = userEvent.setup();
// ✅ SOLUCIÓN: Conexión de userEvent con el reloj virtual de Vitest
vi.useFakeTimers();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });Invocación de userEvent.setup() antes de render(), configurando reloj y parámetros de puntero.
Renderizado del árbol en el DOM virtual mediante render() y localización del nodo objetivo por rol ARIA.
Llamada a user.click, user.type o user.keyboard con la palabra clave await obligatoria.
user-event despacha pointerover -> focus -> keydown -> input -> change -> blur.
Comprobación síncrona o asíncrona de modificaciones en el DOM mediante matchers de jest-dom.