В браузерном окружении физическое взаимодействие пользователя с интерфейсом никогда не сводится к изолированному событию. Когда пользователь кликает по кнопке, нажимает клавишу или вставляет текст из буфера обмена, браузер запускает сложный каскад событий жизненного цикла 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() до выполнения render(), настройка таймеров и параметров указателя.
Рендеринг дерева через render() в виртуальный DOM и поиск целевого узла по ARIA-роли.
Вызов user.click, user.type или user.keyboard с обязательным ключевым словом await.
user-event генерирует pointerover -> focus -> keydown -> input -> change -> blur.
Синхронная или асинхронная проверка изменений в DOM через матчеры jest-dom.
Почему вызов fireEvent.change не позволяет обнаружить ошибки валидации полей ввода, завязанных на потерю фокуса (onBlur)?
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): Очистка текстового поля или текстовой области с выделением содержимого и нажатием 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();
});
});Что произойдет, если в тесте с активированными таймерами vi.useFakeTimers() вызвать userEvent.setup() без опции advanceTimers?
Прямой вызов 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: '79991234567' } });
// ✅ РЕШЕНИЕ: Посимвольный ввод с реальными событиями клавиатуры
await user.type(phoneInput, '79991234567');Если в тесте включены фейковые таймеры (vi.useFakeTimers()), методы userEvent зависают, так как они используют таймеры для эмуляции задержек ввода. В конфигурацию setup() необходимо передавать метод продвижения таймеров.
// ❌ АНТИПАТТЕРН: Тест зависнет по таймауту
vi.useFakeTimers();
const user = userEvent.setup();
// ✅ РЕШЕНИЕ: Связывание userEvent с раннером таймеров Vitest
vi.useFakeTimers();
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });