Skip to content

编写测试

你将学到什么?

  • 针对使用 Redux 的应用推荐的测试实践
  • 配置测试和测试环境搭建的一些示例

指导原则

针对 Redux 逻辑编写测试用例严格遵循 React Testing Libraray 的指导原则:

你的测试越接近软件的使用方式,这些测试越能给你信心 - Kent C. Dodds

因为你编写的多数 Redux 代码都是函数,并且多数又都是纯函数,所以它们很容易在没有 mock 的情况下进行测试的。然而,你需要考虑的是是否你的每一部分 Redux 代码都需要它专有的测试。 在大多数场景下,终端用户并不知道也一点都不在乎应用中是否使用了 Redux。因此,可以将 Redux 代码视为应用实现细节的一部分,多数情况下不并需要对 Redux 代码进行显式测试。

通常我们针对使用 Redux 的应用编写测试的建议是:

  • 倾向于编写整体性的集成测试。对于使用 Redux 的 React 应用,使用一个真实的 store 实例包裹被测试的组件来渲染一个 <Provider>。与被测试页面的交互应该使用真实的 Redux 逻辑,把 API 调用 mock 掉,这样应用代码就不需要改变,只需要断言 UI 是否被正确更新。
  • 如果 需要,可以对纯函数(例如复杂的 reducer 或 selector)进行基本的单元测试。然而,在很多情况下,这些只是被集成测试覆盖的实现细节。
  • 不要尝试 mock selector 函数或 React-Redux 的钩子函数! 对库的 imports 进行 mock 是脆弱的,并且也让你对自己代码的可用性没有信心。

INFO

针对我们推荐的集成测试风格的测试背景,请参考:

设置测试环境

测试运行器

Redux 可以使用任何测试运行器进行测试,因为它就是纯的 JavaScript。一个常见的选择是 Jest,一个广泛使用的测试运行器,它附带了 Create-React-App,并且被 Redux 库仓库使用。如果你正在使用 Vite 来构建你的项目,你可能正在使用 Vitest 作为你的测试运行器。

通常,你的测试运行器需要被配置为编译 JavaScript/TypeScript 语法。如果你要测试 UI 组件,你可能需要配置测试运行器使用 JSDOM 提供一个 mock DOM 环境。

本页中的示例将假设你正在使用 Jest,但是无论你使用什么测试运行器,相同的模式都适用。

查看这些资源以获取典型的测试运行器配置说明:

UI 和网络测试工具

Redux 团队建议使用 React Testing Library (RTL) 来测试连接到 Redux 的 React 组件。React Testing Library 是一个简单而完整的 React DOM 测试实用程序,它鼓励良好的测试实践。它使用 ReactDOM 的 render 函数和 react-dom/tests-utils 中的 act。(Testing Library 家族的工具还包括 许多其他流行框架的适配器。)

我们还建议使用 Mock Server Worker (MSW) 来 mock 网络请求,因为这意味着在编写测试时,应用程序逻辑不需要被更改或 mock。

Redux 连接的 React 组件逻辑的集成测试

我们建议通过集成测试来测试 Redux 连接的 React 组件,这些测试包括一切一起工作,带有断言,旨在验证当用户以给定方式与应用程序交互时,应用程序的行为符合预期。

示例代码

考虑以下 userSlice 切片,store 和 App 组件:

ts
// file: app/store.ts noEmit
import userReducer from '../features/users/userSlice'
export type RootState = {
  user: ReturnType<typeof userReducer>
}
// file: features/users/userAPI.ts noEmit
export const userAPI = {
  fetchUser: async () => ({
    data: 'john'
  })
}
// file: features/users/userSlice.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 selectUser = (state: RootState) => state.user.name
export const selectUserFetchStatus = (state: RootState) => state.user.status

export default userSlice.reducer
ts
// file: features/users/userSlice.ts noEmit
import { createSlice } from '@reduxjs/toolkit'
const userSlice = createSlice({
  name: 'user',
  initialState: {
    name: 'No user',
    status: 'idle'
  },
  reducers: {}
})
export default userSlice.reducer
// file: app/store.ts
import {
  combineReducers,
  configureStore,
  PreloadedState
} from '@reduxjs/toolkit'
import userReducer from '../features/users/userSlice'
// 创建根 reducer 来独立获取 RootState 类型
const rootReducer = combineReducers({
  user: userReducer
})
export function setupStore(preloadedState?: PreloadedState<RootState>) {
  return configureStore({
    reducer: rootReducer,
    preloadedState
  })
}
export type RootState = ReturnType<typeof rootReducer>
export type AppStore = ReturnType<typeof setupStore>
export type AppDispatch = AppStore['dispatch']
ts
// file: features/users/userSlice.ts noEmit
import { createSlice } from '@reduxjs/toolkit'
const userSlice = createSlice({
  name: 'user',
  initialState: {
    name: 'No user',
    status: 'idle'
  },
  reducers: {}
})
export default userSlice.reducer
// file: app/store.ts noEmit
import {
  combineReducers,
  configureStore,
  PreloadedState
} from '@reduxjs/toolkit'
import userReducer from '../features/users/userSlice'
const rootReducer = combineReducers({
  user: userReducer
})
export function setupStore(preloadedState?: PreloadedState<RootState>) {
  return configureStore({
    reducer: rootReducer,
    preloadedState
  })
}
export type RootState = ReturnType<typeof rootReducer>
export type AppStore = ReturnType<typeof setupStore>
export type AppDispatch = AppStore['dispatch']
// file: app/hooks.ts
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'
// 在整个应用程序中使用,而不是 `useDispatch` 和 `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
tsx
// file: features/users/userAPI.ts noEmit
export const userAPI = {
  fetchUser: async () => ({
    data: 'john'
  })
}
// file: app/store.ts noEmit
import {
  combineReducers,
  configureStore,
  PreloadedState
} from '@reduxjs/toolkit'
import userReducer from '../features/users/userSlice'
const rootReducer = combineReducers({
  user: userReducer
})
export function setupStore(preloadedState?: PreloadedState<RootState>) {
  return configureStore({
    reducer: rootReducer,
    preloadedState
  })
}
export type RootState = ReturnType<typeof rootReducer>
export type AppStore = ReturnType<typeof setupStore>
export type AppDispatch = AppStore['dispatch']
// file: app/hooks.ts noEmit
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'
// 在整个应用中使用,而不是使用 `useDispatch` 和 `useSelector`
export const useAppDispatch = () => useDispatch<AppDispatch>()
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
// file: features/users/userSlice.ts noEmit
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
})
const userSlice = createSlice({
  name: 'user',
  initialState: {
    name: 'No user',
    status: 'idle'
  },
  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 selectUser = (state: RootState) => state.user.name
export const selectUserFetchStatus = (state: RootState) => state.user.status
export default userSlice.reducer
// file: features/users/UserDisplay.tsx
import React from 'react'
import { useAppDispatch, useAppSelector } from '../../app/hooks'
import { fetchUser, selectUser, selectUserFetchStatus } from './userSlice'

export default function UserDisplay() {
  const dispatch = useAppDispatch()
  const user = useAppSelector(selectUser)
  const userFetchStatus = useAppSelector(selectUserFetchStatus)

  return (
    <div>
      {/* 展示当前的用户名 */}
      <div>{user}</div>
      {/* 当点击按钮时,分发一个 thunk action 来获取用户 */}
      <button onClick={() => dispatch(fetchUser())}>Fetch user</button>
      {/* 如果我们正在获取用户,就在 UI 上显示 */}
      {userFetchStatus === 'loading' && <div>Fetching user...</div>}
    </div>
  )
}

这个应用程序涉及到了 thunk、reducer 和 selector。所有这些都可以通过编写集成测试来测试,需要注意的是:

  • 在第一次加载应用程序时,应该没有用户 - 我们应该在屏幕上看到“没有用户”。
  • 点击名为“获取用户”的按钮后,我们希望它开始获取用户。我们应该在屏幕上看到“正在获取用户...”。
  • 一段时间后,应该收到用户。我们不再应该看到“正在获取用户...”,而应该根据我们的 API 的响应来显示预期的用户的名称。

通过关注上述整体,我们可以避免尽可能多地模拟应用程序。我们还将确信应用程序的关键行为在以我们期望的方式与用户交互时会按照我们期望的方式运行。

要测试组件,我们将其渲染到 DOM 中,并断言应用程序以我们期望用户使用应用程序的方式进行交互。

设置可重用的测试渲染函数

React Testing Library 的 render 函数接受 React 元素树并渲染这些组件。就像在真实的应用程序中一样,任何 Redux 连接的组件都需要 一个 React-Redux <Provider> 组件包裹在它们周围,并设置和提供一个真实的 Redux store。

此外,测试代码应为每个测试创建一个单独的 Redux store 实例,而不是重用相同的 store 实例并重置其状态。这确保了测试之间不会意外地泄漏值。

除了复制粘贴相同的 store 创建和 Provider 设置在每个测试中,我们可以使用 render 函数中的 wrapper 选项,并导出我们自己的自定义 renderWithProviders 函数,该函数创建一个新的 Redux store 并渲染一个 <Provider>,如 React Testing Library 的设置文档中所解释的那样。

自定义渲染函数会让我们:

  • 每次它被调用时创建一个新的 Redux store 实例,通过一个可选的 preloadedState 值来设置初始值
  • 或者传入一个已经创建好的 Redux store 实例
  • 将其他选项传递给 RTL 原始的 render 函数
  • 自动将要测试的组件包装在 <Provider store={store}>
  • 返回 store 实例,以便测试需要分发更多的 action 或检查状态

一个典型的自定义渲染函数设置可能如下所示:

tsx
// file: features/users/userSlice.ts noEmit
import { createSlice } from '@reduxjs/toolkit'
const userSlice = createSlice({
  name: 'user',
  initialState: {
    name: 'No user',
    status: 'idle'
  },
  reducers: {}
})
export default userSlice.reducer

// file: app/store.ts noEmit
import {
  combineReducers,
  configureStore,
  PreloadedState
} from '@reduxjs/toolkit'
import userReducer from '../features/users/userSlice'
const rootReducer = combineReducers({
  user: userReducer
})
export function setupStore(preloadedState?: PreloadedState<RootState>) {
  return configureStore({
    reducer: rootReducer,
    preloadedState
  })
}
export type RootState = ReturnType<typeof rootReducer>
export type AppStore = ReturnType<typeof setupStore>
// file: utils/test-utils.tsx
import React, { PropsWithChildren } from 'react'
import { render } from '@testing-library/react'
import type { RenderOptions } from '@testing-library/react'
import { configureStore } from '@reduxjs/toolkit'
import type { PreloadedState } from '@reduxjs/toolkit'
import { Provider } from 'react-redux'

import type { AppStore, RootState } from '../app/store'
// 作为一个基本的设置,导入你的相同的切片 reducers
import userReducer from '../features/users/userSlice'

// 这个 interface 扩展了 RTL 的默认 render 选项,同时允许用户指定其他选项,例如 initialState 和 store
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
  preloadedState?: PreloadedState<RootState>
  store?: AppStore
}

export function renderWithProviders(
  ui: React.ReactElement,
  {
    preloadedState = {},
    // 自动创建一个 store 实例,如果没有传入 store
    store = configureStore({ reducer: { user: userReducer }, preloadedState }),
    ...renderOptions
  }: ExtendedRenderOptions = {}
) {
  function Wrapper({ children }: PropsWithChildren<{}>): JSX.Element {
    return <Provider store={store}>{children}</Provider>
  }

  // 返回一个对象,其中包含 store 和所有的 RTL 查询函数
  return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }
}

在这个例子里,我们直接导入相同的切片 reducers,这些 reducers 与真实的应用程序一起使用来创建 store。创建一个可重用的 setupStore 函数可能会有帮助,该函数使用正确的选项和配置来执行实际的 store 创建,并在自定义渲染函数中使用它。

ts
// file: features/users/userSlice.ts noEmit
import { createSlice } from '@reduxjs/toolkit'
const userSlice = createSlice({
  name: 'user',
  initialState: {
    name: 'No user',
    status: 'idle'
  },
  reducers: {}
})
export default userSlice.reducer
// file: app/store.ts
import { combineReducers, configureStore } from '@reduxjs/toolkit'
import type { PreloadedState } from '@reduxjs/toolkit'

import userReducer from '../features/users/userSlice'

// 创建根 reducer,以便我们可以提取 RootState 类型
const rootReducer = combineReducers({
  user: userReducer
})

export const setupStore = (preloadedState?: PreloadedState<RootState>) => {
  return configureStore({
    reducer: rootReducer,
    preloadedState
  })
}

export type RootState = ReturnType<typeof rootReducer>
export type AppStore = ReturnType<typeof setupStore>
export type AppDispatch = AppStore['dispatch']

然后,使用 setupStore 函数替代在测试工具文件中再次调用 configureStore

tsx
// file: features/users/userSlice.ts noEmit
import { createSlice } from '@reduxjs/toolkit'
const userSlice = createSlice({
  name: 'user',
  initialState: {
    name: 'No user',
    status: 'idle'
  },
  reducers: {}
})
export default userSlice.reducer
// file: app/store.ts noEmit
import { combineReducers, configureStore } from '@reduxjs/toolkit'
import type { PreloadedState } from '@reduxjs/toolkit'

import userReducer from '../features/users/userSlice'

const rootReducer = combineReducers({
  user: userReducer
})

export const setupStore = (preloadedState?: PreloadedState<RootState>) => {
  return configureStore({
    reducer: rootReducer,
    preloadedState
  })
}

export type RootState = ReturnType<typeof rootReducer>
export type AppStore = ReturnType<typeof setupStore>
export type AppDispatch = AppStore['dispatch']
// file: utils/test-utils.tsx
import React, { PropsWithChildren } from 'react'
import { render } from '@testing-library/react'
import type { RenderOptions } from '@testing-library/react'
import { configureStore } from '@reduxjs/toolkit'
import type { PreloadedState } from '@reduxjs/toolkit'
import { Provider } from 'react-redux'

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

// 这个 interface 扩展了 RTL 的默认 render 选项,同时允许用户指定其他选项,例如 initialState 和 store
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
  preloadedState?: PreloadedState<RootState>
  store?: AppStore
}

export function renderWithProviders(
  ui: React.ReactElement,
  {
    preloadedState = {},
    // 自动创建一个 store 实例,如果没有传入 store
    store = setupStore(preloadedState),
    ...renderOptions
  }: ExtendedRenderOptions = {}
) {
  function Wrapper({ children }: PropsWithChildren<{}>): JSX.Element {
    return <Provider store={store}>{children}</Provider>
  }
  return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }
}

使用组件编写集成测试

实际的测试文件应该使用自定义的 render 函数来渲染我们的 Redux 连接的组件。如果我们测试的代码涉及到网络请求,我们也应该配置 MSW 来使用适当的测试数据来模拟预期的请求。

tsx
// file: features/users/userSlice.ts noEmit
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
import type { RootState } from '../../app/store'
export const fetchUser = createAsyncThunk('user/fetchUser', async () => {})
const userSlice = createSlice({
  name: 'user',
  initialState: {
    name: 'No user',
    status: 'idle'
  },
  reducers: {}
})
export const selectUser = (state: RootState) => state.user.name
export const selectUserFetchStatus = (state: RootState) => state.user.status
export default userSlice.reducer
// file: app/store.ts noEmit
import { combineReducers, configureStore } from '@reduxjs/toolkit'
import type { PreloadedState } from '@reduxjs/toolkit'

import userReducer from '../features/users/userSlice'

const rootReducer = combineReducers({
  user: userReducer
})

export const setupStore = (preloadedState?: PreloadedState<RootState>) => {
  return configureStore({
    reducer: rootReducer,
    preloadedState
  })
}

export type RootState = ReturnType<typeof rootReducer>
export type AppStore = ReturnType<typeof setupStore>
export type AppDispatch = AppStore['dispatch']
// file: utils/test-utils.tsx noEmit
import React, { PropsWithChildren } from 'react'
import { render } from '@testing-library/react'
import type { RenderOptions } from '@testing-library/react'
import { configureStore } from '@reduxjs/toolkit'
import type { PreloadedState } from '@reduxjs/toolkit'
import { Provider } from 'react-redux'

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

interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
  preloadedState?: PreloadedState<RootState>
  store?: AppStore
}

export function renderWithProviders(
  ui: React.ReactElement,
  {
    preloadedState = {},
    store = setupStore(preloadedState),
    ...renderOptions
  }: ExtendedRenderOptions = {}
) {
  function Wrapper({ children }: PropsWithChildren<{}>): JSX.Element {
    return <Provider store={store}>{children}</Provider>
  }
  return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }
}
// file: app/hooks.tsx noEmit
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'
import type { RootState, AppDispatch } from './store'
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector
// file: features/users/UserDisplay.tsx noEmit
import React from 'react'
import { useAppDispatch, useAppSelector } from '../../app/hooks'
import { fetchUser, selectUser, selectUserFetchStatus } from './userSlice'

export default function UserDisplay() {
  const dispatch = useAppDispatch()
  const user = useAppSelector(selectUser)
  const userFetchStatus = useAppSelector(selectUserFetchStatus)

  return (
    <div>
      {/*展示当前用户名 */}
      <div>{user}</div>
      {/* 当按钮点击时,触发一个 thunk action 去获取用户信息 */}
      <button onClick={() => dispatch(fetchUser())}>Fetch user</button>
      {/* 如果正在获取用户信息,展示获取状态 */}
      {userFetchStatus === 'loading' && <div>Fetching user...</div>}
    </div>
  )
}
// file: features/users/tests/UserDisplay.test.tsx
import React from 'react'
import { rest } from 'msw'
import { setupServer } from 'msw/node'
import { fireEvent, screen } from '@testing-library/react'
// 我们使用自定义的 render 函数,而不是 RTL 的 render
import { renderWithProviders } from '../../../utils/test-utils'
import UserDisplay from '../UserDisplay'

// 我们使用 msw 来拦截测试期间的网络请求,并在 150ms 后返回响应 'John Smith'
// 当收到对 `/api/user` 端点的 get 请求时
export const handlers = [
  rest.get('/api/user', (req, res, ctx) => {
    return res(ctx.json('John Smith'), ctx.delay(150))
  })
]

const server = setupServer(...handlers)

// 在所有测试开始之前启用 API mock
beforeAll(() => server.listen())

// 在每个测试之后关闭 API mock
afterEach(() => server.resetHandlers())

// 所有测试结束时关闭 API mock
afterAll(() => server.close())

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

  // 初始时,应该展示 'No user',并且不在获取用户信息
  expect(screen.getByText(/no user/i)).toBeInTheDocument()
  expect(screen.queryByText(/Fetching user\.\.\./i)).not.toBeInTheDocument()

  // 点击 'Fetch user' 按钮后,应该展示 'Fetching user...'
  fireEvent.click(screen.getByRole('button', { name: /Fetch user/i }))
  expect(screen.getByText(/no user/i)).toBeInTheDocument()

  // 在一段时间后,应该收到用户信息
  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 store 中。使用自定义的 render 函数,有几种不同的方法可以做到这一点。

其中一种方法是在自定义的 render 函数中传递一个 preloadedState 参数:

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

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

另外一种方法是首先创建一个自定义的 Redux store,然后分发一些 action 来构建所需的状态,然后将该特定的 store 实例传入:

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

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

你还可以从自定义渲染函数返回的对象中提取 store,并在测试的一部分中分发更多的 action。

单个函数的单元测试

我们建议使用集成测试,因为它们可以一起测试所有的 Redux 逻辑,但是你可能有时也想为单个函数编写单元测试。

Reducers

Reducers 是纯函数,它们返回在将 action 应用于先前的状态后的新状态。在大多数情况下,reducer 是不需要显式测试的实现细节。但是,如果你的 reducer 包含特别复杂的逻辑,你希望有单元测试的信心,那么 reducer 可以很容易地进行测试。

因为 reducers 是纯函数,所以测试它们应该很简单。调用 reducer 时,使用特定的输入 stateaction,并断言结果状态与预期匹配。

示例

ts
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

可以这样写测试:

ts
// file: todosSlice.ts noEmit
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

// file: todosSlice.test.ts

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

test('should return the initial state', () => {
  expect(reducer(undefined, { type: undefined })).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

Selectors 也通常是纯函数,因此可以使用与 reducer 相同的基本方法进行测试:设置初始值,使用这些输入调用选择器函数,并断言结果与预期输出匹配。

然而,由于大多数选择器都被记忆化以记住它们的最后一个输入,因此在测试中,你可能需要注意选择器在哪里使用时,它返回缓存值而不是生成新值的情况。

Action Creators & Thunks

在 Redux 中,action creators 是返回 plain objects 的函数。我们的建议是不要手动编写 action creators,而是让它们由 createSlice 自动生成,或者通过 @reduxjs/toolkit 中的 createAction 创建。因此,你不需要测试 action creators(Redux Toolkit 维护者已经为你做了这个!)。

action creators 的返回值被认为是应用程序内部的实现细节,当遵循集成测试风格时,不需要显式测试。

同样地,对于使用 Redux Thunk, 我们的建议是不要手动编写它们,而是使用 @reduxjs/toolkit 中的 createAsyncThunk。thunk 会根据 thunk 生命周期为你自动处理分发适当的 pendingfulfilledrejected action 类型。

我们认为 thunk 行为是应用程序的实现细节,建议通过测试使用它的组件(或整个应用程序)来覆盖它,而不是在隔离测试中测试 thunk。

我们推荐的是使用 mswmiragejsjest-fetch-mock, fetch-mock 等工具在 fetch/xhr 级别上模拟异步请求。通过在这个级别上模拟请求,thunk 逻辑中的任何更改都不需要在测试中进行更改 - thunk 仍然尝试进行“真实”异步请求,它只是被拦截了。请参阅 "Integration Test" example 以查看测试组件的示例,该组件内部包含了 thunk 的行为。

INFO

如果你倾向于或者被要求为 action creators 或 thunks 编写单元测试,请参阅 Redux Toolkit 用于 createActioncreateAsyncThunk 的测试。

Middleware

Middleware 函数包装了 Redux 中的 dispatch 调用,因此为了测试这个修改后的行为,我们需要模拟 dispatch 调用的行为。

示例

首先,我们需要一个中间件函数。这与真正的 redux-thunk 类似。

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

    return next(action)
  }

我们需要创建假的 getStatedispatchnext 函数。我们使用 jest.fn() 来创建 stubs,但是在其他测试框架中,你可能会使用 Sinon

执行函数以与 Redux 相同的方式运行我们的中间件。

js
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 是否在正确的时间调用了 getStatedispatchnext 函数。

js
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 函数,以使用不同的 getStatenext 的 mock 实现。

更多信息

  • React Testing Library: React Testing Library 是一个轻量级的解决方案,用于测试 React 组件。它在 react-dom 和 react-dom/test-utils 的基础上提供了轻量级的工具函数,以鼓励更好的测试实践。它的主要指导原则是:“测试越像软件的使用方式,它们就越能给你提供信心。”
  • React Test Utils:ReactTestUtils 使得在你选择的测试框架中测试 React 组件变得容易。React Testing Library 使用 React Test Utils 导出的 act 函数。
  • Blogged Answers: The Evolution of Redux Testing Approaches: Mark Erikson 的想法,关于 Redux 测试是如何从“隔离”到“集成”演变的。
  • Testing Implementation details:Kent C. Dodds 的博客文章,解释了为什么他建议避免测试实现细节。

Redux中文文档. Email: support@redux.org.cn