Введение в 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();
  });
});

Запрос Элементов

Приоритет Запросов (Рекомендуемый Порядок):

  1. Доступные Всем: getByRole, getByLabelText, getByPlaceholderText, getByText
  2. Семантические Запросы: getByAltText, getByTitle
  3. 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 LibraryEnzymeCypress Component
ФокусПоведение пользователяРеализацияE2E + Компоненты
Кривая ОбученияНизкаяСредняяСредняя
СкоростьБыстраяБыстраяМедленнее
Реальный БраузерНет (JSDOM)НетДа
ДоступностьОтличнаяОграниченнаяХорошая
СообществоОчень БольшоеУменьшающеесяРастущее

Заключение

Jest и Testing Library стали де-факто стандартом для современного React тестирования благодаря фокусу на тестировании поведения пользователя, отличному developer experience и комплексному набору возможностей. Поощряя тесты, которые взаимодействуют с компонентами как пользователи, они помогают создавать более поддерживаемые и устойчивые test suites.

Jest & Testing Library Идеальны Для:

  • React приложений любого размера
  • Команд, приоритизирующих доступность
  • Проектов, требующих быстрого выполнения тестов
  • Разработчиков, новых в тестировании
  • Разработки библиотек компонентов

Следующие Шаги

  1. Установить Jest и Testing Library в ваш проект
  2. Начать с простых тестов компонентов
  3. Изучить методы доступных запросов
  4. Практиковать тестирование пользовательских взаимодействий
  5. Добавить coverage reporting в ваш CI/CD
  6. Изучить MSW для API мокирования

Фокусируйтесь на тестировании того, что пользователи видят и делают, и ваша test suite останется ценной по мере эволюции вашего приложения.