Разработка Unit-тестов для компонентов (React Testing Library)
React Testing Library строится на одной идее: тесты должны проверять поведение компонента с точки зрения пользователя, а не детали реализации. Нет прямого доступа к state, нет проверки instance-переменных, нет wrapper.find(MyInternalComponent). Только то, что реально рендерится в DOM и как на это реагирует пользователь.
Это и преимущество, и ограничение. Тесты не ломаются при рефакторинге внутренней логики, но требуют правильно проектировать компоненты — с доступными ролями, понятными data-testid и предсказуемым поведением.
Установка и конфигурация
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event vitest jsdom
Конфигурация Vitest с поддержкой jsdom:
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
globals: true,
},
});
// src/test/setup.ts
import '@testing-library/jest-dom';
Для Jest:
// jest.config.js
module.exports = {
testEnvironment: 'jsdom',
setupFilesAfterFramework: ['@testing-library/jest-dom'],
transform: {
'^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: 'tsconfig.test.json' }],
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|scss)$': 'identity-obj-proxy',
},
};
Базовые паттерны
Первое, что нужно понять — разница между getBy, queryBy и findBy:
-
getBy— синхронный, бросает если не найден -
queryBy— синхронный, возвращает null если не найден (для проверки отсутствия) -
findBy— асинхронный, ждёт появления элемента (для async операций)
// components/LoginForm/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { LoginForm } from './LoginForm';
describe('LoginForm', () => {
it('отображает поля email и пароль', () => {
render(<LoginForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/пароль/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /войти/i })).toBeInTheDocument();
});
it('вызывает onSubmit с введёнными данными', async () => {
const user = userEvent.setup();
const handleSubmit = vi.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), '[email protected]');
await user.type(screen.getByLabelText(/пароль/i), 'password123');
await user.click(screen.getByRole('button', { name: /войти/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: '[email protected]',
password: 'password123',
});
});
it('показывает ошибку при пустом email', async () => {
const user = userEvent.setup();
render(<LoginForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole('button', { name: /войти/i }));
expect(screen.getByText(/введите email/i)).toBeInTheDocument();
// Кнопка отправки должна быть заблокирована или форма не отправлена
expect(vi.fn()).not.toHaveBeenCalled();
});
});
Моки и провайдеры
Компоненты в реальных приложениях зависят от контекста — роутера, стора, i18n, query-клиента. Общий паттерн — кастомный render:
// src/test/render.tsx
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { ReactNode } from 'react';
interface WrapperProps {
children: ReactNode;
initialEntries?: string[];
}
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
}
export function renderWithProviders(
ui: React.ReactElement,
options?: RenderOptions & { initialEntries?: string[] }
) {
const { initialEntries = ['/'], ...rest } = options ?? {};
const queryClient = createTestQueryClient();
function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={initialEntries}>
{children}
</MemoryRouter>
</QueryClientProvider>
);
}
return render(ui, { wrapper: Wrapper, ...rest });
}
Мок HTTP-запросов через MSW:
// src/test/handlers.ts
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/users/:id', ({ params }) => {
return HttpResponse.json({
id: params.id,
name: 'Test User',
email: '[email protected]',
});
}),
http.post('/api/auth/login', async ({ request }) => {
const body = await request.json() as { email: string; password: string };
if (body.password === 'wrong') {
return HttpResponse.json(
{ message: 'Неверный пароль' },
{ status: 401 }
);
}
return HttpResponse.json({ token: 'fake-jwt-token' });
}),
];
// src/test/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// setup.ts — добавить MSW
import { server } from './server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Тестирование async компонентов
// components/UserProfile/UserProfile.test.tsx
import { renderWithProviders } from '@/test/render';
import { screen, waitFor } from '@testing-library/react';
import { server } from '@/test/server';
import { http, HttpResponse } from 'msw';
import { UserProfile } from './UserProfile';
it('загружает и отображает данные пользователя', async () => {
renderWithProviders(<UserProfile userId="42" />);
// Сначала должен быть индикатор загрузки
expect(screen.getByRole('progressbar')).toBeInTheDocument();
// Ждём появления данных
expect(await screen.findByText('Test User')).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});
it('показывает ошибку при недоступном API', async () => {
server.use(
http.get('/api/users/:id', () => {
return HttpResponse.json({ message: 'Server error' }, { status: 500 });
})
);
renderWithProviders(<UserProfile userId="42" />);
expect(await screen.findByText(/не удалось загрузить/i)).toBeInTheDocument();
});
Тестирование форм с React Hook Form
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ProductForm } from './ProductForm';
describe('ProductForm', () => {
it('валидирует обязательные поля перед отправкой', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ProductForm onSubmit={onSubmit} />);
// Пустая отправка
await user.click(screen.getByRole('button', { name: /сохранить/i }));
await waitFor(() => {
expect(screen.getByText(/название обязательно/i)).toBeInTheDocument();
});
expect(onSubmit).not.toHaveBeenCalled();
});
it('отправляет корректные данные', async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<ProductForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/название/i), 'Новый продукт');
await user.type(screen.getByLabelText(/цена/i), '1500');
await user.selectOptions(screen.getByLabelText(/категория/i), 'electronics');
await user.click(screen.getByRole('button', { name: /сохранить/i }));
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'Новый продукт',
price: 1500,
category: 'electronics',
});
});
});
});
Покрытие и CI
Конфигурация coverage в Vitest:
// vitest.config.ts
export default defineConfig({
test: {
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
exclude: [
'node_modules/**',
'src/test/**',
'**/*.stories.tsx',
'**/index.ts',
],
thresholds: {
statements: 80,
branches: 75,
functions: 80,
lines: 80,
},
},
},
});
GitHub Actions:
# .github/workflows/test.yml
- name: Run unit tests
run: npx vitest run --coverage
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: ./coverage/lcov.info
Что тестировать, а что нет
Стоит покрывать тестами: условный рендеринг (если/иначе ветки), обработчики событий, валидацию форм, состояния загрузки и ошибок, интеграцию с роутером (переходы, параметры).
Не стоит: точный CSS-класс элемента (сломается при ребрендинге), snapshot целой страницы без изменений, внутренние методы компонента, детали стейт-менеджера которые не отражаются в UI.
Сроки
Написать первые тесты для существующего проекта с нуля: настройка окружения + базовые тесты ключевых компонентов — 3–5 дней. Покрыть тестами новый компонент средней сложности (форма, список, диалог) — 4–8 часов в зависимости от количества состояний. Внедрить MSW и переписать тесты, использующие прямые моки API — 1–2 дня на проект.







