React_UI_Testing
React UI-тесты
UI-тесты проверяют интерфейс с точки зрения пользователя: что он видит, какие действия выполняет и какой результат получает. В React для компонентных и интеграционных тестов часто используют Vitest, React Testing Library, user-event, jest-dom и MSW.
Главный принцип React Testing Library: тест должен быть похож на реальное использование приложения. Поэтому лучше проверять доступные элементы и видимое поведение, а не внутренний state, приватные функции или структуру компонента.
Установка и настройка
npm install -D vitest jsdom @testing-library/react \
@testing-library/jest-dom @testing-library/user-event mswПример настройки Vitest:
// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.js'],
globals: true,
restoreMocks: true,
},
});jsdom имитирует браузерный DOM в Node.js.
// src/test/setup.js
import '@testing-library/jest-dom/vitest';Скрипты:
{
"scripts": {
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
}
}React Testing Library
Простой компонент:
// Greeting.jsx
export function Greeting({ name }) {
return <h1>Привет, {name}!</h1>;
}Тест:
import { render, screen } from '@testing-library/react';
import { expect, it } from 'vitest';
import { Greeting } from './Greeting';
it('показывает приветствие', () => {
render(<Greeting name="Анна" />);
expect(
screen.getByRole('heading', { name: 'Привет, Анна!' }),
).toBeInTheDocument();
});render()монтирует компонент в тестовый DOM.screenпредоставляет запросы ко всему документу.getByRole()ищет элемент по доступной роли и имени.toBeInTheDocument()— matcher изjest-dom.
Поведение вместо реализации
Хрупкий тест:
expect(container.querySelector('.title')).toHaveTextContent('Привет');Он зависит от CSS-класса. Более устойчивый вариант:
expect(
screen.getByRole('heading', { name: /привет/i }),
).toBeInTheDocument();Изменение классов или внутренней структуры не сломает такой тест, пока пользовательское поведение остаётся прежним.
Поиск элементов
| Запрос | Если элемента нет | Когда применять |
|---|---|---|
getBy... |
Сразу выбрасывает ошибку | Элемент уже должен быть в DOM |
queryBy... |
Возвращает null |
Проверка отсутствия |
findBy... |
Ожидает и возвращает Promise | Элемент появится асинхронно |
Для нескольких элементов существуют getAllBy..., queryAllBy... и findAllBy....
Предпочтительный запрос — getByRole():
screen.getByRole('button', { name: 'Сохранить' });
screen.getByRole('textbox', { name: 'Email' });
screen.getByRole('checkbox', { name: 'Запомнить меня' });
screen.getByRole('heading', { level: 2 });Доступное имя поля формируется из связанного label:
<label htmlFor="email">Email</label>
<input id="email" type="email" />screen.getByRole('textbox', { name: 'Email' });Приблизительный приоритет запросов:
getByRole();getByLabelText();getByPlaceholderText();getByText();getByDisplayValue();getByAltText();getByTitle();getByTestId()— запасной вариант.
data-testid полезен для элементов без подходящей семантической роли, но не должен быть основным способом поиска.
Проверка отсутствия
Неправильно:
expect(screen.getByText('Ошибка')).not.toBeInTheDocument();getByText() выбросит исключение. Правильно:
expect(screen.queryByText('Ошибка')).not.toBeInTheDocument();Полезные matchers
expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(button).toBeEnabled();
expect(button).toBeDisabled();
expect(input).toHaveValue('Анна');
expect(checkbox).toBeChecked();
expect(message).toHaveTextContent('Сохранено');
expect(link).toHaveAttribute('href', '/profile');Тестирование пользовательских взаимодействий
Для действий пользователя применяется @testing-library/user-event.
// Counter.jsx
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<section>
<p>Значение: {count}</p>
<button onClick={() => setCount((value) => value + 1)}>
Увеличить
</button>
</section>
);
}import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, it } from 'vitest';
import { Counter } from './Counter';
it('увеличивает значение после клика', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(
screen.getByRole('button', { name: 'Увеличить' }),
);
expect(screen.getByText('Значение: 1')).toBeInTheDocument();
});Действия userEvent обычно асинхронны, поэтому перед ними нужен await.
Ввод и выбор значений
const user = userEvent.setup();
const email = screen.getByRole('textbox', { name: 'Email' });
await user.type(email, 'user@example.com');
expect(email).toHaveValue('user@example.com');
await user.clear(email);
expect(email).toHaveValue('');
await user.selectOptions(
screen.getByRole('combobox', { name: 'Город' }),
'kazan',
);
await user.click(
screen.getByRole('checkbox', { name: 'Принимаю условия' }),
);
await user.keyboard('{Enter}');userEvent моделирует последовательность связанных действий: фокус, клавиатурные события и изменение значения. fireEvent отправляет отдельное низкоуровневое событие и нужен реже.
Тестирование формы
// LoginForm.jsx
import { useState } from 'react';
export function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('');
const [error, setError] = useState('');
function handleSubmit(event) {
event.preventDefault();
if (!email) {
setError('Введите email');
return;
}
setError('');
onSubmit({ email });
}
return (
<form onSubmit={handleSubmit} aria-label="Вход">
<label>
Email
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
</label>
{error && <p role="alert">{error}</p>}
<button type="submit">Войти</button>
</form>
);
}import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, it, vi } from 'vitest';
import { LoginForm } from './LoginForm';
it('не отправляет пустую форму', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.click(screen.getByRole('button', { name: 'Войти' }));
expect(screen.getByRole('alert')).toHaveTextContent('Введите email');
expect(onSubmit).not.toHaveBeenCalled();
});
it('передаёт введённый email', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<LoginForm onSubmit={onSubmit} />);
await user.type(
screen.getByRole('textbox', { name: 'Email' }),
'user@example.com',
);
await user.click(screen.getByRole('button', { name: 'Войти' }));
expect(onSubmit).toHaveBeenCalledOnce();
expect(onSubmit).toHaveBeenCalledWith({ email: 'user@example.com' });
});Асинхронный интерфейс
Если элемент появляется после Promise или запроса, используется findBy...:
expect(
await screen.findByText('Данные загружены'),
).toBeInTheDocument();Для произвольного асинхронного утверждения подходит waitFor():
import { waitFor } from '@testing-library/react';
await waitFor(() => {
expect(onSave).toHaveBeenCalledOnce();
});Ожидание исчезновения:
import { waitForElementToBeRemoved } from '@testing-library/react';
await waitForElementToBeRemoved(() =>
screen.getByText('Загрузка...'),
);Если достаточно дождаться появления элемента, findBy... обычно понятнее, чем waitFor().
MSW: мокирование API
Mock Service Worker перехватывает HTTP-запросы. Компонент продолжает использовать настоящий fetch, а тест управляет ответом сервера.
Преимущества MSW:
- тест не зависит от внутренней реализации API-клиента;
- можно моделировать успех, пустой ответ и ошибку;
- проверяется интеграция интерфейса с сетевым слоем;
- необработанные запросы можно запретить.
Обработчики:
// src/test/handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/products', () => {
return HttpResponse.json([
{ id: 1, name: 'Клавиатура' },
{ id: 2, name: 'Мышь' },
]);
}),
];Тестовый сервер:
// src/test/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);Общая настройка:
// src/test/setup.js
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());resetHandlers() удаляет переопределения из отдельных тестов. onUnhandledRequest: 'error' не позволяет тесту случайно обратиться к настоящему API.
Компонент с запросом
// ProductList.jsx
import { useEffect, useState } from 'react';
export function ProductList() {
const [products, setProducts] = useState([]);
const [status, setStatus] = useState('loading');
useEffect(() => {
async function load() {
try {
const response = await fetch('/api/products');
if (!response.ok) {
throw new Error('Request failed');
}
setProducts(await response.json());
setStatus('success');
} catch {
setStatus('error');
}
}
load();
}, []);
if (status === 'loading') return <p>Загрузка...</p>;
if (status === 'error') return <p role="alert">Ошибка загрузки</p>;
if (products.length === 0) return <p>Товары не найдены</p>;
return (
<ul aria-label="Товары">
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}Успешный ответ:
it('загружает и показывает товары', async () => {
render(<ProductList />);
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
expect(await screen.findByText('Клавиатура')).toBeInTheDocument();
expect(screen.getByText('Мышь')).toBeInTheDocument();
expect(screen.queryByText('Загрузка...')).not.toBeInTheDocument();
});Ошибка сервера:
import { http, HttpResponse } from 'msw';
import { server } from '../test/server';
it('показывает ошибку при ответе 500', async () => {
server.use(
http.get('/api/products', () => {
return HttpResponse.json(
{ message: 'Internal Server Error' },
{ status: 500 },
);
}),
);
render(<ProductList />);
expect(await screen.findByRole('alert')).toHaveTextContent(
'Ошибка загрузки',
);
});fetch не отклоняет Promise при статусах 404 или 500, поэтому приложение должно проверять response.ok.
Пустой ответ:
it('показывает сообщение для пустого списка', async () => {
server.use(
http.get('/api/products', () => HttpResponse.json([])),
);
render(<ProductList />);
expect(
await screen.findByText('Товары не найдены'),
).toBeInTheDocument();
});Параметры и тело запроса доступны обработчику:
http.get('/api/products/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, name: 'Монитор' });
});
http.post('/api/products', async ({ request }) => {
const product = await request.json();
return HttpResponse.json({ id: 10, ...product }, { status: 201 });
});Тестирование кастомных хуков
Для хуков используется renderHook().
// useCounter.js
import { useState } from 'react';
export function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
return {
count,
increment: () => setCount((value) => value + 1),
reset: () => setCount(initialValue),
};
}import { act, renderHook } from '@testing-library/react';
import { expect, it } from 'vitest';
import { useCounter } from './useCounter';
it('увеличивает и сбрасывает значение', () => {
const { result } = renderHook(() => useCounter(5));
expect(result.current.count).toBe(5);
act(() => result.current.increment());
expect(result.current.count).toBe(6);
act(() => result.current.reset());
expect(result.current.count).toBe(5);
});result.current содержит последнее значение хука. act() нужен для прямого вызова операций, обновляющих React state. Действия userEvent обычно уже корректно обрабатывают act.
Изменение параметров
const { result, rerender } = renderHook(
({ firstName, lastName }) => useFullName(firstName, lastName),
{
initialProps: { firstName: 'Анна', lastName: 'Иванова' },
},
);
expect(result.current).toBe('Анна Иванова');
rerender({ firstName: 'Анна', lastName: 'Петрова' });
expect(result.current).toBe('Анна Петрова');Хук с провайдером
const wrapper = ({ children }) => (
<ThemeContext.Provider value="dark">
{children}
</ThemeContext.Provider>
);
const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current).toBe('dark');Для асинхронного хука применяется waitFor():
const { result } = renderHook(() => useProducts());
await waitFor(() => {
expect(result.current.status).toBe('success');
});Отдельно тестировать хук особенно полезно, если он содержит самостоятельную сложную логику, используется разными компонентами или имеет несколько переходов состояния. Небольшой внутренний хук часто достаточно проверить через поведение компонента.
Компоненты с провайдерами
Для Context, React Router или TanStack Query удобно создать общий helper:
// src/test/renderApp.jsx
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query';
export function renderApp(ui, { route = '/' } = {}) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return {
queryClient,
...render(
<MemoryRouter initialEntries={[route]}>
<QueryClientProvider client={queryClient}>
{ui}
</QueryClientProvider>
</MemoryRouter>,
),
};
}Для каждого теста создаётся новый QueryClient, чтобы кеш и состояние не переходили между тестами. Повторные запросы обычно отключают для быстрых и предсказуемых тестов ошибок.
Snapshot-тесты компонентов
Snapshot сохраняет сериализованное представление DOM и сравнивает его со снимком предыдущего запуска.
import { render } from '@testing-library/react';
import { expect, it } from 'vitest';
import { Badge } from './Badge';
it('соответствует snapshot', () => {
const { container } = render(
<Badge variant="success">Готово</Badge>,
);
expect(container).toMatchSnapshot();
});Inline snapshot хранится прямо в тесте:
expect(container).toMatchInlineSnapshot(`
<div>
<span class="badge badge--success">
Готово
</span>
</div>
`);Snapshot полезен для небольших стабильных компонентов и сериализованных результатов. Большие снимки трудно анализировать: их легко обновить, не заметив ошибку.
Вместо снимка целой страницы лучше писать точечные проверки:
expect(
screen.getByRole('heading', { name: 'Профиль' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Сохранить' }),
).toBeEnabled();Snapshot должен дополнять поведенческие тесты, а не заменять их. Хороший snapshot небольшой, детерминированный и понятный при просмотре diff.
Организация тестов
Тесты компонентов удобно хранить рядом с кодом, а общую инфраструктуру — в src/test:
src/
features/
products/
ProductList.jsx
ProductList.test.jsx
useProducts.js
useProducts.test.js
test/
fixtures/
products.js
handlers.js
server.js
renderApp.jsx
setup.jsПовторяющиеся данные можно создавать фабрикой:
export function createProduct(overrides = {}) {
return {
id: 1,
name: 'Клавиатура',
price: 5000,
...overrides,
};
}Структура Arrange — Act — Assert:
it('добавляет товар в корзину', async () => {
// Arrange
const user = userEvent.setup();
render(<ProductCard product={product} />);
// Act
await user.click(
screen.getByRole('button', { name: 'Добавить в корзину' }),
);
// Assert
expect(screen.getByText('Товар добавлен')).toBeInTheDocument();
});Название теста должно описывать наблюдаемое поведение: «показывает ошибку при недоступности API», а не просто «работает».
Частые ошибки
Забытый await
// Неправильно
user.click(button);
// Правильно
await user.click(button);Синхронный запрос для будущего элемента
// Неправильно
screen.getByText('Готово');
// Правильно
await screen.findByText('Готово');Проверка внутреннего состояния
Не проверяйте state напрямую, если результат доступен пользователю:
expect(screen.getByRole('dialog')).toBeVisible();Избыточные моки
Не заменяйте моками все дочерние компоненты и внутренние функции. Мокировать разумнее внешние границы: HTTP, время, браузерные API и тяжёлые интеграции. Для HTTP предпочтительнее MSW, чем мок global.fetch.
Загрязнение между тестами
- сбрасывайте обработчики через
server.resetHandlers(); - создавайте отдельный Query Client;
- восстанавливайте fake timers и моки;
- не делайте тесты зависимыми от порядка запуска.
Практические рекомендации
- Стройте тест вокруг пользовательского сценария.
- Ищите элементы через роль и доступное имя.
- Используйте
userEvent.setup()иawait. - Применяйте
findBy...для асинхронного появления. - Проверяйте отсутствие через
queryBy.... - Мокируйте HTTP через MSW.
- Тестируйте загрузку, успех, пустой ответ и ошибку.
- Проверяйте публичное поведение, а не внутренний
state. - Используйте snapshots только для небольших стабильных результатов.
- Делайте каждый тест независимым и детерминированным.
Краткая памятка
const user = userEvent.setup();
render(<Component />);
const button = screen.getByRole('button', { name: 'Сохранить' });
await user.click(button);
const message = await screen.findByRole('status');
expect(message).toHaveTextContent('Сохранено');
expect(screen.queryByRole('alert')).not.toBeInTheDocument();Хороший UI-тест описывает значимый пользовательский сценарий, устойчив к безопасному рефакторингу и сообщает понятную причину сбоя.