Пошук уроків, статей та іншого контенту
Навчитеся тестувати завантаження, успішні результати, помилки та зміни інтерфейсу після асинхронних операцій.
Асинхронний інтерфейс зазвичай проходить кілька станів:
Початковий стан — дані ще не завантажувалися.
Стан завантаження — користувач бачить індикатор або повідомлення.
Успішний результат — на екрані з’являються отримані дані.
Помилка — інтерфейс показує зрозуміле повідомлення та дозволяє повторити дію.
У тестах важливо перевіряти не внутрішній стан React, а те, що бачить і з чим взаємодіє користувач:
текст повідомлення;
доступні ролі елементів;
доступність або недоступність кнопок;
появу результату після завершення операції;
повідомлення про помилку.
Для цього зручно використовувати React Testing Library разом із Vitest.
Щоб тест не залежав від реального сервера, передамо функцію завантаження через проп loadUser. У виробничому коді цією функцією може бути API-клієнт, а в тесті ми замінимо її mock-функцією.
// UserPanel.tsx
import { useState } from 'react';
type User = {
name: string;
email: string;
};
type UserPanelProps = {
loadUser: () => Promise<User>;
};
type Status = 'idle' | 'loading' | 'success' | 'error';
export function UserPanel({ loadUser }: UserPanelProps) {
const [status, setStatus] = useState<Status>('idle');
const [user, setUser] = useState<User | null>(null);
async function handleLoadUser() {
setStatus('loading');
setUser(null);
try {
const loadedUser = await loadUser();
setUser(loadedUser);
setStatus('success');
} catch {
setStatus('error');
}
}
return (
<section>
<h1>Профіль користувача</h1>
<button
type="button"
onClick={handleLoadUser}
disabled={status === 'loading'}
>
{status === 'loading' ? 'Завантаження...' : 'Завантажити профіль'}
</button>
{status === 'idle' && (
<p role="status">Натисніть кнопку, щоб завантажити профіль.</p>
)}
{status === 'loading' && (
<p role="status" aria-live="polite">
Завантаження даних...
</p>
)}
{status === 'success' && user && (
<div>
<p role="status">Дані успішно завантажено.</p>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)}
{status === 'error' && (
<div role="alert">
Не вдалося завантажити профіль. Спробуйте ще раз.
</div>
)}
</section>
);
}У компоненті:
кнопка блокується під час завантаження;
повідомлення про завантаження має роль status;
помилка має роль alert;
успішно завантажене ім’я відображається як заголовок другого рівня;
повторне натискання після помилки можливе, оскільки кнопка знову стає активною.
React Testing Library має кілька способів очікувати зміни інтерфейсу.
findBy...Методи findBy... поєднують пошук елемента з очікуванням його появи:
const heading = await screen.findByRole('heading', {
name: 'Олена Коваль',
});Метод повертає Promise, тому його потрібно використовувати з await.
findBy... підходить, коли ми очікуємо появу конкретного елемента:
результату запиту;
повідомлення про помилку;
нового рядка у списку;
повідомлення після збереження.
waitForwaitFor використовується, коли потрібно дочекатися виконання певної перевірки:
await waitFor(() => {
expect(button).toBeEnabled();
});Колбек усередині waitFor має містити assertion, яка спочатку може завершуватися помилкою. Testing Library повторюватиме її протягом обмеженого часу.
Не потрібно використовувати waitFor навколо кожного асинхронного пошуку. Якщо очікується поява елемента, зазвичай достатньо findBy....
getBy...getBy... виконує синхронний пошук. Він підходить для стану, який уже має бути на екрані:
expect(screen.getByRole('button')).toBeDisabled();Якщо елемент ще не з’явився, getBy... негайно завершиться помилкою. Для майбутнього стану використовуйте findBy....
Для прикладу використаємо Vitest, @testing-library/react, @testing-library/user-event і @testing-library/jest-dom.
У файлі налаштування тестів потрібно підключити додаткові matchers:
// setupTests.ts
import '@testing-library/jest-dom/vitest';Середовище тестів має бути jsdom, щоб React-компоненти могли працювати з DOM.
Щоб надійно перевірити стан завантаження, створимо Promise, завершення якого контролюємо вручну. Це не дасть операції завершитися раніше, ніж ми перевіримо loading state.
// UserPanel.test.tsx
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserPanel } from './UserPanel';
describe('UserPanel', () => {
it('показує стан завантаження, а потім успішний результат', async () => {
const user = userEvent.setup();
let resolveRequest!: (value: {
name: string;
email: string;
}) => void;
const loadUser = vi.fn(
() =>
new Promise<{ name: string; email: string }>((resolve) => {
resolveRequest = resolve;
}),
);
render(<UserPanel loadUser={loadUser} />);
const button = screen.getByRole('button', {
name: 'Завантажити профіль',
});
await user.click(button);
expect(loadUser).toHaveBeenCalledTimes(1);
expect(
screen.getByRole('status', { name: 'Завантаження даних...' }),
).toBeInTheDocument();
expect(button).toBeDisabled();
expect(button).toHaveTextContent('Завантаження...');
resolveRequest({
name: 'Олена Коваль',
email: 'olena@example.com',
});
expect(
await screen.findByRole('heading', {
name: 'Олена Коваль',
}),
).toBeInTheDocument();
expect(
screen.getByText('olena@example.com'),
).toBeInTheDocument();
expect(
screen.getByRole('status', {
name: 'Дані успішно завантажено.',
}),
).toBeInTheDocument();
expect(button).toBeEnabled();
expect(button).toHaveTextContent('Завантажити профіль');
});
});Тест перевіряє повний сценарій:
Користувач натискає кнопку.
Функція loadUser викликається один раз.
З’являється повідомлення про завантаження.
Кнопка стає недоступною.
Promise завершується успішно.
На екрані з’являються ім’я та email.
Повідомлення змінюється на успішне.
Кнопка знову стає доступною.
Зверніть увагу: ми не перевіряємо status безпосередньо. Натомість перевіряємо доступний інтерфейс, який відображає цей стан.
Помилку можна змоделювати за допомогою mockRejectedValueOnce.
import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UserPanel } from './UserPanel';
describe('UserPanel', () => {
it('показує повідомлення про помилку', async () => {
const user = userEvent.setup();
const loadUser = vi
.fn()
.mockRejectedValueOnce(new Error('Network error'));
render(<UserPanel loadUser={loadUser} />);
await user.click(
screen.getByRole('button', {
name: 'Завантажити профіль',
}),
);
const errorMessage = await screen.findByRole('alert');
expect(errorMessage).toHaveTextContent(
'Не вдалося завантажити профіль. Спробуйте ще раз.',
);
expect(
screen.queryByRole('heading', { name: 'Олена Коваль' }),
).not.toBeInTheDocument();
expect(
screen.getByRole('button', {
name: 'Завантажити профіль',
}),
).toBeEnabled();
});
});У цьому тесті важливо перевірити не лише наявність помилки, а й поведінку інтерфейсу після неї:
користувач бачить помилку;
старий або неповний результат не відображається;
кнопку можна натиснути повторно.
Початковий стан є синхронним, тому для нього достатньо getBy....
it('показує початковий стан', () => {
const loadUser = vi.fn();
render(<UserPanel loadUser={loadUser} />);
expect(
screen.getByRole('status', {
name: 'Натисніть кнопку, щоб завантажити профіль.',
}),
).toBeInTheDocument();
expect(
screen.getByRole('button', {
name: 'Завантажити профіль',
}),
).toBeEnabled();
expect(loadUser).not.toHaveBeenCalled();
});Такий тест фіксує контракт компонента до початку асинхронної операції.
Користувач часто має можливість повторити запит. Це також варто перевірити.
it('дозволяє повторити запит після помилки', async () => {
const user = userEvent.setup();
const loadUser = vi
.fn()
.mockRejectedValueOnce(new Error('Temporary error'))
.mockResolvedValueOnce({
name: 'Олена Коваль',
email: 'olena@example.com',
});
render(<UserPanel loadUser={loadUser} />);
const button = screen.getByRole('button', {
name: 'Завантажити профіль',
});
await user.click(button);
expect(await screen.findByRole('alert')).toBeInTheDocument();
await user.click(button);
expect(
await screen.findByRole('heading', {
name: 'Олена Коваль',
}),
).toBeInTheDocument();
expect(loadUser).toHaveBeenCalledTimes(2);
});mockRejectedValueOnce задає результат лише для першого виклику, а mockResolvedValueOnce — для другого. Так можна моделювати послідовність подій без реального сервера.
Під час асинхронної операції інтерфейс може змінювати не лише окреме повідомлення, а й саму кнопку.
it('змінює текст кнопки під час завантаження', async () => {
const user = userEvent.setup();
let resolveRequest!: (value: {
name: string;
email: string;
}) => void;
const loadUser = vi.fn(
() =>
new Promise<{ name: string; email: string }>((resolve) => {
resolveRequest = resolve;
}),
);
render(<UserPanel loadUser={loadUser} />);
const button = screen.getByRole('button', {
name: 'Завантажити профіль',
});
await user.click(button);
expect(
screen.getByRole('button', {
name: 'Завантаження...',
}),
).toBeDisabled();
resolveRequest({
name: 'Іван Петренко',
email: 'ivan@example.com',
});
await screen.findByRole('heading', {
name: 'Іван Петренко',
});
expect(
screen.getByRole('button', {
name: 'Завантажити профіль',
}),
).toBeEnabled();
});Пошук кнопки за роллю та доступною назвою одночасно перевіряє і текст, і доступність компонента для користувачів допоміжних технологій.
userEventДля взаємодії з інтерфейсом використовуйте userEvent:
const user = userEvent.setup();
await user.click(button);На відміну від прямого виклику fireEvent.click, userEvent моделює реальнішу поведінку користувача та враховує асинхронність взаємодії. Виклики click, type та інші операції потрібно очікувати через await.
У тестах компонента не потрібно:
перевіряти значення React state;
перевіряти виклик setState;
тестувати внутрішню реалізацію async-функції;
виконувати реальний HTTP-запит;
додавати штучні затримки через setTimeout;
перевіряти CSS-клас замість видимого результату.
Краще тестувати поведінку:
expect(screen.getByRole('button')).toBeDisabled();
expect(await screen.findByRole('alert')).toBeInTheDocument();Такі тести залишаються стабільними навіть після зміни внутрішньої реалізації компонента.
getBy... для майбутнього елементаНеправильно:
expect(screen.getByRole('alert')).toBeInTheDocument();Якщо помилка з’являється асинхронно, пошук може виконатися зарано.
Правильно:
expect(await screen.findByRole('alert')).toBeInTheDocument();await перед дією користувачаНеправильно:
user.click(button);
expect(screen.getByText('Завантаження...')).toBeInTheDocument();Правильно:
await user.click(button);
expect(screen.getByText('Завантаження...')).toBeInTheDocument();Реальний сервер робить тест:
повільним;
нестабільним;
залежним від мережі;
складним для перевірки помилок.
Передавайте функцію завантаження через проп або ізолюйте API-клієнт і підміняйте його mock-функцією.
Тест, який перевіряє тільки успішно завантажене ім’я, може не помітити, що:
індикатор завантаження не показується;
кнопка дозволяє повторні кліки;
помилка не відображається;
інтерфейс зависає в loading state.
Асинхронний сценарій потрібно перевіряти послідовно: завантаження, результат або помилка.
waitForНе варто писати:
await waitFor(() => {
expect(screen.getByText('Дані успішно завантажено.')).toBeInTheDocument();
});Якщо елемент просто має з’явитися, зрозуміліше написати:
expect(
await screen.findByText('Дані успішно завантажено.'),
).toBeInTheDocument();waitFor залишайте для випадків, коли потрібно дочекатися складнішої умови або кількох змін.
Для тестування async UI:
моделюйте Promise через mock-функції;
не використовуйте реальні мережеві запити;
перевіряйте стан завантаження одразу після дії користувача;
для майбутніх елементів використовуйте findBy...;
використовуйте waitFor, коли потрібно дочекатися assertion;
перевіряйте доступність кнопок і повідомлень;
окремо тестуйте успішний результат, помилку та повторну спробу;
перевіряйте видиму поведінку інтерфейсу, а не внутрішній стан React.