Тестирование useReducer логики

useReducer является мощным инструментом управления состоянием в React, особенно в сложных компонентах с множественными взаимосвязанными состояниями. Тестирование такой логики отличается от стандартного тестирования простых компонентов, поскольку основной упор делается на проверку корректного изменения состояния при разных действиях.


Структура редьюсера

Редьюсер в React имеет стандартный вид:

const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { ...state, count: state.count + 1 };
    case 'decrement':
      return { ...state, count: state.count - 1 };
    case 'reset':
      return initialState;
    default:
      return state;
  }
}

Ключевые элементы для тестирования:

  • Начальное состояние (initialState)
  • Типы действий (action.type)
  • Корректность возвращаемого состояния

Тестирование должно охватывать каждый кейс, включая обработку неизвестных действий.


Тестирование редьюсера отдельно

Тестирование useReducer логики не всегда требует рендеринга компонента. Можно тестировать чистую функцию редьюсера напрямую:

import { reducer, initialState } from './counterReducer';

test('increment action increases count', () => {
  const state = { count: 0 };
  const newState = reducer(state, { type: 'increment' });
  expect(newState.count).toBe(1);
});

test('decrement action decreases count', () => {
  const state = { count: 1 };
  const newState = reducer(state, { type: 'decrement' });
  expect(newState.count).toBe(0);
});

test('reset action returns initial state', () => {
  const state = { count: 5 };
  const newState = reducer(state, { type: 'reset' });
  expect(newState).toEqual(initialState);
});

test('unknown action returns current state', () => {
  const state = { count: 3 };
  const newState = reducer(state, { type: 'unknown' });
  expect(newState).toBe(state);
});

Преимущества такого подхода:

  • Быстрое тестирование без необходимости рендеринга компонента.
  • Легко покрыть все ветки логики.
  • Простота поддержания и отладки.

Интеграционное тестирование с React Testing Library

Для проверки поведения компонента, использующего useReducer, важно тестировать взаимодействие пользователя с интерфейсом и влияние этих действий на состояние.

Пример простого счетчика:

import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';

test('increments count on button click', () => {
  render(<Counter />);
  
  const incrementButton = screen.getByText('Increment');
  fireEvent.click(incrementButton);
  
  const countDisplay = screen.getByText(/count: 1/i);
  expect(countDisplay).toBeInTheDocument();
});

test('decrements count on button click', () => {
  render(<Counter />);
  
  const decrementButton = screen.getByText('Decrement');
  fireEvent.click(decrementButton);
  
  const countDisplay = screen.getByText(/count: -1/i);
  expect(countDisplay).toBeInTheDocument();
});

test('resets count to initial state', () => {
  render(<Counter />);
  
  const incrementButton = screen.getByText('Increment');
  const resetButton = screen.getByText('Reset');
  
  fireEvent.click(incrementButton);
  fireEvent.click(incrementButton);
  fireEvent.click(resetButton);
  
  const countDisplay = screen.getByText(/count: 0/i);
  expect(countDisplay).toBeInTheDocument();
});

Особенности тестирования с RTL:

  • Использование fireEvent или userEvent для имитации действий пользователя.
  • Проверка изменений DOM вместо внутреннего состояния.
  • Локализация тестов на конкретные элементы интерфейса: кнопки, поля, текстовые отображения.

Тестирование сложных редьюсеров

Для более сложных редьюсеров с множественными действиями и состояниями рекомендуется:

  1. Разделять логику редьюсера на отдельные функции, если есть повторяющийся код.

  2. Создавать тестовые сценарии, покрывающие цепочки действий:

    // Пример: последовательность действий
    let state = initialState;
    state = reducer(state, { type: 'increment' });
    state = reducer(state, { type: 'increment' });
    state = reducer(state, { type: 'decrement' });
    expect(state.count).toBe(1);
  3. Использовать проверку неизменяемости состояния:

    const state = { count: 0 };
    const newState = reducer(state, { type: 'increment' });
    expect(newState).not.toBe(state); // новый объект, не мутация

Моки и асинхронные действия

Если редьюсер обрабатывает асинхронные действия, например через dispatch({ type: 'fetchSuccess', payload }), тестирование может включать:

  • Моки сетевых запросов через jest.mock или msw.
  • Проверку состояния до, во время и после асинхронного действия.
  • Использование waitFor для ожидания обновления DOM:
import { render, screen, waitFor } from '@testing-library/react';
import DataComponent from './DataComponent';
import { fetchData } from './api';

jest.mock('./api');

test('updates state after fetch', async () => {
  fetchData.mockResolvedValue({ value: 42 });
  
  render(<DataComponent />);
  
  await waitFor(() => {
    expect(screen.getByText(/value: 42/i)).toBeInTheDocument();
  });
});

Рекомендации по покрытию тестами

  • Каждое действие редьюсера должно иметь хотя бы один тест.
  • Для сложных состояний проверять все ключи состояния после действий.
  • В интеграционных тестах проверять связь интерфейса и редьюсера, а не внутреннюю структуру объекта.
  • Использовать именованные константы для action.type для уменьшения опечаток.

Тщательное тестирование useReducer обеспечивает надежность компонентов, делает код предсказуемым и упрощает поддержку сложной логики состояния в React.