У браузерному оточенні фізична взаємодія користувача з інтерфейсом ніколи не зводиться до ізольованої події. Коли користувач клікає по кнопці, натискає клавішу або вставляє текст із буфера обміну, браузер запускає складний каскад подій життєвого циклу DOM (Event Dispatch Pipeline), що включає фази захоплення (capture phase), спливання (bubbling phase), переміщення фокуса та зміни активного елемента (document.activeElement).
Історична утиліта fireEvent з @testing-library/dom була лише тонкою обгорткою над нативним методом node.dispatchEvent(new Event(type)). Виклик fireEvent.click(button) відправляє у вузол виключно синтетичну подію click. При цьому браузерний рушій:
pointerdown, mousedown).pointer-events: none елемента.focusin, focus, blur, focusout.Space або Enter.Через це тести з fireEvent створювали оманливе відчуття безпеки: форма з логікою валідації по onBlur проходила тести, але наглухо ламалася в продакшені у реальних користувачів, оскільки fireEvent.change не викликав переміщення фокуса.
@testing-library/user-event версії 14+ фундаментально розв'язує цю проблему. Вона моделює віртуальний браузерний стек: керує віртуальним вказівником (миша, тач-скрін), віртуальною клавіатурою, буфером обміну та синхронізує стан DOM у суворій відповідності до специфікацій W3C UI Events.
У версії user-event v14+ взаємодія будується навколо інстансу сесії, що створюється через виклик userEvent.setup(). Цей виклик ініціалізує внутрішній стан віртуального драйвера пристроїв введення: поточне положення курсора, фокус, натиснуті модифікатори клавіш (Shift, Alt, Control, Meta) та активні захоплення вказівника (pointer capture).
+-----------------------------------------------------------------------+
| userEvent.click(submitButton) |
+-----------------------------------------------------------------------+
│
▼ (1. Наведення вказівника)
+-----------------------------------------------------------------------+
| pointerover -> mouseover -> pointerenter -> mouseenter |
+-----------------------------------------------------------------------+
│
▼ (2. Натискання кнопки миші)
+-----------------------------------------------------------------------+
| pointerdown -> mousedown |
+-----------------------------------------------------------------------+
│
▼ (3. Переміщення фокуса)
+-----------------------------------------------------------------------+
| Попередній елемент: blur -> focusout |
| Новий елемент: focusin -> focus |
| Оновлення: document.activeElement = submitButton |
+-----------------------------------------------------------------------+
│
▼ (4. Відпускання та тригер дії)
+-----------------------------------------------------------------------+
| pointerup -> mouseup -> click |
+-----------------------------------------------------------------------+fireEvent vs userEvent#| Критерій | fireEvent | userEvent v14+ |
| :--- | :--- | :--- |
| Принцип роботи | Одиночний виклик node.dispatchEvent(event) | Каскад браузерних подій відповідно до специфікації W3C |
| Керування фокусом | Фокус не змінюється, document.activeElement незмінний | Автоматичне переведення фокуса, виклик blur та focus |
| Введення тексту | Миттєво підміняє value через input/change | Послідовне введення символів: keydown -> keypress -> input -> keyup |
| Перевірка інтерактивності | Спрацьовує навіть на disabled елементах | Блокує дії на заблокованих або прихованих елементах |
| Синхронність | Синхронний виклик (fireEvent.click(...)) | Асинхронний проміс (await user.click(...)) |
userEvent.setup()#Усі взаємодії зобов'язані починатися з виклику userEvent.setup(). Створення екземпляра повинно відбуватися до монтування компонента у виклику render():
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
it('демонструє базову ініціалізацію userEvent', async () => {
// Налаштування сесії користувача з опціональною конфігурацією
const user = userEvent.setup({
delay: null, // Вимкнення штучних затримок для максимальної швидкості тестів
});
render(<SearchInput />);
const input = screen.getByRole('textbox', { name: /пошук/i });
// Усі методи екземпляра user є асинхронними
await user.type(input, 'Next.js 15');
});UserEvent#user.click(element): Повний цикл кліку (pointerdown, focus, mousedown, pointerup, mouseup, click).user.dblClick(element) / user.tripleClick(element): Подвійний та потрійний клік із виділенням тексту.user.type(element, text, options): Введення послідовності символів із генерацією подій клавіатури на кожен знак.user.clear(element): Очищення текстового поля або textarea з виділенням вмісту та натисканням Backspace.user.tab(options): Переміщення фокуса за табуляцією з урахуванням tabIndex (options.shift: true для зворотного переміщення).user.keyboard(text): Низькорівнева емуляція натискань клавіш: user.keyboard('{Enter}'), user.keyboard('{Escape}'), user.keyboard('[ShiftLeft>]a[/ShiftLeft]').user.upload(input, file): Завантаження файлів у <input type="file">.user.selectOptions(element, values) / user.deselectOptions(element, values): Вибір опцій у селекті <select>.user.paste(text): Вставка тексту з буфера обміну в поточний активний елемент із викликом clipboardData.onBlur та сабмітом#Реальні форми перевіряють валідність полів при втраті фокуса (onBlur), форматують введення на льоту і відправляють дані при натисканні клавіші Enter. fireEvent не здатен коректно протестувати таку логіку, тоді як userEvent відтворює її з максимальною точністю.
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('Введіть коректну адресу електронної пошти');
} else {
setEmailError(null);
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!emailError && email && username) {
onSubmit({ email, username });
}
};
return (
<form onSubmit={handleSubmit} aria-label="Форма реєстрації">
<div>
<label htmlFor="reg-email">Email</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">Ім'я користувача</label>
<input
id="reg-username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<button type="submit">Зареєструватися</button>
</form>
);
}
describe('RegistrationForm with userEvent', () => {
it('перевіряє blur-валідацію при перемиканні табом та відправку форми', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<RegistrationForm onSubmit={handleSubmit} />);
const emailInput = screen.getByRole('textbox', { name: /email/i });
const usernameInput = screen.getByRole('textbox', { name: /ім'я користувача/i });
const submitBtn = screen.getByRole('button', { name: /зареєструватися/i });
// 1. Введення некоректного email
await user.type(emailInput, 'invalid-email');
// 2. Натискання клавіші Tab переводить фокус на наступне поле (тригерить onBlur на email)
await user.tab();
expect(usernameInput).toHaveFocus();
// 3. Валідація спрацювала по onBlur
expect(
screen.getByRole('alert')
).toHaveTextContent('Введіть коректну адресу електронної пошти');
expect(emailInput).toBeInvalid();
// 4. Повернення назад через Shift+Tab, очищення та введення коректної адреси
await user.tab({ shift: true });
expect(emailInput).toHaveFocus();
await user.clear(emailInput);
await user.type(emailInput, '[email protected]');
// 5. Перехід до username та введення даних
await user.tab();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(emailInput).toBeValid();
await user.type(usernameInput, 'alex_lead');
// 6. Відправка форми натисканням Enter прямо з поля введення
await user.keyboard('{Enter}');
expect(handleSubmit).toHaveBeenCalledTimes(1);
expect(handleSubmit).toHaveBeenCalledWith({
email: '[email protected]',
username: 'alex_lead',
});
});
});Доступне діалогове вікно за специфікацією WAI-ARIA зобов'язане утримувати фокус всередині себе при натисканні Tab та закриватися при натисканні клавіші 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;
// Фокус на перший інтерактивний елемент при відкритті
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">
Дія 1
</button>
<button type="button">
Дія 2
</button>
<button type="button" onClick={onClose} aria-label="Закрити вікно">
Скасувати
</button>
</div>
);
}
describe('AccessibleModal Keyboard Navigation', () => {
it('утримує фокус всередині вікна (Focus Trap) та закривається по Escape', async () => {
const user = userEvent.setup();
const handleClose = vi.fn();
render(
<div>
<button type="button">Зовнішня кнопка сторінки</button>
<AccessibleModal
isOpen={true}
onClose={handleClose}
title="Підтвердження дії"
/>
</div>
);
const action1Btn = screen.getByRole('button', { name: 'Дія 1' });
const action2Btn = screen.getByRole('button', { name: 'Дія 2' });
const cancelBtn = screen.getByRole('button', { name: 'Закрити вікно' });
// 1. При відкритті фокус автоматично переходить на першу кнопку
expect(action1Btn).toHaveFocus();
// 2. Навігація Tab вперед
await user.tab();
expect(action2Btn).toHaveFocus();
await user.tab();
expect(cancelBtn).toHaveFocus();
// 3. Tab з останнього елемента циклічно повертає фокус на перший елемент
await user.tab();
expect(action1Btn).toHaveFocus();
// 4. Shift+Tab з першого елемента циклічно повертає на останній
await user.tab({ shift: true });
expect(cancelBtn).toHaveFocus();
// 5. Закриття модального вікна клавішею Escape
await user.keyboard('{Escape}');
expect(handleClose).toHaveBeenCalledTimes(1);
});
});Компоненти випадних списків вимагають навігації стрілками клавіатури (ArrowDown, ArrowUp), вибору через Enter та закриття по 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}
>
Оберіть фреймворк
</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('дозволяє обирати опції стрілками клавіатури та клавішею Enter', async () => {
const user = userEvent.setup();
const handleSelect = vi.fn();
render(<CustomSelect options={options} onSelect={handleSelect} />);
const trigger = screen.getByRole('button', { name: 'Оберіть фреймворк' });
trigger.focus();
// 1. Відкриття списку стрілкою вниз
await user.keyboard('{ArrowDown}');
expect(screen.getByRole('listbox')).toBeInTheDocument();
const optionElements = screen.getAllByRole('option');
expect(optionElements[0]).toHaveAttribute('aria-selected', 'true');
// 2. Переміщення підсвітки вниз
await user.keyboard('{ArrowDown}');
expect(optionElements[1]).toHaveAttribute('aria-selected', 'true');
// 3. Вибір опції натисканням Enter
await user.keyboard('{Enter}');
expect(handleSelect).toHaveBeenCalledWith({ id: 'next', label: 'Next.js 15' });
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});
});user.upload()#Перевірка завантаження файлів вимагає коректної генерації об'єкта FileList та події change з метаданими файлів.
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">Завантажити аватар</label>
<input
id="avatar-file"
type="file"
accept="image/png, image/jpeg"
onChange={handleChange}
/>
{fileName && <p>Обрано файл: {fileName}</p>}
</div>
);
}
describe('AvatarUploader', () => {
it('коректно обробляє завантаження файлу через user.upload()', async () => {
const user = userEvent.setup();
const handleUpload = vi.fn();
render(<AvatarUploader onUpload={handleUpload} />);
const input = screen.getByLabelText(/завантажити аватар/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('Обрано файл: avatar.png')).toBeInTheDocument();
});
});Прямий виклик userEvent.click() (синтаксис v13) перестворює тимчасовий внутрішній контекст драйвера на кожен виклик. Це скидає стан натиснутих клавіш-модифікаторів, віртуальний фокус та позицію каретки.
// ❌ АНТИПАТЕРН (v13): Стан фокуса та драйвера втрачається між викликами
await userEvent.click(button);
// ✅ РІШЕННЯ (v14+): Єдиний екземпляр сесії на весь тест
const user = userEvent.setup();
await user.click(button);Усі методи userEvent повертають проміси, оскільки вони планують асинхронні мікротаски та фізичні затримки браузера. Пропуск await призводить до того, що перевірки expect виконуються до того, як подія завершить обробку в React.
// ❌ АНТИПАТЕРН: Подія не встигне обробитися до assert
user.type(input, 'test');
expect(input).toHaveValue('test'); // Падіння!
// ✅ РІШЕННЯ: Обов'язковий await на кожному кроці
await user.type(input, 'test');
expect(input).toHaveValue('test');fireEvent.change(input, { target: { value: 'abc' } }) грубо переписує властивість value за один раз. Воно не викликає події keydown, keypress, keyup та не емулює посимвольне введення, через що маски введення (наприклад, телефонні номери) та валідація на натискання клавіш не відпрацьовують.
// ❌ АНТИПАТЕРН: Маска введення не зреагує на атомарні натискання
fireEvent.change(phoneInput, { target: { value: '380991234567' } });
// ✅ РІШЕННЯ: Посимвольне введення з реальними подіями клавіатури
await user.type(phoneInput, '380991234567');Якщо в тесті увімкнено фейкові таймери (vi.useFakeTimers()), методи userEvent зависають, оскільки вони використовують таймери для емуляції затримок введення. У конфігурацію setup() необхідно передавати метод просування таймерів.
// ❌ АНТИПАТЕРН: Тест зависне по таймауту
vi.useFakeTimers();
const user = userEvent.setup();
// ✅ РІШЕННЯ: Зв'язування userEvent з раннером таймерів Vitest
vi.useFakeTimers();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });Виклик userEvent.setup() до виконання render(), налаштування таймерів та параметрів вказівника.
Рендеринг дерева через render() у віртуальний DOM та пошук цільового вузла за ARIA-роллю.
Виклик user.click, user.type або user.keyboard з обов'язковим ключовим словом await.
user-event генерує pointerover -> focus -> keydown -> input -> change -> blur.
Синхронна або асинхронна перевірка змін у DOM через матчери jest-dom.