Історично компонентне тестування в екосистемі 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-відповіді або помилки |
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();
});
});Використання 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();Визначте семантичну роль елемента в 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.