Коротко: Руководство по тестированию доступности (a11y) и ARIA-ролей в React Testing Library: методы getByRole, accessible name, logRoles и отладка семантики интерфейсов.
Тестирование интерфейсов часто сводится к проверке того, отрендерился ли конкретный компонент и вызвался ли нужный колбэк. При этом разработчики нередко полагаются на селекторы классов, атрибуты data-testid или поиск по произвольному тексту. Такой подход создает хрупкие тесты, завязанные на детали реализации, и полностью игнорирует доступность (accessibility, a11y) продукта для пользователей скринридеров и ассистивных технологий.
React Testing Library (RTL) спроектирована иначе: ее философия требует взаимодействовать с DOM так, как это делает реальный пользователь. Понимание того, как устроен поиск элементов по ARIA-ролям, как работает вычисление доступного имени (accessible name) и как использовать встроенные механизмы библиотеки, позволяет выявлять проблемы с семантикой еще на этапе запуска автотестов.
Почему RTL ставит доступность во главу угла
Главная идея всей линейки Testing Library сформулирована в ее манифесте: «Чем ближе ваши тесты к тому, как используется ваше приложение, тем больше уверенности они вам дают».
Философия Testing Library: тестирование с точки зрения пользователя
Обычный пользователь не знает о названиях CSS-классов, структуре div-оберток или названиях внутренних пропсов компонента. Он взаимодействует со страницей через визуальные и смысловые ориентиры: заголовки, кнопки, поля ввода, списки и ссылки.
Пользователи ассистивных технологий (например, программ экранного доступа вроде NVDA, VoiceOver или JAWS) ориентируются в приложении через Accessibility Tree (дерево доступности), которое формируется браузером на базе HTML-разметки и спецификации WAI-ARIA. RTL имитирует этот процесс, заставляя разработчиков писать тесты, опираясь именно на семантические ориентиры.
Иерархия запросов: почему getByRole лучше getByTestId
Официальная документация Testing Library определяет строгий приоритет методов поиска (queries):
- Запросы, доступные каждому пользователю (Queries Accessible to Everyone):
getByRole— абсолютный приоритет. Находит элементы в Accessibility Tree по их роли и доступному имени.getByLabelText— оптимален для полей форм, так как проверяет связь между<label>и<input>.getByPlaceholderText— запасной вариант для полей, если нет постоянного лейбла (хотя с точки зрения UX плейсхолдер не заменяет лейбл).getByText— поиск неинтерактивного текстового контента (параграфы, статичные блоки).getByDisplayValue— поиск по текущему значению формы.
- Семантические запросы:
getByAltText— для изображений и медиаконтента.getByTitle— для элементов с атрибутомtitle.
- Тестовые идентификаторы (Test IDs):
getByTestId— низший приоритет. Должен использоваться только тогда, когда элемент не имеет семантического смысла или роль невозможно вычислить стандартными способами.
Если тест завязан на getByTestId('submit-btn'), он успешно пройдет, даже если кнопка сверстана как <div onClick={...}>Отправить</div>. Однако незрячий пользователь не сможет обнаружить эту кнопку, а пользователь с клавиатурной навигацией не сможет сфокусироваться на ней через клавишу Tab. Метод getByRole('button', { name: /отправить/i }) упадет, если элемент не является валидной кнопкой, мгновенно подсветив дефект доступности.
Как React Testing Library работает с деревом доступности (Accessibility Tree)
Браузер транслирует DOM-дерево в параллельную структуру — Accessibility Tree. В нем каждый узел имеет свою роль (role), доступное имя (accessible name), описание (accessible description) и набор состояний (states/properties).
Неявные (implicit) роли семантического HTML против явных role="..."
Семантические теги HTML5 по умолчанию обладают встроенными (имплицитными) ARIA-ролями:
<button>➔role="button"<a href="...">➔role="link"(без атрибутаhrefссылка не получает рольlink)<input type="checkbox">➔role="checkbox"<nav>➔role="navigation"<main>➔role="main"<h1>...<h6>➔role="heading"с соответствующим свойствомlevel<ul>,<ol>➔role="list"<li>➔role="listitem"
Первое правило WAI-ARIA гласит: не используйте ARIA-атрибуты, если ту же функциональность можно реализовать нативным HTML-тегом. getByRole одинаково успешно находит как элементы с явным атрибутом role="button", так и нативные <button>.
// Плохо: потеря семантики и доступности
<div role="button" tabIndex={0} onClick={handleClick}>
Сохранить
</div>
// Хорошо: нативная семантика
<button type="button" onClick={handleClick}>
Сохранить
</button>
В обоих случаях RTL найдет элемент по screen.getByRole('button'), но второй вариант гарантирует доступность по умолчанию без необходимости вручную обрабатывать нажатия клавиш Enter и Space.
Доступное имя (Accessible Name): как RTL сопоставляет текст, aria-label и aria-labelledby
Элемент редко идентифицируется только по роли. На странице может быть несколько кнопок, поэтому getByRole принимает опцию name, которая соответствует алгоритму Accessible Name Computation.
Источники доступного имени (в порядке приоритета):
aria-labelledby— ссылка на ID другого элемента, чей текст выступает именем.aria-label— явная текстовая строка, переопределяющая внутренний текст.- Внутренний текст элемента (например, текст внутри
<button>Текст</button>). - Нативный атрибут
alt(для изображений) илиtitle.
// Accessible name: "Закрыть модальное окно"
<button aria-label="Закрыть модальное окно">
<svg aria-hidden="true">...</svg>
</button>
// Accessible name: "Удалить профиль"
<h2 id="delete-heading">Удалить профиль</h2>
<button aria-labelledby="delete-heading">
<TrashIcon aria-hidden="true" />
</button>
Практика: написание тестов с использованием getByRole
Рассмотрим основные сценарии использования getByRole в связке с @testing-library/react и @testing-library/user-event.
Фильтрация по роли и имени (опция name)
Опция name поддерживает регулярные выражения, что делает тесты устойчивыми к регистру и переносам строк.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserProfile } from './UserProfile';
test('корректно отображает заголовок и отправляет форму', async () => {
const user = userEvent.setup();
render(<UserProfile />);
// Поиск заголовка определенного уровня
const heading = screen.getByRole('heading', { level: 1, name: /профиль пользователя/i });
expect(heading).toBeInTheDocument();
// Поиск текстового поля по связанному label
const nameInput = screen.getByRole('textbox', { name: /имя/i });
await user.type(nameInput, 'Тимофей');
// Поиск чекбокса
const termsCheckbox = screen.getByRole('checkbox', { name: /согласен с условиями/i });
await user.click(termsCheckbox);
expect(termsCheckbox).toBeChecked();
// Поиск кнопки отправки
const submitBtn = screen.getByRole('button', { name: /сохранить/i });
await user.click(submitBtn);
});
Проверка состояний: aria-expanded, aria-selected, aria-disabled
Интерактивные UI-компоненты (аккордеоны, дропдауны, вкладки) должны сообщать скринридерам о своем текущем состоянии. getByRole позволяет проверять эти состояния через встроенные параметры или сопоставление матчеров jest-dom.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Accordion } from './Accordion';
test('раскрывает секцию аккордеона по клику', async () => {
const user = userEvent.setup();
render(<Accordion title="Детали заказа" content="Информация о доставке" />);
// Проверяем, что кнопка изначально свернута
const trigger = screen.getByRole('button', {
name: /детали заказа/i,
expanded: false
});
expect(trigger).toBeInTheDocument();
await user.click(trigger);
// Проверяем, что состояние обновилось
expect(screen.getByRole('button', { name: /детали заказа/i, expanded: true })).toBeInTheDocument();
expect(screen.getByText(/информация о доставке/i)).toBeVisible();
});
Аналогично можно фильтровать элементы по другим свойствам доступности:
{ selected: true }— для вкладок (role="tab") и элементов списков (role="option").{ checked: true }— для радиокнопок и чекбоксов.{ pressed: true }— для кнопок-переключателей (toggle buttons сaria-pressed).
Тестирование динамических обновлений и Live Regions (aria-live, role="alert")
Когда на странице асинхронно появляется важное сообщение (например, уведомление об ошибке или успешной операции), ассистивные технологии уведомляют пользователя только в том случае, если область размечена как Live Region.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
test('отображает доступное сообщение об ошибке при неверных данных', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.click(screen.getByRole('button', { name: /войти/i }));
// role="alert" неявно содержит aria-live="assertive"
const errorMessage = await screen.findByRole('alert');
expect(errorMessage).toHaveTextContent(/неверный логин или пароль/i);
});
Скрытые элементы и исключение из Accessibility Tree
Одна из частых причин падения тестов — попытка взаимодействовать с элементом, который скрыт от ассистивных технологий.
Как RTL обрабатывает aria-hidden="true", display: none и visibility: hidden
По умолчанию методы getByRole, queryByRole и findByRole игнорируют элементы, исключенные из дерева доступности в соответствии со спецификацией WAI-ARIA 1.2:
- Элементы с CSS-свойствами
display: noneилиvisibility: hidden. - Элементы с атрибутом
aria-hidden="true"или находящиеся внутри контейнера с таким атрибутом. - Элементы с атрибутом
hidden.
Если элемент визуально скрыт через display: none, вызов screen.getByRole('button') выбросит ошибку Unable to find an accessible element with the role "button".
Проверка видимости для скринридеров: опция hidden: true и утилита isInaccessible
Если необходимо убедиться, что декоративный элемент (например, фоновая иконка) корректно скрыт от скринридера, можно использовать опцию { hidden: true } в getByRole или функцию isInaccessible:
import { render, screen, isInaccessible } from '@testing-library/react';
import { Modal } from './Modal';
test('фоновые элементы корректно скрываются при открытии модального окна', () => {
render(
<div>
<main id="main-content">Основной контент</main>
<Modal isOpen={true} title="Диалог" />
</div>
);
const mainContent = document.getElementById('main-content');
// Проверяем, что основной контент исключен из Accessibility Tree
expect(isInaccessible(mainContent!)).toBe(true);
// Поиск с флагом hidden: true для технической верификации
const hiddenRegion = screen.getByRole('main', { hidden: true });
expect(hiddenRegion).toBeInTheDocument();
});
Инструменты отладки и продвинутый анализ
Когда getByRole не может найти нужный узел, RTL формирует детализированное сообщение об ошибке, перечисляя все доступные роли в текущем DOM-дереве.
Использование logRoles для анализа DOM-дерева в тестах
Утилита logRoles позволяет распечатать в консоль текущее дерево ролей отрендеренного фрагмента DOM с вычисленными доступными именами:
import { render, logRoles } from '@testing-library/react';
import { NavigationMenu } from './NavigationMenu';
test('отладка структуры доступности меню', () => {
const { container } = render(<NavigationMenu />);
// Выведет в stdout иерархию доступности
logRoles(container);
});
Пример вывода logRoles:
navigation:
Name "":
--------------------------------------------------
list:
Name "":
--------------------------------------------------
listitem:
Name "":
--------------------------------------------------
link:
Name "Главная":
<a href="/" />
--------------------------------------------------
listitem:
Name "":
--------------------------------------------------
button:
Name "Выйти":
<button />
Такой вывод сразу показывает, какие имена и роли браузер присвоил компонентам, помогая локализовать отсутствие aria-label или некорректную вложенность.
Чтение подсказок RTL при падении тестов
Если запрос падает:
TestingLibraryElementError: Unable to find an accessible element with the role "button" and name `/закрыть/i`
Here are the accessible roles:
button:
Name "Close dialog":
<button aria-label="Close dialog" />
Ошибка прямо указывает на несовпадение имени: тест искал "закрыть", тогда как в коде установлено доступное имя "Close dialog".
Частые ошибки при тестировании доступности в React
Злоупотребление role вместо нативных тегов (First Rule of ARIA)
Распространенная ошибка — навешивание ролей на несемантичные теги без реализации сопутствующего поведения:
// Ошибка: <div> не поддерживает фокус по умолчанию и события клавиатуры
<div role="button" onClick={onClick}>Кликни</div>
// В тесте:
screen.getByRole('button', { name: /кликни/i }); // Тест пройдет успешно!
Хотя RTL найдет этот элемент, в реальном браузере пользователь без мыши не сможет активировать этот контрол с клавиатуры. Для предотвращения таких сценариев рекомендуется использовать линтеры (например, eslint-plugin-jsx-a11y), отлавливающие подобные антипаттерны еще до запуска тестов.
Некорректный расчет name при тестировании иконочных кнопок
Кнопка без текста, содержащая только SVG, не имеет доступного имени:
// Ошибка: accessible name равно ""
<button onClick={handleDelete}>
<svg><path d="..." /></svg>
</button>
Тест screen.getByRole('button', { name: /удалить/i }) справедливо упадет. Решение — добавить доступное имя через aria-label, а саму иконку скрыть от скринридеров:
// Корректно
<button type="button" aria-label="Удалить запись" onClick={handleDelete}>
<svg aria-hidden="true" focusable="false"><path d="..." /></svg>
</button>
FAQ (Часто задаваемые вопросы)
1. Чем getByRole принципиально лучше getByText или getByTestId?
getByRole одновременно проверяет два критических аспекта: функциональное назначение элемента (роль) и его текстовую идентификацию (доступное имя). getByTestId полностью изолирован от типа элемента и его доступности, а getByText может случайно выбрать неинтерактивный текст внутри обертки вместо самого элемента управления.
2. Всегда ли нужно явно указывать атрибут role в JSX для прохождения тестов?
Нет. В большинстве случаев атрибут role указывать не нужно. Нативные HTML-элементы (<button>, <a>, <input>, <select>, <dialog>, <header>) уже обладают неявными (implicit) ролями, которые React Testing Library считывает автоматически.
3. Как протестировать кнопку, содержащую только SVG-иконку без текста?
Добавьте кнопке атрибут aria-label="Описание действия", а внутреннему тегу <svg> — атрибут aria-hidden="true". В тесте находите элемент стандартным способом: screen.getByRole('button', { name: /описание действия/i }).
4. Почему тест падает с ошибкой, что элемент не найден, хотя в выводе debug() он есть?
Скорее всего, элемент исключен из Accessibility Tree. Это происходит, если сам элемент или один из его родителей имеет атрибут aria-hidden="true", стиль display: none или visibility: hidden. Для поиска таких элементов требуется передать опцию { hidden: true } в запрос либо проверить верстку на корректность скрытия.
5. Заменяет ли тестирование через React Testing Library аудит с помощью axe-core?
RTL стимулирует использование доступных селекторов и семантики, но не проверяет контрастность цветов, структуру всех ARIA-связей на странице или фокус-ловушки. Для комплексного тестирования рекомендуется комбинировать RTL с библиотекой jest-axe (или @axe-core/react) и регулярно проводить ручное тестирование со скринридерами.
Заключение
Использование getByRole в качестве основного селектора в React Testing Library меняет подход к разработке UI-компонентов. Тесты перестают быть просто проверкой бизнес-логики и становятся первой линией контроля доступности. Ориентируясь на дерево доступности и доступные имена элементов, вы создаете более устойчивый тестовый набор, защищенный от изменений деталей реализации, и гарантируете равный доступ к вашему приложению для всех категорий пользователей.




.svg.webp)





