Мы с вами уже научились создавать слайсы, управлять асинхронными операциями и даже структурировать большие приложения. Но профессиональная разработка на этом не заканчивается. Настоящий код это не просто код, который работает. Это код в работоспособности которого вы уверены на 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, скорее всего, уже предустановлен и настроен.
Для тех, кто настраивает проект вручную, установка выглядит так:
npm install --save-dev jest @types/jest
В вашем package.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 то состояние, которое мы ожидаем получить.
Пишем первый тест для синхронного редьюсера
Давайте вспомним, как выглядел наш слайс для счетчика из одного из ранних уроков:
// 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.
// 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) нашему ожидаемому начальному состоянию.
Теперь протестируем сами редьюсеры. Мы будем диспатчить сгенерированные экшн-креаторы и проверять, как состояние изменяется.
// 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 даёт инструменты для мокинга и работы с асинхронностью. Это делает тестирование предсказуемым.
Давайте возьмем пример из урока по асинхронным операциям, слайс для загрузки пользователей.
// 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. Мы хотим убедиться, что при диспатче экшенов pending, fulfilled и rejected наше состояние меняется корректно.
Первое, что нам нужно сделать, это замокать API-запрос. Мы не хотим, чтобы наши тесты реально ходили в сеть. Они должны быть быстрыми и изолированными.
// 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.
// 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).
// 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). Вернитесь к слайсу управления задачами, который мы создавали ранее. Он должен иметь экшены
addTodo,toggleTodo,removeTodo. Напишите полный набор тестов для него.-
Проверьте начальное состояние (пустой массив задач).
-
Проверьте
addTodo. Новая задача должна добавиться в массив, у неё должен быть уникальный id, текст и статусcompleted: false. -
Проверьте
toggleTodo. При переключении задача должна изменить свойcompletedна противоположный. -
Проверьте
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». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.


