Перейти к основному содержимому
Неофициальный Бета-перевод

Эта страница переведена PageTurner AI (бета). Не одобрена официально проектом. Нашли ошибку? Сообщить о проблеме →

Написание тестов

Что вы узнаете
  • Рекомендуемые практики тестирования приложений с использованием Redux
  • Примеры настройки и конфигурации тестов

Основные принципы

Принципы тестирования логики Redux тесно следуют подходу React Testing Library:

Чем больше ваши тесты напоминают реальное использование вашего ПО, тем больше уверенности они могут вам дать. - Kent C. Dodds

Поскольку большая часть кода Redux представляет собой функции, причём многие из них чистые (pure), их легко тестировать без моков. Однако стоит задуматься, нуждается ли каждая часть Redux-кода в отдельных тестах. В большинстве сценариев конечный пользователь не знает и не заботится о том, используется ли в приложении Redux. Поэтому Redux-код можно рассматривать как деталь реализации приложения, которая во многих случаях не требует явного тестирования.

Наши общие рекомендации по тестированию приложений с Redux:

  • Предпочитайте интеграционные тесты с совместной работой всех компонентов. Для React-приложения с Redux отрисуйте <Provider> с реальным хранилищем, оборачивающим тестируемые компоненты. Взаимодействуйте со страницей, используя настоящую Redux-логику (с замоканными API-вызовами, чтобы не менять код приложения), и проверяйте корректность обновлений UI.

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

  • Не пытайтесь мокать функции-селекторы или хуки React-Redux! Моки импортов из библиотек ненадёжны и не дают уверенности в работоспособности вашего кода.

Информация

Для понимания причин рекомендации интеграционных тестов см.:

Настройка тестового окружения

Тестовые раннеры (Test Runners)

Redux можно тестировать с любым тестовым раннером, так как это обычный JavaScript. Распространённый выбор — Vitest (используется в репозиториях Redux), хотя Jest всё ещё широко применяется.

Обычно тестовый раннер нужно настроить для компиляции JavaScript/TypeScript. Если вы тестируете UI-компоненты без браузера, потребуется настроить тестовый раннер для использования JSDOM для эмуляции DOM-окружения.

Примеры ниже используют Vitest, но подходы работают для любого раннера.

Инструкции по типичной настройке:

Инструменты для тестирования UI и сетевых взаимодействий

Команда Redux рекомендует Vitest Browser Mode или React Testing Library (RTL) для тестирования React-компонентов с Redux.

React Testing Library — простой и полнофункциональный инструмент для тестирования React DOM, поощряющий лучшие практики. Он использует render из ReactDOM и act из react-dom/tests-utils. (Семейство инструментов Testing Library также включает адаптеры для многих других популярных фреймворков.)

Vitest Browser Mode запускает интеграционные тесты в реальном браузере, исключая необходимость в "моковом" DOM-окружении (и позволяя визуально проверять регрессии). Для React потребуется vitest-browser-react с render-утилитой, аналогичной RTL.

Мы также рекомендуем Mock Service Worker (MSW) для мокирования сетевых запросов, поскольку это позволяет не изменять логику приложения при написании тестов.

Интеграционное тестирование подключённых компонентов и логики Redux

Наша рекомендация для тестирования React-компонентов с Redux — интеграционные тесты, охватывающие всю систему, где проверяется поведение приложения при взаимодействии пользователя с интерфейсом.

Пример кода приложения

Рассмотрим срез userSlice, хранилище и компонент App:

features/users/usersSlice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import { userAPI } from './userAPI'
import type { RootState } from '../../app/store'

export const fetchUser = createAsyncThunk('user/fetchUser', async () => {
const response = await userAPI.fetchUser()
return response.data
})

interface UserState {
name: string
status: 'idle' | 'loading' | 'complete'
}

const initialState: UserState = {
name: 'No user',
status: 'idle'
}

const userSlice = createSlice({
name: 'user',
initialState,
reducers: {},
extraReducers: builder => {
builder.addCase(fetchUser.pending, (state, action) => {
state.status = 'loading'
})
builder.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'complete'
state.name = action.payload
})
}
})

export const selectUserName = (state: RootState) => state.user.name
export const selectUserFetchStatus = (state: RootState) => state.user.status

export default userSlice.reducer
app/store.ts
import { combineReducers, configureStore } from '@reduxjs/toolkit'
import userReducer from '../features/users/userSlice'
// Create the root reducer independently to obtain the RootState and PreloadedState type
const rootReducer = combineReducers({
user: userReducer
})
export function setupStore(preloadedState?: PreloadedState) {
return configureStore({
reducer: rootReducer,
preloadedState
})
}
export type PreloadedState = Parameters<typeof rootReducer>[0]
export type RootState = ReturnType<typeof rootReducer>
export type AppStore = ReturnType<typeof setupStore>
export type AppDispatch = AppStore['dispatch']
app/hooks.ts
import { useDispatch, useSelector } from 'react-redux'
import type { AppDispatch, RootState } from './store'

// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
export const useAppSelector = useSelector.withTypes<RootState>()
features/users/UserDisplay.tsx
import React from 'react'
import { useAppDispatch, useAppSelector } from '../../app/hooks'
import { fetchUser, selectUserName, selectUserFetchStatus } from './userSlice'

export default function UserDisplay() {
const dispatch = useAppDispatch()
const userName = useAppSelector(selectUserName)
const userFetchStatus = useAppSelector(selectUserFetchStatus)

return (
<div>
{/* Display the current user name */}
<div>{userName}</div>
{/* On button click, dispatch a thunk action to fetch a user */}
<button onClick={() => dispatch(fetchUser())}>Fetch user</button>
{/* At any point if we're fetching a user, display that on the UI */}
{userFetchStatus === 'loading' && <div>Fetching user...</div>}
</div>
)
}

Это приложение включает санки, редюсеры и селекторы. Всё можно протестировать через интеграционные тесты, учитывая:

  • При первоначальной загрузке пользователя нет — должен отображаться текст 'No user'.

  • После клика по кнопке 'Fetch user' начинается загрузка — должен появиться текст 'Fetching user...'.

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

Фокусируясь на этих сценариях, мы минимизируем мокирование и убеждаемся, что ключевое поведение приложения работает как ожидается.

Для тестирования компонент render-ится в DOM, после чего проверяется реакция на взаимодействия в соответствии с ожидаемым пользовательским поведением.

Настройка переиспользуемой функции для тестового рендеринга

Функция render из React Testing Library принимает дерево React-элементов. Как и в реальном приложении, компоненты с Redux требуют обёртки в <Provider> из React-Redux с реальным хранилищем.

Каждый тест должен использовать отдельный экземпляр хранилища Redux вместо сброса состояния общего экземпляра. Это предотвращает утечки данных между тестами.

Вместо копирования и вставки одной и той же настройки создания хранилища и Provider в каждом тесте, мы можем использовать параметр wrapper функции render и экспортировать нашу собственную кастомную функцию renderWithProviders, которая создает новое хранилище Redux и рендерит <Provider>, как описано в документации React Testing Library.

Кастомная функция рендеринга должна позволять:

  • Создавать новый экземпляр хранилища при каждом вызове с опциональным начальным состоянием preloadedState

  • Также можно передать уже созданный экземпляр хранилища Redux

  • Передавать дополнительные опции в оригинальную функцию render из RTL

  • Автоматически оборачивать тестируемый компонент в <Provider store={store}>

  • Возвращать экземпляр хранилища на случай, если тесту потребуется диспатчить дополнительные экшены или проверить состояние

Для удобства также настроим экземпляр userEvent.

Типичная настройка пользовательской функции рендеринга может выглядеть так:

utils/test-utils.tsx
import React, { PropsWithChildren } from 'react'
import { render } from '@testing-library/react'
import type { RenderOptions } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { Provider } from 'react-redux'

import type { AppStore, RootState, PreloadedState } from '../app/store'
import { setupStore } from '../app/store'

// This type interface extends the default options for render from RTL, as well
// as allows the user to specify other things such as preloadedState, store.
interface ExtendedRenderOptions
extends Omit<RenderOptions, 'queries' | 'wrapper'> {
preloadedState?: PreloadedState
store?: AppStore
}

export function renderWithProviders(
ui: React.ReactElement,
extendedRenderOptions: ExtendedRenderOptions = {}
) {
const {
preloadedState = {},
// Automatically create a store instance if no store was passed in
store = setupStore(preloadedState),
...renderOptions
} = extendedRenderOptions

const Wrapper = ({ children }: PropsWithChildren) => (
<Provider store={store}>{children}</Provider>
)

// Return an object with the store, user, and all of RTL's query functions
return {
store,
user: userEvent.setup(),
...render(ui, { wrapper: Wrapper, ...renderOptions })
}
}

Написание интеграционных тестов для компонентов

Фактические тестовые файлы должны использовать пользовательскую функцию render для рендеринга компонентов, подключенных к Redux. Если тестируемый код подразумевает выполнение сетевых запросов, следует также настроить MSW для мокирования ожидаемых запросов с соответствующими тестовыми данными.

features/users/tests/UserDisplay.test.tsx
import React from 'react'
import { beforeAll, afterEach, afterAll, test, expect } from 'vitest'
import { http, HttpResponse, delay } from 'msw'
import { setupServer } from 'msw/node'
import { screen } from '@testing-library/react'
// We're using our own custom render function and not RTL's render.
import { renderWithProviders } from '../../../utils/test-utils'
import UserDisplay from '../UserDisplay'

// We use msw to intercept the network request during the test,
// and return the response 'John Smith' after 150ms
// when receiving a get request to the `/api/user` endpoint
export const handlers = [
http.get('/api/user', async () => {
await delay(150)
return HttpResponse.json('John Smith')
})
]

const server = setupServer(...handlers)

// Enable API mocking before tests.
beforeAll(() => server.listen())

// Reset any runtime request handlers we may add during the tests.
afterEach(() => server.resetHandlers())

// Disable API mocking after the tests are done.
afterAll(() => server.close())

test('fetches & receives a user after clicking the fetch user button', async () => {
const { user } = renderWithProviders(<UserDisplay />)

// should show no user initially, and not be fetching a user
expect(screen.getByText(/no user/i)).toBeInTheDocument()
expect(screen.queryByText(/Fetching user\.\.\./i)).not.toBeInTheDocument()

// after clicking the 'Fetch user' button, it should now show that it is fetching the user
await user.click(screen.getByRole('button', { name: /Fetch user/i }))
expect(screen.queryByText(/no user/i)).not.toBeInTheDocument()
expect(screen.getByText(/Fetching user\.\.\./i)).toBeInTheDocument()

// after some time, the user should be received
expect(await screen.findByText(/John Smith/i)).toBeInTheDocument()
expect(screen.queryByText(/no user/i)).not.toBeInTheDocument()
expect(screen.queryByText(/Fetching user\.\.\./i)).not.toBeInTheDocument()
})

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

Подготовка начального состояния для тестов

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

Один из вариантов — передать аргумент preloadedState в пользовательскую функцию рендеринга:

TodoList.test.tsx
test('Uses preloaded state to render', () => {
const initialTodos = [{ id: 5, text: 'Buy Milk', completed: false }]

const { getByText } = renderWithProviders(<TodoList />, {
preloadedState: {
todos: initialTodos
}
})
})

Другой вариант — сначала создать пользовательское хранилище Redux и диспатчить несколько экшенов для формирования желаемого состояния, а затем передать этот конкретный экземпляр хранилища:

TodoList.test.tsx
test('Sets up initial state state with actions', () => {
const store = setupStore()
store.dispatch(todoAdded('Buy milk'))

const { getByText } = renderWithProviders(<TodoList />, { store })
})

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

Vitest Browser Mode

Настройка переиспользуемой функции для тестового рендеринга

Как и RTL, Vitest Browser Mode предоставляет render для рендеринга компонентов в реальном браузере. Для React-Redux приложения нужно добавить <Provider> в дерево компонентов.

Создадим кастомную функцию render, которая оборачивает компонент в <Provider> и настраивает хранилище Redux, аналогично RTL.

utils/test-utils.tsx
import React, { PropsWithChildren } from 'react'
import { render } from 'vitest-browser-react'
import type { RenderOptions } from 'vitest-browser-react'
import { Provider } from 'react-redux'

import type { AppStore, RootState, PreloadedState } from '../app/store'
import { setupStore } from '../app/store'

// This type interface extends the default options for render from vitest-browser-react, as well
// as allows the user to specify other things such as preloadedState, store.
interface ExtendedRenderOptions extends Omit<RenderOptions, 'wrapper'> {
preloadedState?: PreloadedState
store?: AppStore
}

export async function renderWithProviders(
ui: React.ReactElement,
extendedRenderOptions: ExtendedRenderOptions = {}
) {
const {
preloadedState = {},
// Automatically create a store instance if no store was passed in
store = setupStore(preloadedState),
...renderOptions
} = extendedRenderOptions

const Wrapper = ({ children }: PropsWithChildren) => (
<Provider store={store}>{children}</Provider>
)

const screen = await render(ui, { wrapper: Wrapper, ...renderOptions })
// Return an object with the store, and the result of rendering
return {
store,
...screen
}
}

Для удобства прикрепим её к page в файле настройки:

setup.ts
import { renderWithProviders } from './utils/test-utils'
import { page } from 'vitest/browser'

page.extend({ renderWithProviders })

declare module 'vitest/browser' {
interface BrowserPage {
renderWithProviders: typeof renderWithProviders
}
}

Используем в тестах аналогично RTL:

features/users/tests/UserDisplay.test.tsx
import React from 'react'
import { test, expect } from 'vitest'
import { page } from 'vitest/browser'
import UserDisplay from '../UserDisplay'

test('fetches & receives a user after clicking the fetch user button', async () => {
const { store, ...screen } = await page.renderWithProviders(<UserDisplay />)

const noUserText = screen.getByText(/no user/i)
const fetchingUserText = screen.getByText(/Fetching user\.\.\./i)
const userNameText = screen.getByText(/John Smith/i)

// should show no user initially, and not be fetching a user
await expect.element(noUserText).toBeInTheDocument()
await expect.element(fetchingUserText).not.toBeInTheDocument()

// after clicking the 'Fetch user' button, it should now show that it is fetching the user
await screen.getByRole('button', { name: /fetch user/i }).click()
await expect.element(noUserText).not.toBeInTheDocument()
await expect.element(fetchingUserText).toBeInTheDocument()

// after some time, the user should be received
await expect.element(userNameText).toBeInTheDocument()
await expect.element(noUserText).not.toBeInTheDocument()
await expect.element(fetchingUserText).not.toBeInTheDocument()
})

Модульное тестирование отдельных функций

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

Редюсеры (Reducers)

Редюсеры — это чистые функции, которые возвращают новое состояние после применения экшена к предыдущему состоянию. В большинстве случаев редюсер является деталью реализации и не требует явного тестирования. Однако если ваш редюсер содержит особенно сложную логику и вы хотите убедиться в её корректности с помощью модульных тестов, редюсеры можно легко протестировать.

Поскольку редюсеры — чистые функции, их тестирование просто: вызываем редюсер с входными state и action, затем проверяем соответствие выходного состояния ожиданиям.

Пример

import { createSlice, PayloadAction } from '@reduxjs/toolkit'

export type Todo = {
id: number
text: string
completed: boolean
}

const initialState: Todo[] = [{ text: 'Use Redux', completed: false, id: 0 }]

const todosSlice = createSlice({
name: 'todos',
initialState,
reducers: {
todoAdded(state, action: PayloadAction<string>) {
state.push({
id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
completed: false,
text: action.payload
})
}
}
})

export const { todoAdded } = todosSlice.actions

export default todosSlice.reducer

можно протестировать следующим образом:

import { test, expect } from 'vitest'
import reducer, { todoAdded, Todo } from './todosSlice'

test('should return the initial state', () => {
expect(reducer(undefined, { type: 'unknown' })).toEqual([
{ text: 'Use Redux', completed: false, id: 0 }
])
})

test('should handle a todo being added to an empty list', () => {
const previousState: Todo[] = []

expect(reducer(previousState, todoAdded('Run the tests'))).toEqual([
{ text: 'Run the tests', completed: false, id: 0 }
])
})

test('should handle a todo being added to an existing list', () => {
const previousState: Todo[] = [
{ text: 'Run the tests', completed: true, id: 0 }
]

expect(reducer(previousState, todoAdded('Use Redux'))).toEqual([
{ text: 'Run the tests', completed: true, id: 0 },
{ text: 'Use Redux', completed: false, id: 1 }
])
})

Селекторы (Selectors)

Селекторы также обычно являются чистыми функциями, поэтому их можно тестировать тем же базовым подходом, что и редюсеры: установите начальное значение, вызовите функцию селектора с этими входными данными и проверьте, что результат соответствует ожидаемому.

Однако, поскольку большинство селекторов мемоизированы, чтобы запоминать свои последние входные данные, следует учитывать случаи, когда селектор возвращает кэшированное значение, хотя вы ожидали, что он сгенерирует новое, в зависимости от того, где он используется в тесте.

Генераторы экшенов (Action Creators) и Thunks

В Redux создатели действий (action creators) — это функции, возвращающие простые объекты. Мы рекомендуем не писать их вручную, а автоматически генерировать с помощью createSlice или создавать через createAction из @reduxjs/toolkit. Поэтому вам не нужно отдельно тестировать создателей действий (разработчики Redux Toolkit уже сделали это за вас!).

Возвращаемое значение создателей действий считается внутренней деталью реализации. При использовании интеграционного тестирования такие функции не требуют отдельных тестов.

Аналогично для санков (thunks) с Redux Thunk: мы рекомендуем не писать их вручную, а использовать createAsyncThunk из @reduxjs/toolkit. Санк автоматически отправляет действия pending, fulfilled и rejected в зависимости от своего жизненного цикла.

Поведение санков считается внутренней деталью приложения. Мы рекомендуем тестировать их через группы компонентов или всё приложение целиком, а не изолированно.

Рекомендуем мокать асинхронные запросы на уровне fetch/xhr с помощью инструментов вроде msw, miragejs, jest-fetch-mock или fetch-mock. При таком подходе логика санков не меняется — он по-прежнему пытается выполнить "настоящий" запрос, который перехватывается. Пример тестирования компонента, использующего санк, см. в разделе «Интеграционное тестирование».

Информация

Если вам необходимо писать модульные тесты для создателей действий или санков, изучите тесты Redux Toolkit для createAction и createAsyncThunk.

Middleware (Промежуточное ПО)

Middleware-функции оборачивают поведение вызовов dispatch в Redux. Для тестирования этого поведения нужно замокать вызов dispatch.

Пример

Сначала создадим middleware-функцию, аналогичную настоящему redux-thunk.

const thunkMiddleware =
({ dispatch, getState }) =>
next =>
action => {
if (typeof action === 'function') {
return action(dispatch, getState)
}

return next(action)
}

Нужно создать мок-функции getState, dispatch и next. Для этого используем jest.fn(), но в других фреймворках подойдёт Sinon.

Функция invoke запускает наш middleware так же, как это делает Redux.

const create = () => {
const store = {
getState: jest.fn(() => ({})),
dispatch: jest.fn()
}
const next = jest.fn()

const invoke = action => thunkMiddleware(store)(next)(action)

return { store, next, invoke }
}

Проверяем, что middleware вызывает getState, dispatch и next в нужный момент.

test('passes through non-function action', () => {
const { next, invoke } = create()
const action = { type: 'TEST' }
invoke(action)
expect(next).toHaveBeenCalledWith(action)
})

test('calls the function', () => {
const { invoke } = create()
const fn = jest.fn()
invoke(fn)
expect(fn).toHaveBeenCalled()
})

test('passes dispatch and getState', () => {
const { store, invoke } = create()
invoke((dispatch, getState) => {
dispatch('TEST DISPATCH')
getState()
})
expect(store.dispatch).toHaveBeenCalledWith('TEST DISPATCH')
expect(store.getState).toHaveBeenCalled()
})

Иногда потребуется модифицировать функцию create, чтобы использовать другие мок-реализации getState и next.

Дополнительные материалы

  • React Testing Library: Лёгкое решение для тестирования React-компонентов. Предоставляет утилиты поверх react-dom и react-dom/test-utils, поощряя лучшие практики тестирования. Ключевой принцип: «Чем больше тесты напоминают реальное использование, тем больше уверенности они дают».

  • Ответы в блоге: Эволюция подходов к тестированию Redux: Размышления Марка Эрикссона о том, как тестирование Redux эволюционировало от "изоляции" к "интеграции".

  • Тестирование деталей реализации: Пост в блоге Кента К. Доддса о том, почему он рекомендует избегать тестирования деталей реализации.