Разработка Unit-тестов для компонентов (React Testing Library)

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.
Разработка и обслуживание любых видов сайтов:
Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Разработка Unit-тестов для компонентов (React Testing Library)
Средняя
~3-5 рабочих дней
Часто задаваемые вопросы
Наши компетенции:
Этапы разработки
Последние работы
  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Разработка 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';

Для Jest:

// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterFramework: ['@testing-library/jest-dom'],
  transform: {
    '^.+\\.(ts|tsx)$': ['ts-jest', { tsconfig: 'tsconfig.test.json' }],
  },
  moduleNameMapper: {
    '^@/(.*)$': '<rootDir>/src/$1',
    '\\.(css|scss)$': 'identity-obj-proxy',
  },
};

Базовые паттерны

Первое, что нужно понять — разница между 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();
    // Кнопка отправки должна быть заблокирована или форма не отправлена
    expect(vi.fn()).not.toHaveBeenCalled();
  });
});

Моки и провайдеры

Компоненты в реальных приложениях зависят от контекста — роутера, стора, 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';

interface WrapperProps {
  children: ReactNode;
  initialEntries?: string[];
}

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 });
}

Мок HTTP-запросов через MSW:

// src/test/handlers.ts
import { http, HttpResponse } from 'msw';

export const handlers = [
  http.get('/api/users/:id', ({ params }) => {
    return HttpResponse.json({
      id: params.id,
      name: 'Test User',
      email: '[email protected]',
    });
  }),

  http.post('/api/auth/login', async ({ request }) => {
    const body = await request.json() as { email: string; password: string };

    if (body.password === 'wrong') {
      return HttpResponse.json(
        { message: 'Неверный пароль' },
        { status: 401 }
      );
    }

    return HttpResponse.json({ token: 'fake-jwt-token' });
  }),
];
// src/test/server.ts
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);
// setup.ts — добавить MSW
import { server } from './server';

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

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

// components/UserProfile/UserProfile.test.tsx
import { renderWithProviders } from '@/test/render';
import { screen, waitFor } from '@testing-library/react';
import { server } from '@/test/server';
import { http, HttpResponse } from 'msw';
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();
});

it('показывает ошибку при недоступном API', async () => {
  server.use(
    http.get('/api/users/:id', () => {
      return HttpResponse.json({ message: 'Server error' }, { status: 500 });
    })
  );

  renderWithProviders(<UserProfile userId="42" />);

  expect(await screen.findByText(/не удалось загрузить/i)).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',
      });
    });
  });
});

Покрытие и CI

Конфигурация coverage в Vitest:

// vitest.config.ts
export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov', 'html'],
      exclude: [
        'node_modules/**',
        'src/test/**',
        '**/*.stories.tsx',
        '**/index.ts',
      ],
      thresholds: {
        statements: 80,
        branches: 75,
        functions: 80,
        lines: 80,
      },
    },
  },
});

GitHub Actions:

# .github/workflows/test.yml
- name: Run unit tests
  run: npx vitest run --coverage

- name: Upload coverage
  uses: codecov/codecov-action@v4
  with:
    file: ./coverage/lcov.info

Что тестировать, а что нет

Стоит покрывать тестами: условный рендеринг (если/иначе ветки), обработчики событий, валидацию форм, состояния загрузки и ошибок, интеграцию с роутером (переходы, параметры).

Не стоит: точный CSS-класс элемента (сломается при ребрендинге), snapshot целой страницы без изменений, внутренние методы компонента, детали стейт-менеджера которые не отражаются в UI.

Сроки

Написать первые тесты для существующего проекта с нуля: настройка окружения + базовые тесты ключевых компонентов — 3–5 дней. Покрыть тестами новый компонент средней сложности (форма, список, диалог) — 4–8 часов в зависимости от количества состояний. Внедрить MSW и переписать тесты, использующие прямые моки API — 1–2 дня на проект.