Урок 14: Тестирование слайсов и редьюсеров

Мы с вами уже научились создавать слайсы, управлять асинхронными операциями и даже структурировать большие приложения. Но профессиональная разработка на этом не заканчивается. Настоящий код это не просто код, который работает. Это код в работоспособности которого вы уверены на 100%, даже когда вносите в него изменения. Именно эту уверенность нам и даёт тестирование.

Сегодня мы изучим unit-тестирование наших редьюсеров и слайсов. Вы удивитесь, насколько это может быть просто и даже увлекательно. Мы разберём, почему Redux-логика идеально подходит для тестирования, как писать тесты для синхронных операций и для асинхронных thunk’ов, используя Jest. Всё это мы будем подкреплять живыми примерами кода, чтобы вы не просто поняли теорию, а могли сразу применить эти знания в своих проектах.

Легко ли тестировать редьюсеры и слайсы?

Почему именно тестирование логики состояния считается относительно простой задачей? Всё дело в концептуальной чистоте, которую проповедует Redux. Наши редьюсеры, будь они написаны вручную или созданы с помощью createSlice, являются чистыми функциями.

Давайте вспомним, что это значит. Чистая функция это функция, которая при одних и тех же входных данных всегда возвращает один и тот же результат. Её работа не зависит от внешнего состояния (глобальных переменных) и не производит побочных эффектов (например запросов к API или изменения DOM). Она только принимает аргументы и возвращает новое значение.

Редьюсер это классический пример чистой функции, (state, action) => newState. Вы передаёте ему текущее состояние и объект действия и он возвращает вам совершенно новое, иммутабельно обновлённое состояние. Эта предсказуемость и делает их идеальными кандидатами для unit-тестирования.

Представьте, что вы тестируете функцию, которая делает запрос к серверу. Вам нужно мокать сеть, обрабатывать таймауты, симулировать разные ответы сервера. Это сложно. С редьюсером всё иначе. Вам не нужно ничего мокать, кроме его входных данных. Вы просто вызываете функцию с конкретными state и action и проверяете, что на выходе получили ожидаемый newState. Это быстро, надёжно и не требует сложной настройки тестового окружения.

Redux Toolkit лишь усиливает это преимущество. Слайсы, которые мы генерируем с помощью createSlice, по своей сути, являются теми же редьюсерами, но с автоматически сгенерированными экшенами. Логика внутри reducers и extraReducers остаётся чистой и легко тестируемой. Сегодня мы убедимся в этом на практике, написав тесты для нашего старого знакомого, слайса счетчика, а затем и для более сложного асинхронного thunk’а.

Настраиваем окружение для тестирования (Jest)

Прежде чем писать тесты, давайте убедимся, что у нас есть инструмент для их запуска. В современной экосистеме React стандартом де-факто является Jest. Это гибкий фреймворк для тестирования JavaScript, разработанный Facebook. Если вы создавали проект через Create React App или Vite, Jest, скорее всего, уже предустановлен и настроен.

Для тех, кто настраивает проект вручную, установка выглядит так:

bash
npm install --save-dev jest @types/jest

В вашем package.json можно добавить скрипт для удобства:

json
{
  "scripts": {
    "test": "jest",
    "test:watch": "jest --watch"
  }
}

Jest по умолчанию ищет файлы с расширениями .test.js или .spec.js. Мы будем создавать наши тесты рядом с тестируемыми модулями. Например, для counterSlice.js мы создадим файл counterSlice.test.js.

Одна из ключевых возможностей Jest, которую мы будем активно использовать, это функция expect. В паре с «матчерами» (matchers) она позволяет формулировать утверждения. Базовый синтаксис выглядит так: expect(actualValue).toBe(expectedValue). В нашем случае actualValue это результат вызова редьюсера, а expectedValue то состояние, которое мы ожидаем получить.

Пишем первый тест для синхронного редьюсера

Давайте вспомним, как выглядел наш слайс для счетчика из одного из ранних уроков:

javascript
// counterSlice.js
import { createSlice } from '@reduxjs/toolkit';

const initialState = {
  value: 0,
};

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    incremented: (state) => {
      state.value += 1;
    },
    decremented: (state) => {
      state.value -= 1;
    },
    incrementedByAmount: (state, action) => {
      state.value += action.payload;
    },
  },
});

export const { incremented, decremented, incrementedByAmount } = counterSlice.actions;
export default counterSlice.reducer;

Теперь создадим для него тестовый файл counterSlice.test.js.

Самый простой тест, это проверка начального состояния. Мы импортируем редьюсер и проверим, что при вызове без состояния (или с undefined) и с любым экшеном (кроме тех, что он обрабатывает) возвращается initialState.

javascript
// counterSlice.test.js
import counterReducer, { incremented, decremented, incrementedByAmount } from './counterSlice';

describe('counter reducer', () => {
  test('should return the initial state when passed an empty action', () => {
    // Аргументы: initialState, action
    // Для инициализации хранилища Redux диспатчит специальный экшен типа 'redux/init'
    const initialState = { value: 0 };
    const result = counterReducer(undefined, { type: '' });
    expect(result).toEqual(initialState);
  });
});

Здесь мы видим наш первый тест-кейс внутри блока describe, который группирует связанные тесты. Мы вызываем counterReducer с undefined в качестве состояния (что заставит редьюсер использовать initialState) и с пустым экшеном. Затем мы проверяем, что результат строго равен (toEqual) нашему ожидаемому начальному состоянию.

Теперь протестируем сами редьюсеры. Мы будем диспатчить сгенерированные экшн-креаторы и проверять, как состояние изменяется.

javascript
// counterSlice.test.js (продолжение)
  test('should handle incremented action', () => {
    const initialState = { value: 0 };
    // action = { type: 'counter/incremented' }
    const result = counterReducer(initialState, incremented());
    expect(result.value).toBe(1);
  });

  test('should handle decremented action', () => {
    const initialState = { value: 5 };
    const result = counterReducer(initialState, decremented());
    expect(result.value).toBe(4);
  });

  test('should handle incrementedByAmount action', () => {
    const initialState = { value: 10 };
    const result = counterReducer(initialState, incrementedByAmount(5));
    expect(result.value).toBe(15);
  });

В этих тестах мы наглядно видим принцип «чистоты». Мы берем конкретное начальное состояние, применяем к нему конкретный экшен и проверяем конкретный результат. Это и есть суть unit-тестирования.

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

Ситуация становится чуть сложнее, когда мы имеем дело с асинхронными операциями. Однако Redux Toolkit предоставляет нам createAsyncThunk, который стандартизирует процесс, а Jest даёт инструменты для мокинга и работы с асинхронностью. Это делает тестирование предсказуемым.

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

javascript
// usersSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import { fetchUsers as fetchUsersAPI } from './usersAPI'; // Предположим, это наш модуль для API-вызовов

// Создаем асинхронный thunk
export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
  const response = await fetchUsersAPI(); // Эта функция возвращает промис с данными
  return response.data;
});

const usersSlice = createSlice({
  name: 'users',
  initialState: {
    items: [],
    loading: false,
    error: null,
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.loading = false;
        state.items = action.payload;
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message;
      });
  },
});

export default usersSlice.reducer;

Теперь наша задача протестировать логику внутри extraReducers. Мы хотим убедиться, что при диспатче экшенов pendingfulfilled и rejected наше состояние меняется корректно.

Первое, что нам нужно сделать, это замокать API-запрос. Мы не хотим, чтобы наши тесты реально ходили в сеть. Они должны быть быстрыми и изолированными.

javascript
// usersSlice.test.js
import usersReducer, { fetchUsers } from './usersSlice';
import { fetchUsersAPI } from './usersAPI';

// Мокаем модуль API
jest.mock('./usersAPI');

describe('users slice', () => {
  afterEach(() => {
    jest.clearAllMocks(); // Очищаем моки после каждого теста
  });

  test('should set loading to true on pending', () => {
    const initialState = { items: [], loading: false, error: null };
    // action для pending состояния генерируется автоматически
    const action = { type: fetchUsers.pending.type };
    const result = usersReducer(initialState, action);
    expect(result).toEqual({ items: [], loading: true, error: null });
  });
});

Этот тест очень похож на наши синхронные тесты. Мы просто диспатчим экшен pending и проверяем, что loading стал true.

Теперь самое интересное, это тестирование успешного сценария (fulfilled). Нам нужно сымитировать успешный ответ от API.

javascript
// usersSlice.test.js (продолжение)
  test('should set users and loading to false on fulfilled', async () => {
    // 1. Подготавливаем моковые данные
    const mockUsers = [{ id: 1, name: 'John' }, { id: 2, name: 'Jane' }];
    
    // 2. Заставляем наш мокнутый fetchUsersAPI вернуть эти данные
    fetchUsersAPI.mockResolvedValueOnce({ data: mockUsers });

    // 3. Диспатчим thunk, чтобы получить все экшены
    // Thunk-функция возвращает промис, который резолвится, когда все экшены будут задиспатчены
    const dispatch = jest.fn();
    const getState = jest.fn();

    // Запускаем thunk
    await fetchUsers()(dispatch, getState, undefined);

    // 4. Проверяем, что были задиспатчены правильные экшены в правильном порядке
    // `fetchUsers` диспатчит как минимум два экшена: pending и fulfilled
    expect(dispatch).toHaveBeenCalledTimes(2);

    // Первый аргумент первого вызова dispatch, это pending экшен
    const pendingAction = dispatch.mock.calls[0][0];
    expect(pendingAction.type).toBe(fetchUsers.pending.type);

    // Первый аргумент второго вызова dispatch, это fulfilled экшен
    const fulfilledAction = dispatch.mock.calls[1][0];
    expect(fulfilledAction.type).toBe(fetchUsers.fulfilled.type);
    expect(fulfilledAction.payload).toEqual(mockUsers); // Проверяем, что в payload пришли наши моковые данные

    // 5. Проверяем, как редьюсер обрабатывает fulfilled экшен
    const initialState = { items: [], loading: true, error: null };
    const newState = usersReducer(initialState, fulfilledAction);
    expect(newState).toEqual({
      items: mockUsers,
      loading: false,
      error: null
    });
  });

Этот тест уже сложнее. Мы не только проверяем финальное состояние, но и убеждаемся в правильной последовательности диспатча экшенов. Это даёт нам полную уверенность в работе нашего thunk’а.

Аналогичным образом пишется тест для сценария с ошибкой (rejected).

javascript
// usersSlice.test.js (продолжение)
  test('should set error and loading to false on rejected', async () => {
    // Мокаем ошибку
    const errorMessage = 'Network Error';
    fetchUsersAPI.mockRejectedValueOnce(new Error(errorMessage));

    const dispatch = jest.fn();
    const getState = jest.fn();

    await fetchUsers()(dispatch, getState, undefined);

    // Находим rejected экшен
    const rejectedAction = dispatch.mock.calls[1][0]; // Второй диспатч
    expect(rejectedAction.type).toBe(fetchUsers.rejected.type);
    expect(rejectedAction.error.message).toBe(errorMessage);

    // Проверяем обработку редьюсером
    const initialState = { items: [], loading: true, error: null };
    const newState = usersReducer(initialState, rejectedAction);
    expect(newState).toEqual({
      items: [],
      loading: false,
      error: errorMessage,
    });
  });
});

Практические задания для закрепления материала

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

  • Дополните тесты для счетчика. Напишите тест для редьюсера incrementedByAmount, который проверяет работу с отрицательными числами. Убедитесь, что счетчик корректно уменьшается.

  • Протестируйте слайс задач (todos). Вернитесь к слайсу управления задачами, который мы создавали ранее. Он должен иметь экшены addTodotoggleTodoremoveTodo. Напишите полный набор тестов для него.

    1. Проверьте начальное состояние (пустой массив задач).

    2. Проверьте addTodo. Новая задача должна добавиться в массив, у неё должен быть уникальный id, текст и статус completed: false.

    3. Проверьте toggleTodo. При переключении задача должна изменить свой completed на противоположный.

    4. Проверьте removeTodo. Задача должна быть удалена из массива по id.

  • Усложните тест для fetchUsers. Представьте, что наш API-запрос теперь принимает параметр, например, limit. Модифицируйте thunk и напишите тест, который проверяет, что параметр корректно передается в функцию fetchUsersAPI.

  • Интеграционный тест. Создайте простой тест, который имитирует полный поток: диспатч fetchUsers приводит к состоянию загрузки, а затем к состоянию с загруженными пользователями. Используйте redux-mock-store или просто проверяйте последовательность диспатчей, как мы это делали выше.

Подводим итоги 14-го урока

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

Мы изучили от простых синхронных тестов для счетчика, до тестирования асинхронных thunk’ов с моками API. Вы увидели, как с помощью Jest мы можем проверить любой сценарий, успешный, ошибочный и промежуточный. Помните, что хорошо протестированное приложение это стабильное приложение, которое легко развивать и поддерживать.

В следующем уроке нас ждёт не менее важная тема, это типизация Redux Toolkit с TypeScript. Мы научимся делать наш код ещё более надёжным и самодокументируемым с помощью статической типизации. Это позволит отлавливать множество ошибок ещё на этапе написания кода.

Надеюсь, этот урок был для вас полезным и информативным. Если вы хотите изучить тему Redux и Redux Toolkit комплексно, от основ до продвинутых практик, приглашаю вас ознакомиться с полным курсо.: Курс с уроками по Redux и Redux Toolkit для начинающих.

Поделиться статьей:
Поддержать автора блога

Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.

Персональные рекомендации
Оставить комментарий