Исторически компонентное тестирование в экосистеме React опиралось на библиотеки вроде Enzyme, пропагандировавшие методику поверхностного рендеринга (shallow rendering) и прямую инспекцию внутренних структур компонентов: проверку стейта (wrapper.state()), инспекцию переданных пропсов дочерних узлов или вызовы приватных методов экземпляра класса.
Такой подход нес фундаментальный архитектурный дефект — сильное зацепление (tight coupling) тестов с деталями реализации. При рефакторинге компонента (например, переходе с классового компонента на хуки, разбиении одного монолитного хука на несколько подхуков или изменении структуры внутреннего DOM-дерева) тесты падали, даже если визуальное поведение и публичный контракт компонента оставались на 100% корректными. Возникала проблема ложноотрицательных результатов (false positives), что обесценивало автоматизацию.
Тестирование поведения (Behavior-Driven Testing) лежит в основе философии React Testing Library (RTL), сформулированной Кентом К. Доддсом (Kent C. Dodds):
«The more your tests resemble the way your software is used, the more confidence they can give you.»
(Чем больше ваши тесты похожи на то, как используется ваше приложение, тем больше уверенности они могут вам дать.)
В стандартах современной веб-разработки и React 19 интерфейс тестируется на стыке DOM-дерева и дерева доступности (Accessibility Tree). Это гарантирует, что успешный тест валидирует не только бизнес-логику, но и цифровую доступность (accessibility / a11y) интерфейса для всех категорий пользователей.
В парадигме React Testing Library компонент монтируется в реальное или виртуальное DOM-окружение (jsdom или happy-dom). Тестовый раннер (Vitest 4+) не имеет прямого доступа к внутренним переменным замыканий React Fiber, ссылкам useRef или внутреннему состоянию useState.
Вместо этого тест взаимодействует с интерфейсом через два ключевых канала:
@testing-library/user-event.+-----------------------------------------------------------------------+
| Пользователь / Скринридер |
+-----------------------------------------------------------------------+
│
▼ (Восприятие через UI & ARIA)
+-----------------------------------------------------------------------+
| Accessibility Tree (AOM) & DOM Tree |
| - role: "button", name: "Оформить заказ" |
| - role: "textbox", name: "Электронная почта" |
| - role: "status", aria-live: "polite" |
+-----------------------------------------------------------------------+
▲
│ (Семантические селекторы RTL)
+-----------------------------------------------------------------------+
| React Testing Library (screen.getByRole / findBy) |
+-----------------------------------------------------------------------+
▲
│ (Изоляция: рендер черного ящика)
+-----------------------------------------------------------------------+
| React 19 Component (Fiber / Hooks / Private State) |
+-----------------------------------------------------------------------+При написании тестов критически важно классифицировать элементы архитектуры компонента:
| Категория | Детали реализации (НЕ ТЕСТИРУЕМ) | Публичный контракт (ТЕСТИРУЕМ) |
| :--- | :--- | :--- |
| Состояние | Имена переменных в useState, порядок вызовов хуков | Текст, появляющийся в DOM при переключении состояния |
| Обработчики | Названия функций handleFormSubmit, onClickInternal | Реакция системы на физический клик по кнопке |
| Разметка | Вложенные <div>, служебные CSS-классы Tailwind | Семантический тег <button> или <form aria-label="..."> |
| Запросы | Экземпляр клиента Axios или ссылка на global.fetch | Изменение интерфейса при получении HTTP-ответа или ошибки |
Определите семантическую роль элемента в Accessibility Tree: button, textbox, heading, link, dialog, status, listbox.
Определите, как элемент озвучивается скринридером: текст внутри тега, aria-label, заголовок h1-h6 или связанный label htmlFor.
Используйте getByRole для проверки наличия, queryByRole для проверки отсутствия, findByRole для асинхронного ожидания.
Добавьте опции проверки интерактивных состояний: { expanded: true, pressed: false, hidden: false }.
Только если узел является чисто динамическим холстом (canvas) или сложным SVG, используйте data-testid.
Почему в React Testing Library наивысшим приоритетом обладает селектор getByRole, а не getByTestId или поиск по CSS-классам?
RTL предоставляет строгий приоритет выборки элементов. Использование селекторов низкого приоритета допускается только тогда, когда исчерпаны семантические возможности стандарта WAI-ARIA.
+-----------------------------------------------------------------------+
| ПРИОРИТЕТ 1: Доступно всем пользователям (Accessible to Everyone) |
| ├── getByRole(role, { name, pressed, expanded, hidden }) |
| ├── getByLabelText(text, { selector }) |
| ├── getByPlaceholderText(text) |
| ├── getByText(text, { selector }) |
| └── getByDisplayValue(value) |
+-----------------------------------------------------------------------+
│
▼
+-----------------------------------------------------------------------+
| ПРИОРИТЕТ 2: Семантические визуальные атрибуты |
| ├── getByAltText(altText) |
| └── getByTitle(titleText) |
+-----------------------------------------------------------------------+
│
▼
+-----------------------------------------------------------------------+
| ПРИОРИТЕТ 3: Крайняя мера (Escape Hatch) |
| └── getByTestId('checkout-submit-btn') |
+-----------------------------------------------------------------------+get, query, find#Каждый селектор существует в 6 вариациях, определяющих синхронность и реакцию на отсутствие/множественность элементов:
| Префикс | 0 совпадений | 1 совпадение | 2+ совпадений | Синхронность | Типовое назначение |
| :--- | :--- | :--- | :--- | :--- | :--- |
| getBy* | Исключение (Throw) | Возвращает узел | Исключение (Throw) | Синхронно | Поиск существующего элемента |
| queryBy* | null | Возвращает узел | Исключение (Throw) | Синхронно | Проверка отсутствия элемента в DOM |
| findBy* | Исключение (Timeout) | Возвращает узел | Исключение (Throw) | Promise (async) | Ожидание появления асинхронного узла |
| getAllBy* | Исключение (Throw) | Массив [node] | Массив [node1, ...] | Синхронно | Выборка списков элементов |
| queryAllBy* | Пустой массив [] | Массив [node] | Массив [node1, ...] | Синхронно | Проверка отсутствия группы узлов |
| findAllBy* | Исключение (Timeout) | Массив [node] | Массив [node1, ...] | Promise (async) | Ожидание отрисовки списка элементов |
screen и утилиты отладки#Вместо деструктуризации методов из результата render() стандарт предписывает использовать глобальный объект screen, привязанный к document.body:
import { render, screen, logRoles } from '@testing-library/react';
// Рендеринг дерева
const { container } = render(<CheckoutForm />);
// Вывод текущего состояния DOM в консоль тестового раннера
screen.debug();
// Вывод доступных семантических ролей ARIA для переданного контейнера
logRoles(container);getByRole с расчетом Accessible Name#При поиске через getByRole вторым аргументом передается объект опций с полем name. Доступное имя (Accessible Name) вычисляется браузером согласно спецификации W3C: из текстового контента, атрибута aria-label, либо связки aria-labelledby.
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 OrderItem {
readonly id: string;
readonly title: string;
readonly price: number;
}
interface OrderProps {
readonly items: readonly OrderItem[];
readonly onCheckout: (total: number) => void;
}
function OrderSummary({ items, onCheckout }: OrderProps) {
const [promoApplied, setPromoApplied] = useState(false);
const total = items.reduce((sum, item) => sum + item.price, 0);
const finalPrice = promoApplied ? total * 0.9 : total;
return (
<section aria-labelledby="order-heading">
<h2 id="order-heading">Сводка заказа</h2>
<ul aria-label="Список позиций">
{items.map((item) => (
<li key={item.id}>
<span>{item.title}</span> — <span>{item.price} ₽</span>
</li>
))}
</ul>
<label>
<input
type="checkbox"
checked={promoApplied}
onChange={(e) => setPromoApplied(e.target.checked)}
/>
Применить скидку 10%
</label>
<div role="status" aria-live="polite">
Итого к оплате: {finalPrice} ₽
</div>
<button
type="button"
disabled={items.length === 0}
onClick={() => onCheckout(finalPrice)}
>
Подтвердить и оплатить
</button>
</section>
);
}
describe('OrderSummary Component', () => {
const mockItems: readonly OrderItem[] = [
{ id: '1', title: 'React 19 Architecture Guide', price: 1500 },
{ id: '2', title: 'Vitest Deep Dive', price: 1200 },
];
it('корректно находит элементы через дерево ARIA и рассчитывает итоговую стоимость', async () => {
const user = userEvent.setup();
const handleCheckout = vi.fn();
render(<OrderSummary items={mockItems} onCheckout={handleCheckout} />);
// 1. Поиск структурных заголовков и списков по роли и доступному имени
expect(
screen.getByRole('region', { name: 'Сводка заказа' })
).toBeInTheDocument();
expect(
screen.getByRole('list', { name: 'Список позиций' })
).toBeInTheDocument();
// 2. Поиск интерактивных элементов форм
const discountCheckbox = screen.getByRole('checkbox', {
name: /применить скидку 10%/i,
});
expect(discountCheckbox).not.toBeChecked();
// 3. Проверка динамической области статуса
expect(screen.getByRole('status')).toHaveTextContent('Итого к оплате: 2700 ₽');
// 4. Взаимодействие через пользовательское событие
await user.click(discountCheckbox);
expect(discountCheckbox).toBeChecked();
expect(screen.getByRole('status')).toHaveTextContent('Итого к оплате: 2430 ₽');
// 5. Активация кнопки оформления заказа
const submitBtn = screen.getByRole('button', {
name: /подтвердить и оплатить/i,
});
await user.click(submitBtn);
expect(handleCheckout).toHaveBeenCalledTimes(1);
expect(handleCheckout).toHaveBeenCalledWith(2430);
});
});pressed, expanded)#Компоненты аккордеонов, выпадающих списков и тоггл-кнопок обязаны транслировать свое состояние вспомогательным технологиям через атрибуты aria-expanded, aria-pressed, aria-selected. RTL позволяет фильтровать выборку по этим атрибутам.
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState, useId } from 'react';
interface CollapsibleProps {
readonly title: string;
readonly children: React.ReactNode;
}
function CollapsibleFilter({ title, children }: CollapsibleProps) {
const [isExpanded, setIsExpanded] = useState(false);
const panelId = useId();
return (
<div className="filter-wrapper">
<button
type="button"
aria-expanded={isExpanded}
aria-controls={panelId}
onClick={() => setIsExpanded((prev) => !prev)}
>
{title}
</button>
{isExpanded && (
<div id={panelId} role="region" aria-label={title}>
{children}
</div>
)}
</div>
);
}
describe('CollapsibleFilter Accessibility States', () => {
it('синхронизирует атрибут aria-expanded с физической видимостью контента', async () => {
const user = userEvent.setup();
render(
<CollapsibleFilter title="Категории товаров">
<p>Электроника, Книги, Софт</p>
</CollapsibleFilter>
);
// Начальное состояние: кнопка свернута
const toggleButton = screen.getByRole('button', {
name: 'Категории товаров',
expanded: false,
});
expect(toggleButton).toBeInTheDocument();
// Регион контента отсутствует в DOM
expect(
screen.queryByRole('region', { name: 'Категории товаров' })
).not.toBeInTheDocument();
// Разворачивание панели
await user.click(toggleButton);
// Проверка смены ARIA-состояния на expanded: true
expect(
screen.getByRole('button', {
name: 'Категории товаров',
expanded: true,
})
).toBeInTheDocument();
// Проверка отображения региона
expect(
screen.getByRole('region', { name: 'Категории товаров' })
).toHaveTextContent('Электроника, Книги, Софт');
});
});queryBy* vs waitForElementToBeRemoved#Типичная ошибка новичков — использование getBy* при проверке исчезновения узла. Метод getBy* сразу выбрасывает фатальное исключение. Для синхронной проверки отсутствия применяется queryBy*, а для отслеживания асинхронного исчезновения — waitForElementToBeRemoved.
import { describe, it, expect } from 'vitest';
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { useState } from 'react';
function DismissibleBanner({ message }: { readonly message: string }) {
const [visible, setVisible] = useState(true);
if (!visible) return null;
return (
<aside role="alert">
<span>{message}</span>
<button
type="button"
aria-label="Закрыть уведомление"
onClick={() => setVisible(false)}
>
×
</button>
</aside>
);
}
describe('DismissibleBanner LifeCycle', () => {
it('корректно удаляет алерт из DOM при нажатии на кнопку закрытия', async () => {
const user = userEvent.setup();
render(<DismissibleBanner message="Сессия истекает через 5 минут" />);
// 1. Убеждаемся в наличии алерта через getByRole
const alertElement = screen.getByRole('alert');
expect(alertElement).toHaveTextContent('Сессия истекает через 5 минут');
const closeBtn = screen.getByRole('button', {
name: 'Закрыть уведомление',
});
// 2. Инициируем скрытие
await user.click(closeBtn);
// 3. Синхронная проверка отсутствия: queryByRole возвращает null
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// 4. Альтернатива для асинхронных анимаций/транзишенов:
// await waitForElementToBeRemoved(() => screen.queryByRole('alert'));
});
});logRoles#Если тест падает с сообщением «Unable to find an accessible element with the role...», утилита logRoles позволяет распечатать вычисленное AOM-дерево и понять, как элемент воспринимается ассистивными технологиями.
import { describe, it, expect } from 'vitest';
import { render, screen, logRoles } from '@testing-library/react';
import React from 'react';
function UserNavigation() {
return (
<header>
<nav aria-label="Основное меню">
<a href="/profile">Мой профиль</a>
<a href="/settings">Настройки</a>
<button type="button" aria-label="Выйти из аккаунта">
<svg aria-hidden="true" focusable="false"><path d="M0 0h24v24H0z" /></svg>
</button>
</nav>
</header>
);
}
describe('UserNavigation Diagnostic', () => {
it('выводит дерево ролей для точной настройки селекторов', () => {
const { container } = render(<UserNavigation />);
// Логирует в stdout структуру ролей:
// banner:
// navigation "Основное меню":
// link "Мой профиль"
// link "Настройки"
// button "Выйти из аккаунта"
logRoles(container);
// Точный селектор на базе лога
expect(screen.getByRole('navigation', { name: 'Основное меню' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Выйти из аккаунта' })).toBeInTheDocument();
});
});Какой метод RTL необходимо использовать для проверки того, что элемент (например, модальное окно или индикатор загрузки) отсутствует в DOM?
Использование container.querySelector('.btn-primary') или container.getElementsByTagName('input') жестко связывает тест со стилями оформления и версткой. Если дизайнер заменит класс на утилиту Tailwind или обернет кнопку в служебный <span>, тест упадет, хотя компонент продолжит работать.
// ❌ АНТИПАТТЕРН: Зацепление за CSS-класс оформления
const button = container.querySelector('.submit-button-active');
expect(button).toBeDefined();
// ✅ РЕШЕНИЕ: Запрос по семантической роли и доступному имени
const button = screen.getByRole('button', { name: /сохранить/i });
expect(button).toBeInTheDocument();Атрибут data-testid невидим для пользователей и скринридеров. Если разработчик случайно удалит текст из кнопки или сломает фокус с клавиатуры, тест с getByTestId продолжит проходить, пропуская критический дефект в продакшен.
// ❌ АНТИПАТТЕРН: Скрывает дефекты семантики и доступности
render(<div onClick={handleClick} data-testid="fake-submit">Отправить</div>);
const el = screen.getByTestId('fake-submit'); // Тест пройдет, но это не кнопка!
// ✅ РЕШЕНИЕ: Заставляет использовать доступный HTML
render(<button type="submit">Отправить</button>);
const el = screen.getByRole('button', { name: /отправить/i });Семейство методов getBy* немедленно выбрасывает исключение TestingLibraryElementError, если элемент не обнаружен в DOM. Конструкция expect(screen.getByText('Ошибка')).not.toBeInTheDocument() упадет на самом вызове getByText, не дойдя до матчера expect.
// ❌ АНТИПАТТЕРН: Тест упадет с необработанным исключением в методе getBy
expect(screen.getByText('Ошибка авторизации')).not.toBeInTheDocument();
// ✅ РЕШЕНИЕ: queryBy возвращает null при отсутствии узла
expect(screen.queryByText('Ошибка авторизации')).not.toBeInTheDocument();Попытки получить доступ к внутренним переменным через wrapper.state() (привычка из Enzyme) или инспекцию _reactFiber лишают тест устойчивости к рефакторингу. Тестировать необходимо только изменения в выводимом DOM.
// ❌ АНТИПАТТЕРН: Проверка приватного состояния компонента
expect(componentInstance.state.isOpen).toBe(true);
// ✅ РЕШЕНИЕ: Проверка реакции интерфейса на изменение состояния
expect(screen.getByRole('dialog', { name: /редактирование/i })).toBeVisible();