RTL и user-event: тестируйте контракт, который видит пользователь
Testing Library запрашивает дерево доступности — role выше label выше text выше testid — тесты падают, когда ломается UX, а не разметка. user-event проигрывает реальные цепочки событий, fireEvent шлёт одно синтетическое. act-предупреждения — это неожиданные обновления.
Команда дизайн-системы выкатила визуальный рефреш: старый Button заменили стилизованным div с onClick — без элемента button, без обработки клавиатуры, без focus ring. Все 1 400 тестов в приложении-потребителе остались зелёными, потому что каждый из них находил элементы через getByTestId и кликал через fireEvent.click — а fireEvent радостно диспатчит синтетический клик на любой узел, фокусируемый или нет. Через три дня после релиза государственный клиент завёл эскалацию по комплаенсу: его сотрудники работают с клавиатуры, и каждое действие отправки в приложении молча перестало работать — Tab перепрыгивал фальшивые кнопки, Enter ничего не делал, скринридеры объявляли безымянные группы. В контракте был пункт о доступности; в копию переписки добавили юристов. Фраза из постмортема, которая прижилась: у сьюта был 100% pass rate и 0% detection rate, потому что data-testid переживает любую разметку, а fireEvent работает на чём угодно — тесты проверяли реализацию, и реализация была в порядке. getByRole("button", { name: /submit/i }) упал бы в первом же PR: div не даёт роль button. Починка компонента заняла час. Три недели ушли на миграцию 1 400 запросов — чтобы сьют упал в следующий раз, когда сломается UX.
Запрос — это и есть проверка
Главный принцип Testing Library — тесты должны походить на то, как софт используют, — не лозунг: он закодирован в API запросов как порядок приоритета, и каждый шаг вниз по этому порядку отдаёт класс регрессий, который тест способен поймать. getByRole запрашивает вычисленное дерево доступности — ту же структуру, которую потребляют вспомогательные технологии, — а опция name фиксирует доступное имя (вычисляемое из label, aria-label, aria-labelledby или текста по алгоритму accname W3C). Запрос по роли — это две проверки в одной: элемент существует и он показан пользователю как правильный тип контрола с правильным именем. getByLabelText проверяет привязку подписи к полю формы — htmlFor, обёртывание или aria-labelledby — ровно ту связь, которую разрешает скринридер; визуально соседняя, но не привязанная подпись проходит проверку getByText и валит эту. getByText утверждает видимый контент для неинтерактивных элементов. getByTestId не утверждает ничего, кроме того, что тест и разметка согласны насчёт одной строки. Это осознанный аварийный люк — область с canvas, сторонний виджет, чью разметку вы не можете поправить, — а не дефолт.
screen запрашивает весь документ. within(node) ограничивает запросы поддеревом — и ради читаемости («кнопка Save внутри этого диалога»), и ради производительности на больших DOM, что важнее, чем ожидает большинство команд (цифры ниже).
PR заменяет getByTestId('save-btn') на getByRole('button', { name: /save/i }), и тест падает: кнопка существует, но без доступного имени — в ней только SVG-иконка. Какой вывод сделает сеньор?
Что на самом деле диспатчит user-event
fireEvent.click диспатчит ровно одно синтетическое MouseEvent. Реальный клик пользователя — это последовательность: pointerover → pointerenter → pointermove → pointerdown → mousedown → focus → pointerup → mouseup → click — и user-event v14 проигрывает всё взаимодействие целиком, порядка девяти событий с правильным порядком, координатами и побочными эффектами фокуса. user.type диспатчит keydown → keypress → input → keyup на каждый символ: строка из 20 символов — это 80 с лишним событий, каждое из которых уважает maxLength, disabled, readonly и текущее выделение. fireEvent.change телепортирует значение в инпут целиком — он никогда не вызывает обработчики keydown, его не ограничивает maxLength, и он даст зелёный свет состояниям, которые пользователь буквально не может создать. Компонент, отправляющий форму по Enter через onKeyDown, нетестируем через fireEvent.change и тривиально тестируется через await user.type(input, "query{Enter}").
Механика v14 важна на код-ревью. const user = userEvent.setup() до render создаёт инстанс, привязанный к одному согласованному состоянию устройств ввода (клавиатура, указатель, буфер обмена). Каждый API асинхронный — await user.click(...) — потому что user-event уступает поток между событиями цепочки, чтобы React успевал коммитить промежуточное состояние, как реальный браузер перемежает диспатч событий с рендерингом. Пропущенный await — не вопрос стиля: взаимодействие начинает гоняться с идущими следом проверками и становится постоянным источником act-предупреждений и флейков.
// ❌ проходит, даже когда пользователь заблокирован:
fireEvent.change(input, { target: { value: "abcdefgh" } }); // игнорирует maxLength
fireEvent.click(saveButton); // срабатывает на disabled и нефокусируемых узлах
// ✅ падает так же, как падает пользователь:
const user = userEvent.setup();
await user.type(input, "abcdefgh"); // остановится на maxLength, события на каждую клавишу
await user.click(saveButton); // бросит исключение на pointer-events: noneКомпромисс — цена и строгость. user-event медленнее — больше событий, точки await между ними — и он отказывается выполнять невозможные взаимодействия: клик по элементу с pointer-events: none бросает исключение. Эта строгость и есть смысл: когда user.click падает, пользователь тоже заблокирован. Берите fireEvent только для событий, которые пользователь не может породить напрямую (scroll, кастомное событие из не-UI источника), а не по умолчанию.
У инпута maxLength={5}. Тест делает fireEvent.change(input, { target: { value: 'abcdefgh' } }) и проверяет, что значение равно 'abcdefgh', — проходит. Тот же тест на await user.type(input, 'abcdefgh') падает. Кто прав?
act()-предупреждения: симптом, а не цель
Предупреждение — «An update to X inside a test was not wrapped in act(…)» — не означает «сыпьте act, пока не замолчит». RTL уже оборачивает render, fireEvent и каждый вызов user-event в act. Предупреждение означает: обновление состояния произошло, когда ни один act-осведомлённый скоуп не был открыт — почти всегда это асинхронная операция, которую тест не дождался: fetch, зарезолвившийся после последней проверки, таймер, сработавший после возврата из теста, пропущенный await у user-event. Правильное лечение — дождаться видимого пользователю результата (await screen.findByText(...)) или заставить тест владеть своей незавершённой работой; обёртывание случайных строк в act() глушит пожарную сигнализацию, пока обновление всё равно приземляется в неконтролируемый момент. Команды, глобально фильтрующие act-предупреждения из console.error, встречают их снова как флейки в CI, и хуже: позднее обновление протекает в следующий тест — так рождаются падения, зависящие от порядка.
Инвентарь отказов из Hook этого урока обобщается: сьют на сплошных testid переживает рефакторинги и пропускает сломанный UX; сьют только на fireEvent остаётся зелёным, пока гниёт обработка клавиатуры и ограничений; сьют, глушащий act, конвертирует детерминированные предупреждения в вероятностные флейки. У всех трёх один корень: тест перестал походить на пользователя.
▸Почему это работает
Почему запросы по роли медленнее — и насколько, честно? getByRole вычисляет роли и доступные имена: алгоритм accname обходит поддеревья, разрешает ссылки aria-labelledby и проверяет видимость. В jsdom getByRole по всему документу на DOM из тысяч узлов может стоить десятки миллисекунд за вызов, тогда как getByTestId — это querySelector за доли миллисекунды. На нескольких сотнях тестов это складывается в минуты — обычный аргумент сторонников testid. Сеньорский ответ — скоупинг, а не капитуляция: within(screen.getByRole("table")) ограничивает дорогое вычисление поддеревом, и сьют сохраняет проверки контракта. Ещё честность: у jsdom нет лэйаут-движка, поэтому видимость в вычислении имени аппроксимируется по разобранным стилям — небольшой класс случаев, где разрешение роли отличается от реального браузера. Эти случаи — для слоя E2E, а не повод отказаться от ролей везде.
- 01Почему getByRole ловит регрессии, которые getByTestId поймать не может, и какова честная цена его использования?
- 02Сравните, что реально диспатчат fireEvent.click / fireEvent.change и user-event, и назовите случаи, когда fireEvent — правильный инструмент.
Testing Library переворачивает привычный вопрос тестирования: не «работает ли моя реализация», а «держится ли контракт, который воспринимает пользователь». API запросов кодирует это как порядок приоритета с механическими зубами. getByRole разрешает дерево доступности и доступное имя — две проверки в одной, и причина, по которой рефакторинг div-с-onClick падает немедленно под запросами по роли, проплывая сквозь testid-сьют, как показала комплаенс-эскалация из Hook ценой трёхнедельной миграции запросов. getByLabelText утверждает ровно ту привязку подписи, которую разрешает скринридер; getByText — видимый контент; getByTestId — лишь согласие строк между тестом и разметкой, поэтому он переживает любую поломку и зарезервирован за областями действительно без ролей. Сторона взаимодействий зеркалит сторону запросов. fireEvent диспатчит одно синтетическое событие без состояния устройства: change телепортирует значения мимо maxLength и обработчиков keydown, click срабатывает на узлах, до которых пользователь не доберётся. user-event v14 симулирует устройство ввода — setup() привязывает согласованное состояние клавиатуры и указателя, каждый API асинхронный и уступает поток между событиями настоящей цепочки (девять событий на клик, четыре с лишним на нажатие), чтобы React коммитил так, как браузер перемежает диспатч с рендером, а невозможные взаимодействия бросают исключение — симуляция встаёт на сторону заблокированного пользователя. Цена — скорость и строгость, окупаемые пойманными регрессиями. Наконец, act-предупреждения: RTL уже оборачивает render и user-event в act, поэтому предупреждение всегда означает обновление вне ожидаемого скоупа — недождавшийся fetch, таймер или вызов user-event. Ждите видимый пользователю результат через findBy; не глушите предупреждение — подавленный act-сигнал это будущий флейк, зависящий от порядка тестов. Общий корень всех отказов урока — testid везде, только fireEvent, глушение act — тест, переставший походить на пользователя.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.