Приветствую на семнадцатом уроке нашего большого курса по Redux и Redux Toolkit. Сегодня мы переходим к одной из основных тем для любого профессионального разработчика, это тестированию.
Мы уже научились создавать слайсы, работать с асинхронными запросами и даже организовывать структуру проекта. Но как убедиться, что наш тщательно выстроенный код продолжает работать правильно после каждого изменения? Как не сломать старое, добавляя новую функциональность? Ответ, писать тесты.
В этом уроке мы сфокусируемся на тестировании React-компонентов, которые используют наше состояние Redux и, что особенно интересно, данные, полученные через RTK Query.
В моем блоге вы также найдете полный цикл уроков по React для начинающих.
Тестирование компонентов с Redux
Когда мы тестируем обычный компонент с useState, всё относительно просто: мы рендерим компонент, находим элементы, имитируем клики и проверяем, что состояние изменилось так, как мы ожидали. Но что происходит, когда компонент начинает общаться с глобальным состоянием?
Представь компонент, который показывает список пользователей. Он использует useSelector, чтобы получить этот список из стора и useDispatch, чтобы отправить экшен, например, для удаления пользователя. А если этот список загружается через RTK Query, то под капотом еще и запускается асинхронный запрос.
Если мы просто попробуем рендерить такой компонент в тестовом окружении, нас ждет несколько проблем:
-
Ошибка отсутствия стора. Хуки
useSelectorиuseDispatchбудут искать<Provider>в дереве компонентов выше. Если его нет, то они выбросят ошибку. -
Реальные HTTP-запросы. RTK Query попытается сделать настоящий HTTP-запрос к API, что для тестов недопустимо. Тесты должны быть быстрыми, изолированными и надежными, а сетевые запросы медленные и ненадежные.
-
Сложность воспроизведения состояний. Нам нужно проверить не только «успешное» состояние компонента, но и состояния загрузки (
pending) и ошибки (rejected).
Решение этих проблем лежит в использовании двух ключевых концепций: мока (mock) стора и правильной обертки компонента в Provider для тестов.
Концепция мока стора и тестового Provider
Давай разберем эти концепции по порядку.
Mock store (моковый store)
Мок это упрощенная, контролируемая версия реального объекта, которая имитирует его поведение. Вместо того чтобы создавать настоящий store с помощью configureStore со всеми нашими слайсами и middleware, мы можем создать его «муляж».
Мы создаем объект, который имеет те же методы, что и настоящий store (в первую очередь getState, dispatch и subscribe), но мы полностью контролируем, что они возвращают. Для тестирования компонента нам критически важен метод getState, потому что именно его использует хук useSelector для получения данных.
Мы можем создать мок стора, вручную задав начальное состояние (initialState), которое точно соответствует структуре нашего настоящего корневого состояния (RootState). Это позволяет нам тестировать компонент в любом нужном нам состоянии: с пустым списком пользователей, с заполненным, с ошибкой и т.д.
Тестовый Provider
Хуки React-Redux требуют, чтобы выше в дереве компонентов был <Provider>. В наших тестах мы должны сделать то же самое: обернуть тестируемый компонент в <Provider> и передать ему в пропс store не настоящий store, а наш мок.
Это решает первую проблему. Хук useSelector теперь будет брать данные из нашего мокового стора, а useDispatch будет возвращать функцию-заглушку, которую мы можем отслеживать (например, с помощью Jest jest.fn()).
// Пример обертки import { Provider } from 'react-redux' import { render, screen } from '@testing-library/react' const mockStore = configureStore([]) // Создаем моковый store const store = mockStore({ // Наше подконтрольное начальное состояние usersApi: { queries: {}, mutations: {}, // ... другие поля RTK Query }, // ... другие слайсы, если они нужны компоненту }) const Wrapper = ({ children }) => ( <Provider store={store}>{children}</Provider> ) test('should render component', () => { render(<MyComponent />, { wrapper: Wrapper }) // ... наши assertions })
Подход к тестированию компонентов с RTK Query
RTK Query управляет своим собственным состоянием внутри стора (обычно в срезе с именем api или как мы его назвали, например, usersApi). Чтобы заставить компонент поверить, что данные уже загружены или произошла ошибка, нам нужно правильно заполнить эту часть состояния в нашем моковом сторе.
Структура состояния RTK Query специфична. Она содержит:
-
queries. Объект, где ключ это хэш аргументов запроса, а значение, это статус запроса и данные. -
mutations. Аналогично для мутаций.
К счастью, нам не нужно запоминать эту структуру. Redux Toolkit предоставляет утилиту для тестирования @reduxjs/toolkit/query/react экспортирует специальный объект server, но чаще нам поможет ручное заполнение состояния на основе его реальной структуры.
Самый практичный способ, это посмотреть в Redux DevTools, как выглядит состояние после успешного запроса и просто скопировать эту структуру в мок своего стора.
Альтернативный, более «белый» подход, это мокать не store, а сами хуки RTK Query. Библиотека jest позволяет нам это легко сделать.
Пишем тест для компонента списка пользователей
Вспомним наш компонент UsersList, который мы создавали, кажется, сто лет назад в девятом уроке. Он использует хук, сгенерированный RTK Query, для загрузки и отображения списка пользователей.
Шаг 1: Компонент, который мы будем тестировать
Предположим, наш компонент выглядит так:
// UsersList.jsx import { useGetUsersQuery } from '../api/usersApi' const UsersList = () => { const { data: users, isLoading, error } = useGetUsersQuery() if (isLoading) return <div data-testid="loading">Загрузка...</div> if (error) return <div data-testid="error">Произошла ошибка: {error.message}</div> return ( <ul> {users?.map((user) => ( <li key={user.id} data-testid={`user-${user.id}`}> {user.name} ({user.email}) </li> ))} </ul> ) } export default UsersList
Шаг 2: Настраиваем тестовое окружение
Сначала установим необходимые пакеты, если они еще не установлены:
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event jest-environment-jsdom
Создадим файл теста: UsersList.test.jsx.
Шаг 3: Мокаем RTK Query Hook
Вместо того чтобы мокать весь store, мы можем напрямую подменить хук useGetUsersQuery, чтобы он возвращал данные, которые мы хотим протестировать.
// UsersList.test.jsx import { render, screen } from '@testing-library/react' import UsersList from './UsersList' // Мокаем хук, который использует наш компонент jest.mock('../api/usersApi', () => ({ useGetUsersQuery: jest.fn(), })) // Импортируем уже замоканную версию, чтобы управлять ею в тестах import { useGetUsersQuery } from '../api/usersApi' describe('UsersList Component', () => { it('должен показывать индикатор загрузки', () => { // 1. Arrange (Подготовка): Настраиваем мок так, чтобы он возвращал состояние загрузки useGetUsersQuery.mockReturnValue({ isLoading: true, }) // 2. Act (Действие): Рендерим компонент render(<UsersList />) // 3. Assert (Проверка): Убеждаемся, что элемент с текстом "Загрузка..." появился на экране expect(screen.getByTestId('loading')).toBeInTheDocument() }) it('должен показывать сообщение об ошибке', () => { // Arrange const errorMessage = 'Failed to fetch' useGetUsersQuery.mockReturnValue({ error: { message: errorMessage }, }) // Act render(<UsersList />) // Assert expect(screen.getByTestId('error')).toHaveTextContent(`Произошла ошибка: ${errorMessage}`) }) it('должен отображать список пользователей', () => { // Arrange const mockUsers = [ { id: 1, name: 'Максим', email: 'max@example.com' }, { id: 2, name: 'Анна', email: 'anna@example.com' }, ] useGetUsersQuery.mockReturnValue({ data: mockUsers, }) // Act render(<UsersList />) // Assert // Проверяем, что элементы списка отрендерились expect(screen.getByTestId('user-1')).toHaveTextContent('Максим (max@example.com)') expect(screen.getByTestId('user-2')).toHaveTextContent('Анна (anna@example.com)') // Или проверяем, что всего пользователей в списке 2 const listItems = screen.getAllByRole('listitem') expect(listItems).toHaveLength(2) }) })
Пояснение к коду:
-
Мокание хука.
jest.mock('../api/usersApi', ...)заменяет все экспорты из файлаusersApiна мок-функции. Мы отдельно мокаемuseGetUsersQueryкакjest.fn(). -
Управление моком. В каждом тесте мы используем
useGetUsersQuery.mockReturnValue(...), чтобы определить, что должен вернуть хук при вызове внутри компонента. Мы имитируем разные состояния: загрузку, ошибку, успех с данными. -
Селекторы в тестах. Мы используем
screen.getByTestIdдля поиска элементов поdata-testid. Это самый стабильный способ, так как он не зависит от текста или CSS-классов, которые могут меняться. Мы добавилиdata-testidв наш компонент специально для тестов. -
Утверждения. Мы используем матчеры из
@testing-library/jest-dom(например.toBeInTheDocument(),.toHaveTextContent()), которые делают утверждения более читаемыми.
Мокание store с помощью configureStore (альтернативный способ)
Иногда мокание каждого хука может быть избыточным, особенно если компонент использует много разных хуков из RTK Query или вместе с ними использует и «классические» слайсы. В этом случае можно создать полноценный мок store.
Для этого нам понадобится вспомогательная функция configureStore из Redux Toolkit, которая в тестовом режиме создаст store с нашим подконтрольным состоянием.
// UsersList.test.withStore.jsx import { render, screen } from '@testing-library/react' import { Provider } from 'react-redux' import { configureStore } from '@reduxjs/toolkit' import UsersList from './UsersList' // Импортируем наш API slice, чтобы подключить его редьюсер к store import { usersApi } from '../api/usersApi' // Внимание: создаем НАСТОЯЩИЙ store, но с подконтрольным состоянием. // Мы не будем делать реальных запросов, потому что не подключаем middleware для них. const createTestStore = (preloadedState) => { return configureStore({ reducer: { // Подключаем редьюсер нашего API. Это важно! [usersApi.reducerPath]: usersApi.reducer, }, preloadedState, // Подгружаем нужное нам состояние // Мы можем не подключать middleware для тестов, так как запросы мы имитируем данными middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(usersApi.middleware), }) } describe('UsersList Component (with Store)', () => { it('отображает список пользователей через мок store', () => { // 1. Arrange: Создаем store с заранее заполненными данными для RTK Query. // Структура состояния повторяет структуру из DevTools. const mockUsers = [{ id: 1, name: 'Максим', email: 'max@example.com' }] const store = createTestStore({ [usersApi.reducerPath]: { queries: { // Ключ 'getUsers(undefined)' это хэш запроса. В реальном приложении он может быть другим. // Самый надежный способ, это посмотреть в DevTools. 'getUsers(undefined)': { status: 'fulfilled', data: mockUsers, }, }, mutations: {}, provided: {}, subscriptions: {}, config: { // ... конфигурация }, }, }) // 2. Act: Рендерим компонент в обертке с Provider render( <Provider store={store}> <UsersList /> </Provider> ) // 3. Assert expect(screen.getByTestId('user-1')).toBeInTheDocument() }) })
Этот подход более продвинутый, но и более сложный. Он требует глубокого понимания внутренней структуры состояния RTK Query. В 90% случаев мокания хука, как в первом примере, будет достаточно.
Практические задачи для закрепления
Чтобы материал точно отложился в голове, выполни следующие задания:
-
Базовое тестирование. Возьми компонент счетчика, который мы создавали на 7-м уроке. Напиши для него тесты, используя мок store. Проверь, что начальное значение отображается правильно и что при нажатии на кнопки «+» и «-» вызывается действие
dispatchс правильными экшенами (для проверки диспатча можно использоватьjest.fn()и посмотреть, с какими аргументами она была вызвана). -
Тестирование всех состояний RTK Query. Для компонента
UsersListдопиши тесты, которые проверяют:-
Состояние загрузки (
isLoading: true). Убедись, что в этом случае не отображается список и не показывается ошибка. -
Состояние ошибки. Убедись, что в этом случае не отображается список и индикатор загрузки.
-
Успешное состояние. Убедись, что отображается правильное количество пользователей и их данные (имя, email).
-
-
Интеграционный тест. Создай небольшой компонент, который использует и данные из RTK Query (список пользователей), и данные из обычного слайса (например, выбранный фильтр). Напиши тест, который проверяет, что при изменении фильтра в store, компонент перерендеривается и показывает отфильтрованный список. Это продвинутая задача, которая потребует создания комплексного мокового store.
Итоги по 17-му уроку
Мы разобрали два основных подхода, мокание отдельных хуков и создание мокового стора с предзаполненным состоянием. Главный вывод такой, что не нужно бояться тестировать такие компоненты. Инструменты, которые мы используем (Jest, React Testing Library, Redux Toolkit) отлично дружат друг с другом и предоставляют все необходимое для написания надежных и понятных тестов.
Хорошо протестированное приложение это не только стабильность, но уверенность в том, что любое изменение не сломает уже существующую функциональность.
В следующем уроке мы отойдем от написания кода и посвятим время отладке. Мы детально разберем Redux DevTools Extension и научимся путешествовать во времени по состоянию нашего приложения, чтобы находить и исправлять баги с невероятной скоростью.
Полный курс с уроками по Redux и Redux Toolkit для начинающих
Поддержка автора осуществляется с помощью специальной формы ниже, предоставленной сервисом «ЮMoney». Все платёжные операции выполняются на защищённой странице сервиса, что обеспечивает их корректность и полную безопасность.


