Покрытие React-компонентов тестами: Jest и React Testing Library на практике

#Jest #ReactTestingLibrary #Testing #React #Frontend #QualityAssurance #JavaScript

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

Тестирование React компонентов

Почему "просто тесты" не работают

Типичные ошибки:

  • Тестирование реализации (внутренние state-ы)
  • 100% coverage с бесполезными тестами
  • Хрупкие тесты, ломающиеся после любого изменения
  • Игнорирование доступности и пользовательских сценариев

Результат: Ложное чувство безопасности и часы бесполезной работы.

Философия React Testing Library

"Тестируйте компоненты так, как их использует пользователь"

— Kent C. Dodds (создатель RTL)

3 главных принципа:

  • 1
    Queries по доступным ролям: getByRole(), getByLabelText() вместо классов
  • 2
    User 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"],
};