Покрытие React-компонентов тестами: Jest и React Testing Library на практике
#Jest #ReactTestingLibrary #Testing #React #Frontend #QualityAssurance #JavaScript
Устали от ломающихся компонентов после каждого рефакторинга? Разбираем battle-tested подходы к тестированию React с Jest и RTL: от базовых снимков до асинхронных сценариев. Реальные кейсы из e-commerce: как мы снизили баги на 78% при 92% coverage.

Почему "просто тесты" не работают
Типичные ошибки:
- Тестирование реализации (внутренние state-ы)
- 100% coverage с бесполезными тестами
- Хрупкие тесты, ломающиеся после любого изменения
- Игнорирование доступности и пользовательских сценариев
Результат: Ложное чувство безопасности и часы бесполезной работы.
Философия React Testing Library
"Тестируйте компоненты так, как их использует пользователь"
— Kent C. Dodds (создатель RTL)
3 главных принципа:
- 1Queries по доступным ролям: getByRole(), getByLabelText() вместо классов
- 2User Event вместо синтетических событий: user.click() вместо fireEvent
- 3Асинхронные проверки: waitFor, findBy для загрузки данных
Пошаговые примеры из реальных проектов
1. Базовый тест: Кнопка с загрузкой
.tsx
// Компонент
export const SubmitButton = ({ isLoading }: { isLoading: boolean }) => (
<button
aria-disabled={isLoading}
aria-label={isLoading ? "Загрузка" : "Отправить"}
>
{isLoading ? <Spinner /> : "Отправить"}
</button>
);
// Тест
test("кнопка блокируется при загрузке", async () => {
const user = userEvent.setup();
render(<SubmitButton isLoading={true} />);
const button = screen.getByRole("button", { name: /загрузка/i });
expect(button).toBeDisabled();
expect(button).toHaveAttribute("aria-disabled", "true");
// Проверка анимации
expect(screen.getByRole("status")).toBeInTheDocument(); // Spinner
});2. Форма с валидацией
.tsx
// Компонент
const EmailForm = ({ onSubmit }) => {
const [email, setEmail] = useState("");
const [error, setError] = useState("");
const handleSubmit = () => {
if (!/^\S+@\S+\.\S+$/.test(email)) {
setError("Некорректный email");
return;
}
onSubmit(email);
};
return (
<form onSubmit={handleSubmit}>
<input
aria-label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
{error && <div role="alert">{error}</div>}
<button type="submit">Отправить</button>
</form>
);
};
// Тест
test("показывает ошибку при невалидном email", async () => {
const user = userEvent.setup();
const mockSubmit = jest.fn();
render(<EmailForm onSubmit={mockSubmit} />);
await user.type(screen.getByLabelText("Email"), "invalid-email");
await user.click(screen.getByText("Отправить"));
// Проверяем сообщение об ошибке
const error = await screen.findByRole("alert");
expect(error).toHaveTextContent("Некорректный email");
expect(mockSubmit).not.toHaveBeenCalled();
});3. Асинхронный компонент (загрузка данных)
.tsx
// Компонент
export const UserProfile = ({ userId }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
setUser(data);
setLoading(false);
});
}, [userId]);
if (loading) return <Loader />;
return (
<div>
<h1>{user.name}</h1>
<p>Email: {user.email}</p>
</div>
);
};
// Тест
test("отображает данные пользователя после загрузки", async () => {
// Мокируем API
global.fetch = jest.fn().mockResolvedValue({
json: () => Promise.resolve({
name: "Иван Иванов",
email: "ivan@example.com"
}),
});
render(<UserProfile userId="123" />);
// Проверяем лоадер
expect(screen.getByRole("status")).toBeInTheDocument();
// Ждем появления данных
const userName = await screen.findByRole("heading", { name: /иван иванов/i });
const email = await screen.findByText(/ivan@example.com/i);
expect(userName).toBeInTheDocument();
expect(email).toBeInTheDocument();
expect(fetch).toHaveBeenCalledWith("/api/users/123");
});Как достичь 90%+ coverage без фейков
Стратегия осмысленного покрытия:
Стратегия
A[Критическая логика] -->|Приоритет 1| B[Компоненты с бизнес-логикой]
C[Пользовательские сценарии] -->|Приоритет 2| D[Основные user flows]
E[Визуальные элементы] -->|Приоритет 3| F[Состояния загрузки/ошибки]
G[Утилитарные функции] -->|Приоритет 4| H[Хелперы, валидаторы]Инструменты:
bash
# Покрытие + визуализация
npx jest --coverage --collectCoverageFrom='src/**/*.{js,jsx,ts,tsx}'
# Интерактивный режим
npx jest --watchПример осмысленного .jest.config:
jest.config.js
module.exports = {
coverageThreshold: {
global: {
statements: 90,
branches: 80,
functions: 85,
lines: 90,
},
},
coveragePathIgnorePatterns: [
"/node_modules/",
"src/index.tsx",
"src/App.tsx",
"src/reportWebVitals.ts"
],
};Антипаттерны: что убивает ваши тесты
Тестирование внутреннего состояния:
❌ Плохо
test("устанавливает state", () => {
const { result } = renderHook(() => useForm());
act(() => result.current.setEmail("test@test.com"));
expect(result.current.email).toBe("test@test.com");
});Правильное решение:
✅ Правильно
test("блокирует кнопку при невалидном email", async () => {
render(<Form />);
await user.type(screen.getByLabelText("Email"), "invalid");
expect(screen.getByRole("button")).toBeDisabled();
});Избыточные снепшоты:
❌ Плохо
expect(container).toMatchSnapshot(); // Ломается после любого изменения CSSПравильное решение:
✅ Правильно
test("корректно рендерит заголовок", () => {
render(<Header title="Каталог" />);
expect(screen.getByRole("heading", { name: "Каталог" })).toBeInTheDocument();
});Игнорирование доступности:
❌ Плохо
fireEvent.click(getByTestId("submit-btn"));Правильное решение:
✅ Правильно
user.click(screen.getByRole("button", { name: "Отправить" }));Интеграция в CI/CD
Пример .github/workflows/tests.yml:
tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm test -- --ci --coverage
- name: Upload coverage
uses: codecov/codecov-action@v3Метрики нашего проекта
| Показатель | До RTL | После RTL |
|---|---|---|
| Регрессионные баги | 24/мес | 5/мес |
| Время на рефакторинг | 8 ч | 3 ч |
| Покрытие (branch) | 45% | 86% |
Итог:
Настоящие тесты — не про 100% coverage, а про:
- 🛡️ Защиту от регрессий в ключевых сценариях
- 📝 Документирование поведения компонентов
- ♿ Контроль доступности для всех пользователей
- ⚡ Ускорение рефакторинга без страха
Стартовый набор
bash
npm install -D jest @testing-library/react @testing-library/user-event jest-environment-jsdomНастройте за 5 минут:
jest.config.js
// jest.config.js
module.exports = {
testEnvironment: "jsdom",
setupFilesAfterEnv: ["@testing-library/jest-dom/extend-expect"],
};