Redux и тестирование с reducer-ами

Redux — это одна из самых популярных библиотек для управления состоянием в JavaScript-приложениях, особенно в связке с React. Тестирование Redux-приложений важно для обеспечения правильной работы логики и предсказуемости состояний. В этой главе рассмотрим, как эффективно тестировать reducer-ы с использованием React Testing Library и других инструментов для тестирования.

Понимание reducer-а

Reducer — это чистая функция, которая принимает два аргумента: текущее состояние и действие, и возвращает новое состояние. В Redux reducer-ы используются для обработки всех изменений состояния приложения. Их задача — принять определённое действие (action) и изменить состояние, соответствующим образом реагируя на это действие.

Пример простого reducer-а:

const initialState = { count: 0 };

function counterReducer(state = initialState, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    default:
      return state;
  }
}

Этот reducer обрабатывает два типа действий: INCREMENT и DECREMENT, изменяя свойство count в состоянии.

Подходы к тестированию reducer-ов

Тестирование reducer-ов отличается от тестирования React-компонентов. Основная цель заключается в том, чтобы проверить, правильно ли работает логика изменения состояния на основе поступающих действий. Тесты должны быть быстрыми и независимыми, так как reducer-ы — это чистые функции без побочных эффектов.

Тестирование reducer-а обычно включает следующие шаги:

  1. Проверка начального состояния.
  2. Проверка изменения состояния при разных действиях.
  3. Проверка того, что при неизвестных действиях состояние остаётся неизменным.

Пример тестирования reducer-а

Для начала создадим тесты для приведённого выше counterReducer.

import { counterReducer } from './counterReducer';

describe('counterReducer', () => {
  it('должен вернуть начальное состояние при отсутствии переданного состояния', () => {
    const initialState = { count: 0 };
    expect(counterReducer(undefined, {})).toEqual(initialState);
  });

  it('должен увеличить счетчик при действии INCREMENT', () => {
    const action = { type: 'INCREMENT' };
    const initialState = { count: 0 };
    const expectedState = { count: 1 };
    expect(counterReducer(initialState, action)).toEqual(expectedState);
  });

  it('должен уменьшить счетчик при действии DECREMENT', () => {
    const action = { type: 'DECREMENT' };
    const initialState = { count: 1 };
    const expectedState = { count: 0 };
    expect(counterReducer(initialState, action)).toEqual(expectedState);
  });

  it('должен возвращать текущее состояние для неизвестных действий', () => {
    const action = { type: 'UNKNOWN' };
    const initialState = { count: 0 };
    expect(counterReducer(initialState, action)).toEqual(initialState);
  });
});

Здесь используется фреймворк тестирования Jest. В этих тестах:

  • Проверяется, что при отсутствии состояния в качестве аргумента, reducer возвращает начальное состояние.
  • Проверяется правильность изменения состояния при действии INCREMENT и DECREMENT.
  • Проверяется, что неизвестное действие не меняет состояние.

Работа с асинхронными действиями в Redux

В большинстве современных приложений с использованием Redux часто применяются асинхронные операции, такие как запросы к серверу. Чтобы тестировать такие операции, часто используются middleware, такие как redux-thunk. Важно понимать, что тестирование reducer-ов, которые не занимаются асинхронной логикой, остаётся таким же, как показано выше.

Для асинхронных действий тестирование обычно будет происходить на уровне действий или компонентов, а не самих reducer-ов. Однако иногда стоит проверить, как reducer реагирует на действия, генерируемые асинхронными операциями.

Пример асинхронного действия с использованием redux-thunk:

const fetchData = () => {
  return dispatch => {
    dispatch({ type: 'FETCH_REQUEST' });
    return fetch('/data')
      .then(response => response.json())
      .then(data => {
        dispatch({ type: 'FETCH_SUCCESS', payload: data });
      })
      .catch(error => {
        dispatch({ type: 'FETCH_FAILURE', error });
      });
  };
};

Этот код не относится напрямую к reducer-у, однако reducer может обрабатывать такие действия, как FETCH_REQUEST, FETCH_SUCCESS и FETCH_FAILURE. Тестирование асинхронных операций в Redux обычно включает проверку, как состояние меняется после выполнения этих действий.

Использование middleware для тестирования асинхронных действий

Для тестирования асинхронных действий в Redux можно использовать такие библиотеки, как redux-mock-store и jest-fetch-mock.

Пример теста с использованием redux-mock-store:

import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { fetchData } from './actions';

const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);

describe('fetchData', () => {
  it('должен выполнять асинхронный запрос и вызывать соответствующие действия', () => {
    const store = mockStore({});
    const mockData = { name: 'Test' };

    jest.spyOn(global, 'fetch').mockResolvedValue({
      json: jest.fn().mockResolvedValue(mockData),
    });

    const expectedActions = [
      { type: 'FETCH_REQUEST' },
      { type: 'FETCH_SUCCESS', payload: mockData },
    ];

    return store.dispatch(fetchData()).then(() => {
      expect(store.getActions()).toEqual(expectedActions);
      global.fetch.mockRestore();
    });
  });
});

В этом тесте:

  • Мокируется вызов fetch.
  • Проверяется, что после выполнения действия fetchData вызываются ожидаемые действия (FETCH_REQUEST и FETCH_SUCCESS).

Структурирование тестов для крупных приложений

Когда приложение становится большим, а reducer-ы растут, важно поддерживать структуру тестов в порядке. Хорошей практикой является организация тестов для каждого reducer-а отдельно, а также группировка тестов для связанных reducer-ов в единые пакеты.

Например, если в приложении используется несколько различных reducer-ов для управления состоянием пользователя, товаров и корзины, можно разделить тесты на несколько файлов:

  • userReducer.test.js
  • productReducer.test.js
  • cartReducer.test.js

Также стоит соблюдать принцип изоляции тестов, чтобы каждый тест проверял только одну конкретную функцию. Это позволяет выявлять ошибки на ранних этапах и облегчает поддержку тестов.

Важные моменты при тестировании reducer-ов

  1. Чистота тестов: Reducer-ы должны быть чистыми функциями, без побочных эффектов. Это облегчает их тестирование, так как не требуется мокировать внешние зависимости или среды.
  2. Модульность: Хорошо спроектированные reducer-ы, как и тесты для них, должны быть независимыми друг от друга. Это снижает сложность тестирования и улучшает поддержку.
  3. Предсказуемость: Reducer должен всегда возвращать новое состояние, основываясь только на действии и текущем состоянии. Это позволяет гарантировать, что приложение будет вести себя предсказуемо.

Заключение

Тестирование reducer-ов в Redux — это важный этап обеспечения качества приложения. Основной акцент следует делать на проверке правильности обработки различных действий и неизменности состояния при непредвиденных ситуациях. Правильная организация тестов и использование инструментов, таких как jest, redux-mock-store, поможет убедиться, что ваше приложение работает так, как ожидалось.