メインコンテンツへスキップ
非公式ベータ版翻訳

このページは PageTurner AI で翻訳されました(ベータ版)。プロジェクト公式の承認はありません。 エラーを見つけましたか? 問題を報告 →

テストの書き方

学習内容
  • Reduxを使用したアプリのテストに関する推奨プラクティス
  • テスト設定の構成例

基本原則

Reduxロジックのテストにおける基本原則は、React Testing Libraryの考え方に沿っています:

テストが実際の使用方法に近ければ近いほど、より確かな信頼性を得られます - Kent C. Dodds

Reduxで書くコードのほとんどは関数であり、その多くは純粋関数なので、モックなしでテストしやすい特性があります。ただし、Reduxコードの各部分に専用のテストが必要かどうか検討すべきです。ほとんどのケースで、エンドユーザーはアプリ内でReduxが使われているかどうかを知らず、気にも留めません。したがって、Reduxコードはアプリの実装詳細として扱え、多くの状況で明示的なテストを必要としません。

Reduxを使用したアプリのテストに関する一般的なアドバイス:

  • 可能な限り統合テストを優先する。ReactアプリでReduxを使用する場合、テスト対象のコンポーネントをラップした実際のストアインスタンスを持つ<Provider>をレンダリングします。ページとのインタラクションでは実際のReduxロジックを使用し、API呼び出しはモック化してアプリコードを変更せず、UIが適切に更新されることをアサートします。

  • 必要に応じて、特に複雑なリデューサーやセレクターなどの純粋関数に対して基本のユニットテストを使用します。ただし多くの場合、これらは統合テストでカバーされる実装詳細に過ぎません。

  • セレクター関数やReact-Reduxフックのモック化は避ける! ライブラリのインポートをモック化すると脆くなり、実際のアプリコードが機能しているという確信が得られません。

情報

統合スタイルのテストを推奨する背景については以下を参照:

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

テストランナー

Reduxは任意のテストランナーでテストできます。単なるプレーンなJavaScriptだからです。最近増えている選択肢はVitest(Reduxライブラリリポジトリで使用)ですが、Jestも依然広く使われています。

通常、テストランナーはJavaScript/TypeScript構文をコンパイルする設定が必要です。ブラウザなしでUIコンポーネントをテストする場合、モックDOM環境を提供するためテストランナーにJSDOMの使用設定がほぼ必須です。

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

代表的なテストランナーの設定方法については以下を参照:

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

Reduxチームは、Reduxに接続するReactコンポーネントのテストにVitestブラウザモードまたはReact Testing Library (RTL)の使用を推奨します

React Testing Libraryは、優れたテストプラクティスを促進するシンプルで完全なReact DOMテストユーティリティです。ReactDOMのrender関数とreact-dom/tests-utilsのactを使用します(Testing Libraryファミリーには他の人気フレームワーク用アダプターも含まれます)。

Vitestブラウザモードは実際のブラウザで統合テストを実行し、「モック」DOM環境の必要性を排除します(視覚的フィードバックと回帰テストも可能)。React使用時にはRTLと同様のrenderユーティリティを含むvitest-browser-reactも必要です。

ネットワークリクエストのモックには**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 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>
)
}

このアプリにはサンク、リデューサー、セレクターが含まれます。以下の点に注意した統合テストですべてをテストできます:

  • アプリ初期表示時にはユーザーが存在せず、画面に「ユーザーなし」と表示される

  • 「ユーザー取得」ボタンクリック後、ユーザー取得が開始され「取得中...」と表示される

  • 一定時間後、ユーザーデータを受信し「取得中...」表示が消え、APIレスポンスに基づいたユーザー名が表示される

上記を包括的にテストすることで、アプリのモック化を最小限に抑えられます。ユーザーがアプリを使用する方法を想定した操作時に、重要な動作が期待通りに行われることを確信できます。

コンポーネントテストでは、DOMにrenderでレンダリングし、ユーザー操作に対するアプリの反応が期待通りかをアサートします。

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

React Testing Libraryのrender関数はReact要素ツリーを受け取りコンポーネントをレンダリングします。実際のアプリと同様、Redux接続コンポーネントにはReact-Reduxの<Provider>コンポーネントでラップされた実際のReduxストアが必要です。

さらに重要なのは、テスト間で同じストアインスタンスを再利用せず、毎回新しいReduxストアインスタンスを作成することです。これによりテスト間での値の漏洩を防げます。

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

カスタムレンダリング関数では以下を可能にします:

  • 呼び出しごとに新しいReduxストアインスタンスを作成(初期値として使用可能な任意のpreloadedStateを指定可能)

  • あるいは、すでに作成済みのReduxストアインスタンスを渡す

  • RTLのオリジナルrender関数に追加オプションを渡す

  • テスト対象コンポーネントを自動的に<Provider store={store}>でラップする

  • 追加アクションのディスパッチや状態確認が必要な場合に備え、ストアインスタンスを返す

便宜上、userインスタンスの設定も行います。

カスタムレンダー関数の典型的な設定例は以下のようになります:

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ストアに存在している必要があります。カスタムレンダー関数では、これを実現する方法がいくつかあります。

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

2つ目のオプションは、最初にカスタム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ブラウザモード

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

RTLと同様に、Vitestブラウザモードは実際のブラウザでコンポーネントをレンダリングするrender関数を提供します。ただしReact-Reduxアプリのテストでは、レンダリングツリーに<Provider>が含まれることを保証する必要があります。

前述のRTLカスタムレンダー関数と同様に、コンポーネントを<Provider>でラップしReduxストアを設定するカスタムレンダー関数を作成できます。

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)

リデューサーは純粋関数であり、前の状態にアクションを適用した後の新しい状態を返します。ほとんどの場合、リデューサーは明示的なテストを必要としない実装詳細です。ただし、特に複雑なロジックを含むリデューサーについてユニットテストで確信を持ちたい場合、リデューサーは簡単にテストできます。

リデューサーは純粋関数であるため、テストは直感的に行えます。特定の入力stateactionでリデューサーを呼び出し、結果の状態が期待通りかをアサートします。

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)

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

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

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

Reduxにおいて、アクションクリエイターはプレーンオブジェクトを返す関数です。推奨されるアプローチは、手動でアクションクリエイターを作成するのではなく、createSlice で自動生成するか、@reduxjs/toolkitcreateAction を使用して作成することです。したがって、アクションクリエイター単体をテストする必要性はありません(Redux Toolkitのメンテナーが既にテスト済みです!)。

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

同様に、Redux Thunk を使用するサンクについても、手動で作成するのではなく、@reduxjs/toolkitcreateAsyncThunk を使用することを推奨します。このサンクは、ライフサイクルに基づいて適切な pendingfulfilledrejected アクションタイプのディスパッチを自動的に処理します。

サンクの動作はアプリケーションの実装詳細と見なし、単体テストではなく、それを使用するコンポーネント群(またはアプリ全体)のテストでカバーすることを推奨します。

非同期リクエストのモック化には、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)
}

偽のgetStatedispatchnext関数を作成する必要があります。スタブ作成には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 }
}

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

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

参考情報

  • React Testing Library: React Testing LibraryはReactコンポーネントのテストに特化した軽量ソリューションです。react-domおよびreact-dom/test-utils上に軽量なユーティリティ関数を提供し、優れたテストプラクティスを促進します。その主要な指針は「テストがソフトウェアの使用方法に近いほど、より確かな信頼性が得られる」です。

  • Blogged Answers: The Evolution of Redux Testing Approaches: Mark Eriksonによる、Reduxのテスト手法が「分離」から「統合」へ進化した経緯についての考察

  • Testing Implementation Details: 実装の詳細をテストしないことを推奨する理由についてのKent C. Doddsのブログ記事