Введение в Jest & Testing Library
Jest и Testing Library представляют современный стандарт для тестирования React приложений. Вместе они предоставляют мощную, дружелюбную к разработчикам экосистему для написания поддерживаемых тестов, которые фокусируются на поведении пользователя, а не на деталях реализации.
Jest — это комплексный JavaScript testing framework, разработанный Facebook, включающий встроенные test runners, библиотеку assertion, возможности мокирования и инструменты покрытия кода. React Testing Library (часть семейства Testing Library) поощряет тестирование компонентов так, как пользователи взаимодействуют с ними, продвигая лучшие практики тестирования и более устойчивые test suites.
Почему Выбрать Jest & Testing Library?
Ключевые Преимущества:
- Нулевая Конфигурация: Работает out-of-the-box с Create React App
- User-Centric Тестирование: Тесты фокусируются на поведении пользователя, не на реализации
- Быстрое Выполнение: Параллельное выполнение тестов с интеллектуальным кешированием
- Богатая Экосистема: Обширные matchers, утилиты и community plugins
- Фокус на Доступности: Встроенные возможности тестирования accessibility
- Отличный DX: Чёткие error messages и полезные debugging tools
- Универсальная Поддержка: Работает с React, Vue, Angular, Svelte и vanilla JS
Начало Работы с Jest
Установка и Настройка
# Для новых React проектов (Jest включён)
npx create-react-app my-app
# Для существующих проектов
npm install --save-dev jest @testing-library/react @testing-library/jest-dom
npm install --save-dev @testing-library/user-event
# TypeScript поддержка
npm install --save-dev @types/jest
Конфигурация Jest
// jest.config.js
module.exports = {
// Тестовое окружение
testEnvironment: 'jsdom',
// Setup файлы
setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
// Пути модулей
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|svg)$': '<rootDir>/__mocks__/fileMock.js',
},
// Конфигурация покрытия
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/index.js',
'!src/**/*.test.{js,jsx,ts,tsx}',
],
coverageThresholds: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70,
},
},
// Трансформация файлов
transform: {
'^.+\\.(js|jsx|ts|tsx)$': 'babel-jest',
},
// Паттерны совпадения тестов
testMatch: [
'**/__tests__/**/*.(test|spec).(js|jsx|ts|tsx)',
'**/*.(test|spec).(js|jsx|ts|tsx)',
],
};
Основные Концепции Jest
Базовая Структура Теста
// sum.js
export function sum(a, b) {
return a + b;
}
// sum.test.js
import { sum } from './sum';
describe('функция sum', () => {
test('складывает 1 + 2 чтобы получить 3', () => {
expect(sum(1, 2)).toBe(3);
});
test('правильно складывает отрицательные числа', () => {
expect(sum(-1, -2)).toBe(-3);
});
test('обрабатывает ноль', () => {
expect(sum(0, 5)).toBe(5);
});
});
Jest Matchers
describe('Jest matchers', () => {
// Равенство
test('toBe vs toEqual', () => {
const obj = { name: 'Иван' };
expect(obj).toEqual({ name: 'Иван' }); // Глубокое равенство
expect(obj).not.toBe({ name: 'Иван' }); // Равенство по ссылке
});
// Truthiness
test('truthiness matchers', () => {
expect(null).toBeNull();
expect(undefined).toBeUndefined();
expect(true).toBeTruthy();
expect(false).toBeFalsy();
expect(0).toBeFalsy();
});
// Числа
test('number matchers', () => {
expect(10).toBeGreaterThan(5);
expect(10).toBeGreaterThanOrEqual(10);
expect(10).toBeLessThan(20);
expect(0.1 + 0.2).toBeCloseTo(0.3);
});
// Строки
test('string matchers', () => {
expect('привет мир').toMatch(/мир/);
expect('привет').not.toMatch(/пока/);
expect('команда').toContain('ком');
});
// Массивы и итерируемые
test('array matchers', () => {
const фрукты = ['яблоко', 'банан', 'апельсин'];
expect(фрукты).toContain('банан');
expect(фрукты).toHaveLength(3);
expect(фрукты).toEqual(expect.arrayContaining(['яблоко', 'банан']));
});
// Объекты
test('object matchers', () => {
const user = {
name: 'Иван',
age: 30,
email: 'ivan@example.com',
};
expect(user).toHaveProperty('name');
expect(user).toHaveProperty('age', 30);
expect(user).toMatchObject({ name: 'Иван' });
});
// Исключения
test('exception matchers', () => {
function throwError() {
throw new Error('Что-то пошло не так');
}
expect(throwError).toThrow();
expect(throwError).toThrow('Что-то пошло не так');
expect(throwError).toThrow(Error);
});
});
Асинхронное Тестирование
// Промисы
test('получает данные пользователя', () => {
return fetchUser(1).then((user) => {
expect(user.name).toBe('Иван Иванов');
});
});
// Async/Await
test('получает данные пользователя с async/await', async () => {
const user = await fetchUser(1);
expect(user.name).toBe('Иван Иванов');
});
// Resolves/Rejects
test('получение пользователя resolve', async () => {
await expect(fetchUser(1)).resolves.toHaveProperty('name');
});
test('получение пользователя reject с ошибкой', async () => {
await expect(fetchUser(999)).rejects.toThrow('Пользователь не найден');
});
Основы React Testing Library
Базовое Тестирование Компонентов
// Button.jsx
export function Button({ onClick, children, disabled }) {
return (
<button onClick={onClick} disabled={disabled}>
{children}
</button>
);
}
// Button.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';
describe('Компонент Button', () => {
test('рендерит кнопку с текстом', () => {
render(<Button>Нажми меня</Button>);
expect(screen.getByRole('button', { name: /нажми меня/i })).toBeInTheDocument();
});
test('вызывает onClick при клике', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Нажми меня</Button>);
const button = screen.getByRole('button');
await user.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test('не вызывает onClick когда disabled', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick} disabled>Нажми меня</Button>);
const button = screen.getByRole('button');
await user.click(button);
expect(handleClick).not.toHaveBeenCalled();
});
});
Запрос Элементов
Приоритет Запросов (Рекомендуемый Порядок):
- Доступные Всем:
getByRole
,getByLabelText
,getByPlaceholderText
,getByText
- Семантические Запросы:
getByAltText
,getByTitle
- Test IDs:
getByTestId
(последний resort)
import { render, screen, within } from '@testing-library/react';
describe('Методы запросов', () => {
test('getBy запросы', () => {
render(<LoginForm />);
// По роли (ЛУЧШЕЕ)
const button = screen.getByRole('button', { name: /войти/i });
// По тексту label
const emailInput = screen.getByLabelText(/email/i);
// По placeholder
const searchInput = screen.getByPlaceholderText(/поиск/i);
// По текстовому содержимому
const heading = screen.getByText(/добро пожаловать/i);
// По test ID (ПОСЛЕДНИЙ RESORT)
const element = screen.getByTestId('custom-element');
expect(button).toBeInTheDocument();
});
test('queryBy для элементов которые могут не существовать', () => {
render(<Notification show={false} />);
// Возвращает null если не найдено (не бросает ошибку)
const message = screen.queryByText(/уведомление/i);
expect(message).not.toBeInTheDocument();
});
test('findBy для асинхронных элементов', async () => {
render(<AsyncComponent />);
// Ждёт появления элемента (возвращает промис)
const data = await screen.findByText(/загруженные данные/i);
expect(data).toBeInTheDocument();
});
});
Взаимодействия Пользователя
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
test('взаимодействия пользователя', async () => {
const user = userEvent.setup();
render(<ContactForm />);
// Ввод в input
const nameInput = screen.getByLabelText(/имя/i);
await user.type(nameInput, 'Иван Иванов');
expect(nameInput).toHaveValue('Иван Иванов');
// Очистить input
await user.clear(nameInput);
expect(nameInput).toHaveValue('');
// Клик по кнопке
const submitButton = screen.getByRole('button', { name: /отправить/i });
await user.click(submitButton);
// Двойной клик
await user.dblClick(submitButton);
// Выбрать опцию
const select = screen.getByLabelText(/страна/i);
await user.selectOptions(select, 'Россия');
expect(select).toHaveValue('Россия');
// Отметить checkbox
const checkbox = screen.getByRole('checkbox', { name: /согласен/i });
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
Продвинутые Паттерны Тестирования
Тестирование Форм
// ContactForm.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ContactForm } from './ContactForm';
describe('ContactForm', () => {
test('отправляет форму с валидными данными', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<ContactForm onSubmit={onSubmit} />);
// Заполнить форму
await user.type(screen.getByLabelText(/имя/i), 'Иван Иванов');
await user.type(screen.getByLabelText(/email/i), 'ivan@example.com');
await user.type(screen.getByLabelText(/сообщение/i), 'Привет мир');
// Отправить
await user.click(screen.getByRole('button', { name: /отправить/i }));
// Проверить отправку
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
name: 'Иван Иванов',
email: 'ivan@example.com',
message: 'Привет мир',
});
});
});
test('показывает ошибки валидации для пустых полей', async () => {
const user = userEvent.setup();
render(<ContactForm />);
// Отправить пустую форму
await user.click(screen.getByRole('button', { name: /отправить/i }));
// Проверить сообщения об ошибках
expect(await screen.findByText(/имя обязательно/i)).toBeInTheDocument();
expect(screen.getByText(/email обязателен/i)).toBeInTheDocument();
});
});
Тестирование Доступности
import { render, screen } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('Тесты доступности', () => {
test('LoginForm не имеет нарушений доступности', async () => {
const { container } = render(<LoginForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
test('кнопка имеет правильный aria-label', () => {
render(<IconButton icon="trash" aria-label="Удалить элемент" />);
const button = screen.getByRole('button', { name: /удалить элемент/i });
expect(button).toHaveAccessibleName('Удалить элемент');
});
});
Стратегии Мокирования в Jest
Мокирование Функций
// Простой mock
const mockCallback = jest.fn();
mockCallback('test');
expect(mockCallback).toHaveBeenCalledWith('test');
// Mock implementation
const mockFn = jest.fn((x) => x * 2);
expect(mockFn(5)).toBe(10);
// Mock return values
const mock = jest.fn();
mock.mockReturnValue(42);
mock.mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValue(3);
// Mock resolved/rejected промисы
const asyncMock = jest.fn();
asyncMock.mockResolvedValue({ data: 'успех' });
asyncMock.mockRejectedValue(new Error('Неудача'));
Мокирование Модулей
// api.js
export const fetchUser = (id) => {
return fetch(`/api/users/${id}`).then((res) => res.json());
};
// component.test.js
import { fetchUser } from './api';
jest.mock('./api');
test('загружает данные пользователя', async () => {
fetchUser.mockResolvedValue({ name: 'Иван', email: 'ivan@example.com' });
render(<UserProfile userId={1} />);
expect(await screen.findByText(/иван/i)).toBeInTheDocument();
expect(fetchUser).toHaveBeenCalledWith(1);
});
Best Practices
1. Тестировать Поведение Пользователя, Не Реализацию
// ❌ Плохо: Тестирование деталей реализации
test('вызывает handleSubmit когда форма отправлена', () => {
const handleSubmit = jest.fn();
const { getByRole } = render(<Form onSubmit={handleSubmit} />);
// Тестирование prop напрямую
});
// ✅ Хорошо: Тестирование взаимодействия пользователя
test('отправляет форму когда пользователь кликает на отправить', async () => {
const user = userEvent.setup();
render(<Form />);
await user.click(screen.getByRole('button', { name: /отправить/i }));
expect(await screen.findByText(/успех/i)).toBeInTheDocument();
});
2. Использовать Доступные Запросы
// ❌ Избегать: Использование test IDs
screen.getByTestId('submit-button');
// ✅ Предпочитать: Использование доступных запросов
screen.getByRole('button', { name: /отправить/i });
3. Избегать Тестирования Внутренностей Библиотеки
// ❌ Плохо: Тестирование состояния
expect(component.state.isLoading).toBe(false);
// ✅ Хорошо: Тестирование отрендеренного вывода
expect(screen.queryByText(/загрузка/i)).not.toBeInTheDocument();
Сравнение с Другими Инструментами
Характеристика | Jest + Testing Library | Enzyme | Cypress Component |
---|---|---|---|
Фокус | Поведение пользователя | Реализация | E2E + Компоненты |
Кривая Обучения | Низкая | Средняя | Средняя |
Скорость | Быстрая | Быстрая | Медленнее |
Реальный Браузер | Нет (JSDOM) | Нет | Да |
Доступность | Отличная | Ограниченная | Хорошая |
Сообщество | Очень Большое | Уменьшающееся | Растущее |
Заключение
Jest и Testing Library стали де-факто стандартом для современного React тестирования благодаря фокусу на тестировании поведения пользователя, отличному developer experience и комплексному набору возможностей. Поощряя тесты, которые взаимодействуют с компонентами как пользователи, они помогают создавать более поддерживаемые и устойчивые test suites.
Jest & Testing Library Идеальны Для:
- React приложений любого размера
- Команд, приоритизирующих доступность
- Проектов, требующих быстрого выполнения тестов
- Разработчиков, новых в тестировании
- Разработки библиотек компонентов
Следующие Шаги
- Установить Jest и Testing Library в ваш проект
- Начать с простых тестов компонентов
- Изучить методы доступных запросов
- Практиковать тестирование пользовательских взаимодействий
- Добавить coverage reporting в ваш CI/CD
- Изучить MSW для API мокирования
Фокусируйтесь на тестировании того, что пользователи видят и делают, и ваша test suite останется ценной по мере эволюции вашего приложения.