React_UI_Testing

React UI-тесты

UI-тесты проверяют интерфейс с точки зрения пользователя: что он видит, какие действия выполняет и какой результат получает. В React для компонентных и интеграционных тестов часто используют Vitest, React Testing Library, user-event, jest-dom и MSW.

Главный принцип React Testing Library: тест должен быть похож на реальное использование приложения. Поэтому лучше проверять доступные элементы и видимое поведение, а не внутренний state, приватные функции или структуру компонента.


Установка и настройка

npm install -D vitest jsdom @testing-library/react \
  @testing-library/jest-dom @testing-library/user-event msw

Пример настройки Vitest:

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./src/test/setup.js'],
    globals: true,
    restoreMocks: true,
  },
});

jsdom имитирует браузерный DOM в Node.js.

// src/test/setup.js
import '@testing-library/jest-dom/vitest';

Скрипты:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage"
  }
}

React Testing Library

Простой компонент:

// Greeting.jsx
export function Greeting({ name }) {
  return <h1>Привет, {name}!</h1>;
}

Тест:

import { render, screen } from '@testing-library/react';
import { expect, it } from 'vitest';
import { Greeting } from './Greeting';

it('показывает приветствие', () => {
  render(<Greeting name="Анна" />);

  expect(
    screen.getByRole('heading', { name: 'Привет, Анна!' }),
  ).toBeInTheDocument();
});

Поведение вместо реализации

Хрупкий тест:

expect(container.querySelector('.title')).toHaveTextContent('Привет');

Он зависит от CSS-класса. Более устойчивый вариант:

expect(
  screen.getByRole('heading', { name: /привет/i }),
).toBeInTheDocument();

Изменение классов или внутренней структуры не сломает такой тест, пока пользовательское поведение остаётся прежним.


Поиск элементов

Запрос Если элемента нет Когда применять
getBy... Сразу выбрасывает ошибку Элемент уже должен быть в DOM
queryBy... Возвращает null Проверка отсутствия
findBy... Ожидает и возвращает Promise Элемент появится асинхронно

Для нескольких элементов существуют getAllBy..., queryAllBy... и findAllBy....

Предпочтительный запрос — getByRole():

screen.getByRole('button', { name: 'Сохранить' });
screen.getByRole('textbox', { name: 'Email' });
screen.getByRole('checkbox', { name: 'Запомнить меня' });
screen.getByRole('heading', { level: 2 });

Доступное имя поля формируется из связанного label:

<label htmlFor="email">Email</label>
<input id="email" type="email" />
screen.getByRole('textbox', { name: 'Email' });

Приблизительный приоритет запросов:

  1. getByRole();
  2. getByLabelText();
  3. getByPlaceholderText();
  4. getByText();
  5. getByDisplayValue();
  6. getByAltText();
  7. getByTitle();
  8. getByTestId() — запасной вариант.

data-testid полезен для элементов без подходящей семантической роли, но не должен быть основным способом поиска.

Проверка отсутствия

Неправильно:

expect(screen.getByText('Ошибка')).not.toBeInTheDocument();

getByText() выбросит исключение. Правильно:

expect(screen.queryByText('Ошибка')).not.toBeInTheDocument();

Полезные matchers

expect(element).toBeInTheDocument();
expect(element).toBeVisible();
expect(button).toBeEnabled();
expect(button).toBeDisabled();
expect(input).toHaveValue('Анна');
expect(checkbox).toBeChecked();
expect(message).toHaveTextContent('Сохранено');
expect(link).toHaveAttribute('href', '/profile');

Тестирование пользовательских взаимодействий

Для действий пользователя применяется @testing-library/user-event.

// Counter.jsx
import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <section>
      <p>Значение: {count}</p>
      <button onClick={() => setCount((value) => value + 1)}>
        Увеличить
      </button>
    </section>
  );
}
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, it } from 'vitest';
import { Counter } from './Counter';

it('увеличивает значение после клика', async () => {
  const user = userEvent.setup();
  render(<Counter />);

  await user.click(
    screen.getByRole('button', { name: 'Увеличить' }),
  );

  expect(screen.getByText('Значение: 1')).toBeInTheDocument();
});

Действия userEvent обычно асинхронны, поэтому перед ними нужен await.

Ввод и выбор значений

const user = userEvent.setup();
const email = screen.getByRole('textbox', { name: 'Email' });

await user.type(email, 'user@example.com');
expect(email).toHaveValue('user@example.com');

await user.clear(email);
expect(email).toHaveValue('');

await user.selectOptions(
  screen.getByRole('combobox', { name: 'Город' }),
  'kazan',
);

await user.click(
  screen.getByRole('checkbox', { name: 'Принимаю условия' }),
);

await user.keyboard('{Enter}');

userEvent моделирует последовательность связанных действий: фокус, клавиатурные события и изменение значения. fireEvent отправляет отдельное низкоуровневое событие и нужен реже.


Тестирование формы

// LoginForm.jsx
import { useState } from 'react';

export function LoginForm({ onSubmit }) {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');

  function handleSubmit(event) {
    event.preventDefault();

    if (!email) {
      setError('Введите email');
      return;
    }

    setError('');
    onSubmit({ email });
  }

  return (
    <form onSubmit={handleSubmit} aria-label="Вход">
      <label>
        Email
        <input
          type="email"
          value={email}
          onChange={(event) => setEmail(event.target.value)}
        />
      </label>

      {error && <p role="alert">{error}</p>}
      <button type="submit">Войти</button>
    </form>
  );
}
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { expect, it, vi } from 'vitest';
import { LoginForm } from './LoginForm';

it('не отправляет пустую форму', async () => {
  const user = userEvent.setup();
  const onSubmit = vi.fn();

  render(<LoginForm onSubmit={onSubmit} />);
  await user.click(screen.getByRole('button', { name: 'Войти' }));

  expect(screen.getByRole('alert')).toHaveTextContent('Введите email');
  expect(onSubmit).not.toHaveBeenCalled();
});

it('передаёт введённый email', async () => {
  const user = userEvent.setup();
  const onSubmit = vi.fn();

  render(<LoginForm onSubmit={onSubmit} />);

  await user.type(
    screen.getByRole('textbox', { name: 'Email' }),
    'user@example.com',
  );
  await user.click(screen.getByRole('button', { name: 'Войти' }));

  expect(onSubmit).toHaveBeenCalledOnce();
  expect(onSubmit).toHaveBeenCalledWith({ email: 'user@example.com' });
});

Асинхронный интерфейс

Если элемент появляется после Promise или запроса, используется findBy...:

expect(
  await screen.findByText('Данные загружены'),
).toBeInTheDocument();

Для произвольного асинхронного утверждения подходит waitFor():

import { waitFor } from '@testing-library/react';

await waitFor(() => {
  expect(onSave).toHaveBeenCalledOnce();
});

Ожидание исчезновения:

import { waitForElementToBeRemoved } from '@testing-library/react';

await waitForElementToBeRemoved(() =>
  screen.getByText('Загрузка...'),
);

Если достаточно дождаться появления элемента, findBy... обычно понятнее, чем waitFor().


MSW: мокирование API

Mock Service Worker перехватывает HTTP-запросы. Компонент продолжает использовать настоящий fetch, а тест управляет ответом сервера.

Преимущества MSW:

Обработчики:

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

export const handlers = [
  http.get('/api/products', () => {
    return HttpResponse.json([
      { id: 1, name: 'Клавиатура' },
      { id: 2, name: 'Мышь' },
    ]);
  }),
];

Тестовый сервер:

// src/test/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';

export const server = setupServer(...handlers);

Общая настройка:

// src/test/setup.js
import '@testing-library/jest-dom/vitest';
import { afterAll, afterEach, beforeAll } from 'vitest';
import { server } from './server';

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

resetHandlers() удаляет переопределения из отдельных тестов. onUnhandledRequest: 'error' не позволяет тесту случайно обратиться к настоящему API.

Компонент с запросом

// ProductList.jsx
import { useEffect, useState } from 'react';

export function ProductList() {
  const [products, setProducts] = useState([]);
  const [status, setStatus] = useState('loading');

  useEffect(() => {
    async function load() {
      try {
        const response = await fetch('/api/products');

        if (!response.ok) {
          throw new Error('Request failed');
        }

        setProducts(await response.json());
        setStatus('success');
      } catch {
        setStatus('error');
      }
    }

    load();
  }, []);

  if (status === 'loading') return <p>Загрузка...</p>;
  if (status === 'error') return <p role="alert">Ошибка загрузки</p>;
  if (products.length === 0) return <p>Товары не найдены</p>;

  return (
    <ul aria-label="Товары">
      {products.map((product) => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

Успешный ответ:

it('загружает и показывает товары', async () => {
  render(<ProductList />);

  expect(screen.getByText('Загрузка...')).toBeInTheDocument();
  expect(await screen.findByText('Клавиатура')).toBeInTheDocument();
  expect(screen.getByText('Мышь')).toBeInTheDocument();
  expect(screen.queryByText('Загрузка...')).not.toBeInTheDocument();
});

Ошибка сервера:

import { http, HttpResponse } from 'msw';
import { server } from '../test/server';

it('показывает ошибку при ответе 500', async () => {
  server.use(
    http.get('/api/products', () => {
      return HttpResponse.json(
        { message: 'Internal Server Error' },
        { status: 500 },
      );
    }),
  );

  render(<ProductList />);

  expect(await screen.findByRole('alert')).toHaveTextContent(
    'Ошибка загрузки',
  );
});

fetch не отклоняет Promise при статусах 404 или 500, поэтому приложение должно проверять response.ok.

Пустой ответ:

it('показывает сообщение для пустого списка', async () => {
  server.use(
    http.get('/api/products', () => HttpResponse.json([])),
  );

  render(<ProductList />);

  expect(
    await screen.findByText('Товары не найдены'),
  ).toBeInTheDocument();
});

Параметры и тело запроса доступны обработчику:

http.get('/api/products/:id', ({ params }) => {
  return HttpResponse.json({ id: params.id, name: 'Монитор' });
});

http.post('/api/products', async ({ request }) => {
  const product = await request.json();
  return HttpResponse.json({ id: 10, ...product }, { status: 201 });
});

Тестирование кастомных хуков

Для хуков используется renderHook().

// useCounter.js
import { useState } from 'react';

export function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);

  return {
    count,
    increment: () => setCount((value) => value + 1),
    reset: () => setCount(initialValue),
  };
}
import { act, renderHook } from '@testing-library/react';
import { expect, it } from 'vitest';
import { useCounter } from './useCounter';

it('увеличивает и сбрасывает значение', () => {
  const { result } = renderHook(() => useCounter(5));

  expect(result.current.count).toBe(5);

  act(() => result.current.increment());
  expect(result.current.count).toBe(6);

  act(() => result.current.reset());
  expect(result.current.count).toBe(5);
});

result.current содержит последнее значение хука. act() нужен для прямого вызова операций, обновляющих React state. Действия userEvent обычно уже корректно обрабатывают act.

Изменение параметров

const { result, rerender } = renderHook(
  ({ firstName, lastName }) => useFullName(firstName, lastName),
  {
    initialProps: { firstName: 'Анна', lastName: 'Иванова' },
  },
);

expect(result.current).toBe('Анна Иванова');

rerender({ firstName: 'Анна', lastName: 'Петрова' });
expect(result.current).toBe('Анна Петрова');

Хук с провайдером

const wrapper = ({ children }) => (
  <ThemeContext.Provider value="dark">
    {children}
  </ThemeContext.Provider>
);

const { result } = renderHook(() => useTheme(), { wrapper });
expect(result.current).toBe('dark');

Для асинхронного хука применяется waitFor():

const { result } = renderHook(() => useProducts());

await waitFor(() => {
  expect(result.current.status).toBe('success');
});

Отдельно тестировать хук особенно полезно, если он содержит самостоятельную сложную логику, используется разными компонентами или имеет несколько переходов состояния. Небольшой внутренний хук часто достаточно проверить через поведение компонента.


Компоненты с провайдерами

Для Context, React Router или TanStack Query удобно создать общий helper:

// src/test/renderApp.jsx
import { render } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import {
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query';

export function renderApp(ui, { route = '/' } = {}) {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
      mutations: { retry: false },
    },
  });

  return {
    queryClient,
    ...render(
      <MemoryRouter initialEntries={[route]}>
        <QueryClientProvider client={queryClient}>
          {ui}
        </QueryClientProvider>
      </MemoryRouter>,
    ),
  };
}

Для каждого теста создаётся новый QueryClient, чтобы кеш и состояние не переходили между тестами. Повторные запросы обычно отключают для быстрых и предсказуемых тестов ошибок.


Snapshot-тесты компонентов

Snapshot сохраняет сериализованное представление DOM и сравнивает его со снимком предыдущего запуска.

import { render } from '@testing-library/react';
import { expect, it } from 'vitest';
import { Badge } from './Badge';

it('соответствует snapshot', () => {
  const { container } = render(
    <Badge variant="success">Готово</Badge>,
  );

  expect(container).toMatchSnapshot();
});

Inline snapshot хранится прямо в тесте:

expect(container).toMatchInlineSnapshot(`
  <div>
    <span class="badge badge--success">
      Готово
    </span>
  </div>
`);

Snapshot полезен для небольших стабильных компонентов и сериализованных результатов. Большие снимки трудно анализировать: их легко обновить, не заметив ошибку.

Вместо снимка целой страницы лучше писать точечные проверки:

expect(
  screen.getByRole('heading', { name: 'Профиль' }),
).toBeInTheDocument();

expect(
  screen.getByRole('button', { name: 'Сохранить' }),
).toBeEnabled();

Snapshot должен дополнять поведенческие тесты, а не заменять их. Хороший snapshot небольшой, детерминированный и понятный при просмотре diff.


Организация тестов

Тесты компонентов удобно хранить рядом с кодом, а общую инфраструктуру — в src/test:

src/
  features/
    products/
      ProductList.jsx
      ProductList.test.jsx
      useProducts.js
      useProducts.test.js
  test/
    fixtures/
      products.js
    handlers.js
    server.js
    renderApp.jsx
    setup.js

Повторяющиеся данные можно создавать фабрикой:

export function createProduct(overrides = {}) {
  return {
    id: 1,
    name: 'Клавиатура',
    price: 5000,
    ...overrides,
  };
}

Структура Arrange — Act — Assert:

it('добавляет товар в корзину', async () => {
  // Arrange
  const user = userEvent.setup();
  render(<ProductCard product={product} />);

  // Act
  await user.click(
    screen.getByRole('button', { name: 'Добавить в корзину' }),
  );

  // Assert
  expect(screen.getByText('Товар добавлен')).toBeInTheDocument();
});

Название теста должно описывать наблюдаемое поведение: «показывает ошибку при недоступности API», а не просто «работает».


Частые ошибки

Забытый await

// Неправильно
user.click(button);

// Правильно
await user.click(button);

Синхронный запрос для будущего элемента

// Неправильно
screen.getByText('Готово');

// Правильно
await screen.findByText('Готово');

Проверка внутреннего состояния

Не проверяйте state напрямую, если результат доступен пользователю:

expect(screen.getByRole('dialog')).toBeVisible();

Избыточные моки

Не заменяйте моками все дочерние компоненты и внутренние функции. Мокировать разумнее внешние границы: HTTP, время, браузерные API и тяжёлые интеграции. Для HTTP предпочтительнее MSW, чем мок global.fetch.

Загрязнение между тестами


Практические рекомендации

  1. Стройте тест вокруг пользовательского сценария.
  2. Ищите элементы через роль и доступное имя.
  3. Используйте userEvent.setup() и await.
  4. Применяйте findBy... для асинхронного появления.
  5. Проверяйте отсутствие через queryBy....
  6. Мокируйте HTTP через MSW.
  7. Тестируйте загрузку, успех, пустой ответ и ошибку.
  8. Проверяйте публичное поведение, а не внутренний state.
  9. Используйте snapshots только для небольших стабильных результатов.
  10. Делайте каждый тест независимым и детерминированным.

Краткая памятка

const user = userEvent.setup();
render(<Component />);

const button = screen.getByRole('button', { name: 'Сохранить' });
await user.click(button);

const message = await screen.findByRole('status');
expect(message).toHaveTextContent('Сохранено');

expect(screen.queryByRole('alert')).not.toBeInTheDocument();

Хороший UI-тест описывает значимый пользовательский сценарий, устойчив к безопасному рефакторингу и сообщает понятную причину сбоя.