Сайт использует сookies для хранения данных. Продолжая использовать сайт, вы даёте согласие на работу с этими файлами.

ОК
💻
Технологии
Опубликовано:
14.09.2026
Обновлено:
14.09.2026

Тестирование доступности (a11y) и ARIA-ролей в React Testing Library

Тимофей Ищенко

Коротко: Руководство по тестированию доступности (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):

  1. Запросы, доступные каждому пользователю (Queries Accessible to Everyone):
    • getByRole — абсолютный приоритет. Находит элементы в Accessibility Tree по их роли и доступному имени.
    • getByLabelText — оптимален для полей форм, так как проверяет связь между <label> и <input>.
    • getByPlaceholderText — запасной вариант для полей, если нет постоянного лейбла (хотя с точки зрения UX плейсхолдер не заменяет лейбл).
    • getByText — поиск неинтерактивного текстового контента (параграфы, статичные блоки).
    • getByDisplayValue — поиск по текущему значению формы.
  2. Семантические запросы:
    • getByAltText — для изображений и медиаконтента.
    • getByTitle — для элементов с атрибутом title.
  3. Тестовые идентификаторы (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.

Источники доступного имени (в порядке приоритета):

  1. aria-labelledby — ссылка на ID другого элемента, чей текст выступает именем.
  2. aria-label — явная текстовая строка, переопределяющая внутренний текст.
  3. Внутренний текст элемента (например, текст внутри <button>Текст</button>).
  4. Нативный атрибут 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-компонентов. Тесты перестают быть просто проверкой бизнес-логики и становятся первой линией контроля доступности. Ориентируясь на дерево доступности и доступные имена элементов, вы создаете более устойчивый тестовый набор, защищенный от изменений деталей реализации, и гарантируете равный доступ к вашему приложению для всех категорий пользователей.

Источники

Это авторская статья, основанная на личном опыте и субъективном взгляде автора. Заметили ошибку или битую ссылку? Сообщите нам: info@codesrc.ru - мы оперативно исправим. Спасибо, что помогаете делать блог лучше.
Следите за нами в соцсетях:

Читайте также