У сучасній фронтенд-інженерії цінність автоматизованих тестів вимірюється не сухим відсотком покриття рядків коду (Code Coverage), а рівнем упевненості (Confidence), який вони гарантують під час регулярного рефакторингу та оновлення бібліотек.
Історичний підхід до тестування через інспекцію внутрішніх структур (стан хуків, пропси Fiber, перевірка викликів приватних методів) призводив до синдрому «крихких тестів»:
useState на useReducer. Інтерфейс для користувача працює бездоганно, але 40 тестів падають, вимагаючи виснажливого ручного виправлення.Філософія React Testing Library (RTL) виключає тестування деталей реалізації (Implementation Details). RTL взаємодіє з віртуальним DOM виключно так, як це робить реальна людина або асистивні технології (скринрідери): через семантичні ролі ARIA, видимий текст та фізичні ланцюжки браузерних подій.
У поєднанні з високопродуктивним тестовим раннером Vitest та мережевим перехоплювачем Mock Service Worker (MSW v2) цей стек забезпечує еталонну архітектуру тестування React 19.
Замість імітації викликів внутрішніх методів React тест запускає ізольовану мінісимуляцію браузера:
[Користувач / Скринрідер]
│
▼ (Взаємодія: Клік, Введення тексту, Натискання клавіші Tab)
[@testing-library/user-event]
│
▼ (Генерація повного каскаду браузерних подій: pointer, focus, mouse, key)
[Віртуальне DOM-середовище: happy-dom / jsdom]
│
▼ (Запуск рендерингу та планування мікротасок)
[React 19 Concurrent Scheduler (act / async transitions)]
│
▼ (Мережеві запити перехоплюються на рівні сокета без виходу в реальну мережу)
[Mock Service Worker (MSW v2) Network Layer]
│
▼ (Перевірка стану через семантичні ARIA-селектори)
[vitest / @testing-library/jest-dom matchers]Ініціалізуйте userEvent.setup(), налаштуйте обробники MSW та змонтуйте компонент через render() із контекстними провайдерами.
Використовуючи getByRole та getByLabelText, перевірте коректність доступності елементів (неактивні кнопки, чисті поля вводу).
Відтворіть реалістичні взаємодії: user.type(), user.click(), user.tab() з генерацією повної черги подій.
Дочекайтеся оновлення інтерфейсу та зникнення лоадерів через findByRole, findByText або waitForElementToBeRemoved.
Упевніться в коректності виклику переданих колбеків, очищенні полів форми чи зміні адреси сторінки.
Який селектор React Testing Library має найвищий пріоритет і є найбільш стійким до рефакторингу під час тестування кнопки відправлення форми?
useActionState), блокування кнопок, валідації полів та відображення оптимістичних оновлень (useOptimistic).Tab.AbortController.userEvent.setup() ініціалізує генератор подій; MSW налаштовує перехоплення на рівні сокетів.
Монтування дерева компонентів React 19 у happy-dom з контекстними провайдерами (QueryClient, Theme).
user.type() заповнює поля з посимвольною генерацією ланцюжка keydown/input/keyup.
Запит перехоплюється на рівні сокета Node.js та повертає детерміновану JSON-відповідь.
Очікування появи повідомлення про успіх в Accessibility Tree через findByRole('alert').
Для коректної роботи React 19 та розширення матчерів Vitest створюється глобальний файл ініціалізації (setupTests.ts):
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'happy-dom', // або 'jsdom'
setupFiles: ['./src/test/setupTests.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/test/'],
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
});import '@testing-library/jest-dom/vitest';
import { cleanup } from '@testing-library/react';
import { afterEach, beforeAll, afterAll } from 'vitest';
import { server } from './mocks/server';
// Запуск Mock Service Worker перед усіма тестами набору
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
// Скидання динамічних обробників та очищення віртуального DOM після кожного тесту
afterEach(() => {
cleanup();
server.resetHandlers();
});
// Зупинка мережевого інтерцептора після завершення сьюта
afterAll(() => server.close());user-event витіснив fireEvent#Метод fireEvent.click(button) лише відправляє ізольовану синтетичну подію 'click'. У реальному браузері взаємодія користувача запускає взаємопов'язаний каскад:
pointerover $\to$ pointerenter $\to$ mousemovepointerdown $\to$ mousedownfocusin, focus)pointerup $\to$ mouseup $\to$ clickПакет @testing-library/user-event відтворює цю реалістичну послідовність, перевіряючи переходи фокусу, обробники blur та спливання подій:
// ❌ Застарілий підхід: синтетичний клік без повноцінного браузерного циклу
fireEvent.click(screen.getByRole('button', { name: /надіслати/i }));
// ✅ Сучасний стандарт: повний каскад фізичних взаємодій
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: /надіслати/i }));Чому в тестах React Testing Library мокування через Mock Service Worker (MSW v2) краще за пряме перевизначення global.fetch = vi.fn()?
Нижче наведено повноцінний промисловий тест форми зміни пароля, що охоплює валідацію, користувацькі взаємодії, обробку помилки 422 та успішне збереження:
import React from 'react';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/mocks/server';
import { ChangePasswordForm } from '../ui/ChangePasswordForm';
describe('<ChangePasswordForm />', () => {
it('рендерить форму в початковому доступному стані', () => {
render(<ChangePasswordForm />);
expect(screen.getByRole('heading', { level: 2, name: /зміна пароля/i })).toBeInTheDocument();
expect(screen.getByLabelText(/поточний пароль/i)).toBeInTheDocument();
expect(screen.getByLabelText(/^новий пароль/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /зберегти пароль/i })).toBeEnabled();
});
it('відображає помилки клієнтської валідації при сабміті порожніх полів', async () => {
const user = userEvent.setup();
render(<ChangePasswordForm />);
const submitBtn = screen.getByRole('button', { name: /зберегти пароль/i });
await user.click(submitBtn);
// findByText очікує появи помилки в DOM у циклі мікротасок
const currentPassError = await screen.findByText(/введіть поточний пароль/i);
const newPassError = screen.getByText(/пароль має містити щонайменше 8 символів/i);
expect(currentPassError).toBeInTheDocument();
expect(newPassError).toBeInTheDocument();
});
it('обробляє помилку сервера 422 (невірний поточний пароль)', async () => {
// Локальне перевизначення обробника MSW для негативного сценарію
server.use(
http.post('/api/auth/change-password', () => {
return HttpResponse.json(
{ message: 'Поточний пароль вказано невірно' },
{ status: 422 }
);
})
);
const user = userEvent.setup();
render(<ChangePasswordForm />);
await user.type(screen.getByLabelText(/поточний пароль/i), 'WrongOldPassword123!');
await user.type(screen.getByLabelText(/^новий пароль/i), 'SuperSecurePass2026!');
await user.click(screen.getByRole('button', { name: /зберегти пароль/i }));
// Кнопка блокується на час виконання запиту
expect(screen.getByRole('button', { name: /збереження\.\.\./i })).toBeDisabled();
// Перевіряємо банер помилки із семантичною роллю alert
const serverAlert = await screen.findByRole('alert');
expect(serverAlert).toHaveTextContent(/поточний пароль вказано невірно/i);
});
it('успішно зберігає новий пароль та очищує поля форми', async () => {
server.use(
http.post('/api/auth/change-password', async ({ request }) => {
const body = (await request.json()) as { oldPass: string; newPass: string };
if (body.newPass === 'SuperSecurePass2026!') {
return HttpResponse.json({ success: true }, { status: 200 });
}
return HttpResponse.json({ success: false }, { status: 400 });
})
);
const user = userEvent.setup();
render(<ChangePasswordForm />);
const oldInput = screen.getByLabelText(/поточний пароль/i);
const newInput = screen.getByLabelText(/^новий пароль/i);
await user.type(oldInput, 'ValidOldPass123!');
await user.type(newInput, 'SuperSecurePass2026!');
await user.click(screen.getByRole('button', { name: /зберегти пароль/i }));
// Очікуємо появу повідомлення про успіх
const successNotice = await screen.findByText(/пароль успішно змінено/i);
expect(successNotice).toBeInTheDocument();
// Поля введення повинні бути очищені
expect(oldInput).toHaveValue('');
expect(newInput).toHaveValue('');
});
});Вибір елементів за назвами CSS-класів (container.querySelector('.btn-primary')) жорстко прив'язує тести до візуального оформлення.
// ❌ Антипатерн: крихкий селектор, що ігнорує доступність (a11y)
const btn = container.querySelector('.btn-primary');
expect(btn).toBeTruthy();
// ✅ Правильне рішення: селектор за доступною семантичною роллю ARIA
const btn = screen.getByRole('button', { name: /надіслати/i });
expect(btn).toBeInTheDocument();У бібліотеці RTL методи render, userEvent та waitFor вже містять внутрішній виклик act(...). Ручні виклики act() засмічують код і маскують нерозв'язані асинхронні переходи.
// ❌ Антипатерн: надлишковий ручний act
await act(async () => {
render(<UserProfile />);
});
// ✅ Правильне рішення: RTL автоматично обробляє чергу мікротасок
render(<UserProfile />);
const profileName = await screen.findByRole('heading', { name: /олексій/i });Огортання синхронних перевірок у waitFor(() => expect(syncValue).toBe(true)) створює непотрібні таймаути і суттєво уповільнює виконання тестів.
// ❌ Антипатерн: waitFor для елемента, що відрендерений синхронно
await waitFor(() => {
expect(screen.getByText(/заголовок/i)).toBeInTheDocument();
});
// ✅ Правильне рішення: прямий синхронний виклик getByText
expect(screen.getByText(/заголовок/i)).toBeInTheDocument();
// Для асинхронних елементів використовуйте findBy безпосередньо:
const asyncItem = await screen.findByText(/динамічний елемент/i);