Writing Tests: recommended practices and setup for testing Redux apps">Writing Tests: recommended practices and setup for testing Redux apps">
跳至主要內容

撰寫測試

您將會學到什麼
  • 使用 Redux 測試應用程式的建議做法
  • 測試設定與建置範例

指導原則

測試 Redux 邏輯的指導原則緊密遵循 React Testing Library

測試越接近軟體的使用方式,就能提供越高的信心。 - Kent C. Dodds

由於您編寫的大部分 Redux 程式碼都是函式,其中許多是純粹的,因此它們很容易在不進行模擬的情況下進行測試。但是,您應該考慮 Redux 程式碼的每一部分是否需要專門的測試。在大部分情況下,最終使用者不知道也不關心應用程式中是否使用了 Redux。因此,在許多情況下,Redux 程式碼可以視為應用程式的實作細節,而不需要對 Redux 程式碼進行明確的測試。

我們對使用 Redux 測試應用程式的建議是

  • 優先撰寫整合測試,讓所有內容一起運作。對於使用 Redux 的 React 應用程式,請使用真實的儲存體執行個體包覆要測試的元件,來呈現 <Provider>。與所測試頁面的互動應使用真實的 Redux 邏輯,並模擬 API 呼叫,以便應用程式程式碼不必變更,並斷言使用者介面已適當地更新。
  • 如果需要,請對純粹函式(例如特別複雜的簡化器或選擇器)使用基本的單元測試。但是,在許多情況下,這些只是實作細節,可以用整合測試來涵蓋。
  • 不要嘗試模擬選擇器函式或 React-Redux 掛鉤!模擬程式庫中的匯入很脆弱,而且無法讓您確信實際的應用程式程式碼正在運作。
資訊

有關我們推薦整合式測試的原因的背景資訊,請參閱

設定測試環境

測試執行器

Redux 可使用任何測試執行器進行測試,因為它只是純 JavaScript。一個常見的選項是 Jest,這是 Create-React-App 附帶且 Redux 程式庫存放庫使用的廣泛測試執行器。如果您使用 Vite 建置專案,您可能會使用 Vitest 作為您的測試執行器。

通常,您的測試執行器需要設定為編譯 JavaScript/TypeScript 語法。如果您要測試 UI 元件,您可能需要設定測試執行器以使用 JSDOM 提供模擬 DOM 環境。

此頁面中的範例假設您使用 Jest,但無論您使用哪個測試執行器,相同的模式都適用。

參閱這些資源以取得典型的測試執行器設定說明

UI 和網路測試工具

Redux 團隊建議使用 React 測試程式庫 (RTL) 來測試連線到 Redux 的 React 元件。React 測試程式庫是一個簡單且完整的 React DOM 測試工具,鼓勵良好的測試實務。它使用 ReactDOM 的 render 函式和 react-dom/tests-utils 的 act。(測試程式庫系列工具也包括 許多其他熱門架構的轉接器。)

我們也建議使用 模擬服務工作者 (MSW) 來模擬網路要求,這表示您的應用程式邏輯在撰寫測試時不需要變更或模擬。

整合測試連接元件與 Redux 邏輯

我們建議透過整合測試來測試 Redux 連接的 React 元件,包含所有協同運作的內容,並透過斷言來驗證當使用者以特定方式與應用程式互動時,應用程式是否會按照預期運作。

範例應用程式程式碼

考慮以下的 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 type
const rootReducer = combineReducers({
user: userReducer
})
export function setupStore(preloadedState?: Partial<RootState>) {
return configureStore({
reducer: rootReducer,
preloadedState
})
}
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>
)
}

此應用程式包含 thunk、reducer 和 selector。所有這些都可以透過撰寫整合測試來測試,並考量以下事項

  • 在首次載入應用程式時,不應該有任何使用者 - 我們應該在畫面中看到「沒有使用者」。
  • 在按一下寫有「擷取使用者」的按鈕後,我們預期它會開始擷取使用者。我們應該在畫面中看到「正在擷取使用者...」顯示。
  • 經過一段時間後,應該會收到使用者。我們不應該再看到「正在擷取使用者...」,而應該會根據 API 的回應看到預期的使用者名稱。

撰寫我們的測試來整體專注於上述內容,我們可以盡可能避免模擬應用程式。我們也會確信應用程式的關鍵行為會在我們預期使用者使用應用程式的方式中執行我們預期的動作。

若要測試元件,我們會將其 render 到 DOM,並斷言應用程式會以我們預期使用者使用應用程式的方式回應互動。

設定可重複使用的測試渲染函式

React Testing Library 的 render 函式接受 React 元素樹,並渲染這些元件。就像在真實的應用程式中一樣,任何 Redux 連接的元件都需要 一個 React-Redux <Provider> 元件包覆它們,並設定並提供一個真實的 Redux 儲存體。

此外,測試程式碼應為每個測試建立獨立的 Redux 儲存體執行個體,而不是重複使用同一個儲存體執行個體並重設其狀態。這可確保不會有值意外地在測試之間外洩。

我們可以使用 render 函式的 wrapper 選項,並匯出我們自訂的 renderWithProviders 函式,建立新的 Redux 儲存體並呈現 <Provider>,而非在每個測試中複製貼上相同的儲存體建立和 Provider 設定,如 React Testing Library 的設定文件 中所述。

自訂呈現函式應允許我們

  • 每次呼叫時建立新的 Redux 儲存體執行個體,並具有可作為初始值使用的選用 preloadedState
  • 交替傳入已建立的 Redux 儲存體執行個體
  • 傳遞其他選項至 RTL 的原始 render 函式
  • 自動使用 <Provider store={store}> 包裝要測試的元件
  • 如果測試需要發送更多動作或檢查狀態,則傳回儲存體執行個體

典型的自訂呈現函式設定可能如下所示

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 { Provider } from 'react-redux'

import type { AppStore, RootState } from '../app/store'
import { setupStore } from '../app/store'
// As a basic setup, import your same slice reducers
import userReducer from '../features/users/userSlice'

// This type interface extends the default options for render from RTL, as well
// as allows the user to specify other things such as initialState, store.
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: Partial<RootState>
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 and all of RTL's query functions
return {
store,
...render(ui, { wrapper: Wrapper, ...renderOptions })
}
}

在此範例中,我們直接匯入實際應用程式用於建立儲存體的相同區塊縮減器。建立可重複使用的 setupStore 函式可能會有幫助,該函式使用正確的選項和組態進行實際的儲存體建立,並在自訂呈現函式中使用它。

app/store.ts
import { combineReducers, configureStore } from '@reduxjs/toolkit'

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

// Create the root reducer separately so we can extract the RootState type
const rootReducer = combineReducers({
user: userReducer
})

export const setupStore = (preloadedState?: Partial<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

import React, { PropsWithChildren } from 'react'
import { render } from '@testing-library/react'
import type { RenderOptions } from '@testing-library/react'
import { Provider } from 'react-redux'

import { setupStore } from '../app/store'
import type { AppStore, RootState } 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 initialState, store.
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: Partial<RootState>
store?: AppStore
}

export function renderWithProviders(
ui: React.ReactElement,
{
preloadedState = {},
// Automatically create a store instance if no store was passed in
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,以適當的測試資料模擬預期的要求。

features/users/tests/UserDisplay.test.tsx
import React from 'react'
import { http, HttpResponse, delay } from 'msw'
import { setupServer } from 'msw/node'
import { fireEvent, 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 () => {
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
fireEvent.click(screen.getByRole('button', { name: /Fetch user/i }))
expect(screen.getByText(/no 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,並在稍後發送更多動作作為測試的一部分。

個別函式的單元測試

雖然我們建議預設使用整合測試,因為它們會一起執行所有 Redux 邏輯,但有時您可能也想要為個別函式撰寫單元測試。

Reducer

Reducer 是純函式,會在將動作套用至前一個狀態後傳回新的狀態。在大部分情況下,Reducer 是不需要明確測試的實作細節。但是,如果您的 Reducer 包含您希望有單元測試信心的特別複雜邏輯,則可以輕鬆地測試 Reducer。

由於 Reducer 是純函式,因此測試它們應該是直接的。使用特定的輸入 stateaction 呼叫 Reducer,並斷言結果狀態符合預期。

範例

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 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 }
])
})

Selector

Selector 通常也是純函式,因此可以使用與 Reducer 相同的基本方法進行測試:設定初始值,使用那些輸入呼叫 Selector 函式,並斷言結果與預期的輸出相符。

但是,由於 大多數 Selector 會記憶其最後的輸入,因此您可能需要留意 Selector 在測試中使用的位置時傳回快取值,而您預期它會產生新的值。

動作建立器和非同步動作

在 Redux 中,動作建立器是回傳純物件的函式。我們的建議是不要手動撰寫動作建立器,而是讓 createSlice 自動產生,或透過 createAction@reduxjs/toolkit 建立。因此,你不應該覺得有必要單獨測試動作建立器(Redux Toolkit 維護人員已經為你完成這項工作了!)。

動作建立器的回傳值被視為應用程式中的實作細節,並且在遵循整合測試樣式時,不需要明確的測試。

類似地,對於使用 Redux Thunk 的 thunk,我們的建議是不要手動撰寫它們,而是使用 createAsyncThunk@reduxjs/toolkit 建立。thunk 會根據 thunk 的生命週期,為你派送適當的 pendingfulfilledrejected 動作類型。

我們認為 thunk 行為是應用程式的實作細節,建議透過測試使用它的元件群組(或整個應用程式)來涵蓋它,而不是孤立地測試 thunk。

我們的建議是使用 mswmiragejsjest-fetch-mockfetch-mock 或類似的工具,在 fetch/xhr 層級模擬非同步請求。透過在此層級模擬請求,thunk 邏輯在測試中都不需要變更,thunk 仍然會嘗試發出「真正的」非同步請求,只是它會被攔截。請參閱 「整合測試」範例,了解如何測試內部包含 thunk 行為的元件。

資訊

如果您偏好或需要為您的動作建立程式或 thunk 寫單元測試,請參閱 Redux Toolkit 用於 createActioncreateAsyncThunk 的測試。

中間件

中間件函式封裝 Redux 中 dispatch 呼叫的行為,因此為了測試這個修改後的行為,我們需要模擬 dispatch 呼叫的行為。

範例

首先,我們需要一個中間件函式。這類似於真正的 redux-thunk

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

return next(action)
}

我們需要建立假的 getStatedispatchnext 函式。我們使用 jest.fn() 建立存根,但使用其他測試架構時,您可能會使用 Sinon

呼叫函式以與 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 }
}

我們測試我們的中間件是否在正確的時間呼叫 getStatedispatchnext 函式。

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 的不同模擬實作。

更多資訊

  • React Testing Library:React Testing Library 是用於測試 React 元件的極輕量級解決方案。它在 react-dom 和 react-dom/test-utils 上提供輕量級的實用函式,以鼓勵更好的測試實務。其主要指導原則是:「您的測試越像您的軟體使用方式,它們就能給您越多的信心。」
  • 部落格解答:Redux 測試方法的演進:Mark Erikson 關於 Redux 測試如何從「隔離」演進到「整合」的想法。
  • 測試實作細節:Kent C. Dodds 的部落格文章,說明他為什麼建議避免測試實作細節。