Розробка 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';
Базові патерни
Перше, що потрібно зрозуміти — різниця між 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();
});
});
Моки та провайдери
Компоненти в реальних програмах залежать від контексту — маршрутизатор, сховище, 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';
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 });
}
Тестування асинхронних компонентів
// components/UserProfile/UserProfile.test.tsx
import { renderWithProviders } from '@/test/render';
import { screen, waitFor } from '@testing-library/react';
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();
});
Тестування форм з 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',
});
});
});
});
Часовий графік
Написати перші тесты для існуючого проекту з нуля: налаштування окружень + базові тесты ключових компонентів — 3–5 днів. Покрити тестами новий компонент середної складності (форма, список, діалог) — 4–8 годин залежно від кількості станів.







