メインコンテンツにスキップ

テストの記述

学習内容
  • Reduxを使用するアプリケーションのテストに関する推奨プラクティス
  • テストの設定とセットアップの例

指針となる原則

Reduxロジックのテストに関する指針となる原則は、React Testing Libraryの原則とほぼ同じです。

テストがソフトウェアの使用方法に似ていれば似ているほど、より大きな自信を持つことができます。 - ケント・C・ドッズ

記述するReduxコードのほとんどは関数であり、その多くは純関数であるため、モックを使用せずに簡単にテストできます。ただし、Reduxコードの各部分が独自の専用テストを必要とするかどうかを検討する必要があります。**ほとんどのシナリオでは、エンドユーザーはアプリケーション内でReduxが使用されているかどうかを知らず、気にもしません**。そのため、Reduxコードはアプリケーションの実装の詳細として扱うことができ、多くの状況ではReduxコードの明示的なテストは必要ありません。

Reduxを使用するアプリケーションのテストに関する一般的なアドバイスは次のとおりです。

  • **すべてが連携して動作する統合テストを優先して記述してください**。Reduxを使用するReactアプリケーションの場合、テスト対象のコンポーネントをラップする実際のストアインスタンスを使用して`<Provider>`をレンダリングします。テスト対象のページとのインタラクションでは、実際のReduxロジックを使用し、アプリケーションコードを変更する必要がないようにAPI呼び出しをモックアウトし、UIが適切に更新されることをアサートします。
  • 必要に応じて、特に複雑なReducerやセレクターなどの純関数に対して基本的な単体テストを使用します。ただし、多くの場合、これらは統合テストでカバーされる実装の詳細にすぎません。
  • **セレクター関数やReact-Reduxフックをモックしようとしないでください!**ライブラリからのインポートをモックすることは脆弱であり、実際のアプリケーションコードが機能しているという自信が得られません。
情報

統合スタイルのテストを推奨する理由の背景については、以下を参照してください。

テスト環境のセットアップ

テストランナー

**Reduxは、プレーンなJavaScriptであるため、どのテストランナーでもテストできます**。一般的なオプションの1つは、Create-React-Appに付属し、Reduxライブラリリポジトリで使用されている、広く使用されているテストランナーであるJestです。Viteを使用してプロジェクトをビルドしている場合は、Vitestをテストランナーとして使用している可能性があります。

通常、テストランナーはJavaScript/TypeScript構文をコンパイルするように設定する必要があります。UIコンポーネントをテストする場合は、モックDOM環境を提供するためにJSDOMを使用するようにテストランナーを設定する必要があるでしょう。

このページの例では、Jestを使用していることを前提としていますが、使用しているテストランナーに関係なく、同じパターンが適用されます。

一般的なテストランナーの設定手順については、これらのリソースを参照してください。

UIおよびネットワークテストツール

**Reduxチームは、Reduxに接続するReactコンポーネントをテストするために、React Testing Library(RTL)を使用することを推奨しています**。React Testing Libraryは、優れたテストプラクティスを促進する、シンプルで完全なReact DOMテストユーティリティです。ReactDOMの`render`関数と`react-dom/test-utils`の`act`を使用します。(Testing Libraryファミリのツールには、他の多くの人気のあるフレームワーク用のアダプターも含まれています。)

また、**ネットワークリクエストをモックするためにMock Service Worker(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、およびセレクターが含まれています。これらはすべて、次の点を考慮して統合テストを記述することでテストできます。

  • アプリケーションの初回ロード時には、まだユーザーが存在しないはずです。画面に「ユーザーなし」と表示されるはずです。
  • 「ユーザーを取得」と書かれたボタンをクリックすると、ユーザーの取得が開始されるはずです。画面に「ユーザーを取得中…」と表示されるはずです。
  • しばらくすると、ユーザーが受信されるはずです。「ユーザーを取得中…」は表示されなくなり、APIからのレスポンスに基づいて予期されるユーザーの名前が表示されるはずです。

上記を全体として焦点を当ててテストを記述することで、アプリケーションのできるだけ多くの部分をモックすることを回避できます。また、ユーザーがアプリケーションを使用するときに、アプリケーションの重要な動作が期待どおりに動作するという自信を持つことができます。

コンポーネントをテストするには、DOMに`render`し、アプリケーションが期待どおりにインタラクションに応答することをアサートします。

再利用可能なテストレンダリング関数の設定

React Testing Libraryの`render`関数は、React要素のツリーを受け取り、それらのコンポーネントをレンダリングします。実際のアプリケーションと同様に、Reduxに接続されたコンポーネントには、React-Redux `<Provider>`コンポーネントがラップされている必要があり、実際のReduxストアが設定され、提供されます。

さらに、**テストコードは、同じストアインスタンスを再利用してその状態をリセットするのではなく、テストごとに個別のReduxストアインスタンスを作成する必要があります**。これにより、テスト間で値が誤ってリークすることがなくなります。

すべてのテストで同じストアの作成と Provider のセットアップをコピーペーストする代わりに、render 関数の wrapper オプションを使用し、React Testing Libraryのセットアップドキュメントで説明されているように、新しいReduxストアを作成し、<Provider> をレンダリングする独自のカスタマイズされた renderWithProviders 関数をエクスポートできます。

カスタムレンダリング関数は、以下のことを可能にする必要があります。

  • 呼び出されるたびに、初期値に使用できるオプションの preloadedState 値を使用して、新しいReduxストアインスタンスを作成する
  • あるいは、既に作成された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']

次に、configureStore を再度呼び出す代わりに、テストユーティリティファイルで setupStore を使用します。

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ストアに特定の状態が既に存在している必要があります。カスタムレンダリング関数を使用すると、これを行うにはいくつかの方法があります。

1つの方法は、カスタムレンダリング関数に 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 }
])
})

セレクター

セレクターも一般的に純粋関数であるため、reducerと同じ基本的なアプローチを使用してテストできます。初期値を設定し、それらの入力でセレクター関数を呼び出し、結果が予期される出力と一致することをアサートします。

ただし、ほとんどのセレクターは最後の入力を記憶するようにメモ化されているため、テストで使用されている場所によっては、新しい値を生成することが期待されている場合にセレクターがキャッシュされた値を返している場合に注意する必要がある場合があります。

アクションクリエーターとサンク

Reduxでは、アクションクリエーターはプレーンオブジェクトを返す関数です。アクションクリエーターを手動で記述するのではなく、createSliceによって自動的に生成するか、@reduxjs/toolkitcreateActionを介して作成することをお勧めします。そのため、**アクションクリエーター自体をテストする必要はありません**(Redux Toolkitのメンテナーが既にあなたのためにそれを行っています!)。

アクションクリエーターの戻り値は、アプリケーション内の実装の詳細と見なされ、統合テストスタイルに従う場合は、明示的なテストは必要ありません。

同様に、Redux Thunkを使用するサンクの場合、手動で記述するのではなく、@reduxjs/toolkitcreateAsyncThunkを使用することをお勧めします。サンクは、サンクのライフサイクルに基づいて、適切な pendingfulfilled、および rejected アクションタイプのディスパッチを処理します。

サンクの動作はアプリケーションの実装の詳細と考えており、サンクを単独でテストするのではなく、それを使用するコンポーネントのグループ(またはアプリ全体)をテストすることでカバーすることをお勧めします。

mswmiragejsjest-fetch-mockfetch-mockなどのツールを使用して、fetch/xhr レベルで非同期リクエストをモックすることをお勧めします。このレベルでリクエストをモックすることにより、サンクロジックをテストで変更する必要はありません。サンクは引き続き「実際の」非同期リクエストを作成しようとしますが、インターセプトされるだけです。サンクの動作を内部的に含むコンポーネントのテスト例については、「統合テスト」の例を参照してください。

情報

アクションクリエーターまたはサンクの単体テストを作成することを希望する場合、またはそうする必要がある場合は、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)
}

偽の getStatedispatch、および next 関数を作成する必要があります。スタブを作成するために jest.fn() を使用しますが、他のテストフレームワークではSinonを使用する可能性があります。

invoke関数は、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 }
}

ミドルウェアが適切なタイミングで getStatedispatch、および 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()
})

場合によっては、getStatenext の異なるモック実装を使用するように create 関数を変更する必要があります。

詳細情報

  • React Testing Library: React Testing Libraryは、Reactコンポーネントをテストするための非常に軽量なソリューションです。react-domとreact-dom/test-utilsの上に、より良いテストプラクティスを促進する方法で、軽量なユーティリティ関数を提供します。その主要な指導原則は、「テストがソフトウェアの使用方法に似ているほど、より多くの自信を与えることができます」です。
  • ブログ回答:Reduxテストアプローチの進化: Mark Eriksonの、Reduxテストが「分離」から「統合」へとどのように進化してきたかについての考察。
  • 実装詳細のテスト: Kent C. Doddsによる、実装詳細のテストを避けることを推奨する理由についてのブログ投稿。