Day 9: Reduxのテスト
今日学ぶこと
- Reduxテストの基本哲学:実装ではなく振る舞いをテストする
- スライス(reducer + action)のテスト方法
- createAsyncThunkのテストとAPIモック
- React Testing Libraryを使った統合テスト
- RTK Queryのテスト戦略
- テストのベストプラクティス
テストの哲学
Reduxのテストで最も重要な原則は、実装の詳細ではなく振る舞いをテストすることです。内部のstate構造やaction typeの文字列をテストするのではなく、「ユーザーがボタンをクリックしたらリストに項目が追加される」といった振る舞いに焦点を当てます。
graph TB
subgraph Pyramid["テストピラミッド"]
E2E["E2Eテスト<br/>少数・高コスト"]
INT["統合テスト<br/>中程度・推奨"]
UNIT["ユニットテスト<br/>多数・低コスト"]
end
style E2E fill:#ef4444,color:#fff
style INT fill:#f59e0b,color:#fff
style UNIT fill:#22c55e,color:#fff
style Pyramid fill:transparent,stroke:#666
Reduxアプリでは統合テストが最もコストパフォーマンスに優れています。コンポーネントとストアを組み合わせてテストすることで、実際のユーザー体験に近い検証ができます。
スライスのユニットテスト
Reducerは純粋関数なので、テストが非常に簡単です。入力(state + action)に対して期待する出力(new state)を検証するだけです。
todoSliceの定義
// features/todos/todoSlice.js
import { createSlice, nanoid } from '@reduxjs/toolkit';
const todoSlice = createSlice({
name: 'todos',
initialState: {
items: [],
filter: 'all', // 'all' | 'active' | 'completed'
},
reducers: {
addTodo: {
reducer(state, action) {
state.items.push(action.payload);
},
prepare(text) {
return { payload: { id: nanoid(), text, completed: false } };
},
},
toggleTodo(state, action) {
const todo = state.items.find((item) => item.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
removeTodo(state, action) {
state.items = state.items.filter((item) => item.id !== action.payload);
},
setFilter(state, action) {
state.filter = action.payload;
},
},
});
export const { addTodo, toggleTodo, removeTodo, setFilter } = todoSlice.actions;
export default todoSlice.reducer;
TypeScript版
// features/todos/todoSlice.ts
import { createSlice, nanoid, PayloadAction } from '@reduxjs/toolkit';
interface Todo {
id: string;
text: string;
completed: boolean;
}
type FilterType = 'all' | 'active' | 'completed';
interface TodoState {
items: Todo[];
filter: FilterType;
}
const initialState: TodoState = {
items: [],
filter: 'all',
};
const todoSlice = createSlice({
name: 'todos',
initialState,
reducers: {
addTodo: {
reducer(state, action: PayloadAction<Todo>) {
state.items.push(action.payload);
},
prepare(text: string) {
return { payload: { id: nanoid(), text, completed: false } };
},
},
toggleTodo(state, action: PayloadAction<string>) {
const todo = state.items.find((item) => item.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
removeTodo(state, action: PayloadAction<string>) {
state.items = state.items.filter((item) => item.id !== action.payload);
},
setFilter(state, action: PayloadAction<FilterType>) {
state.filter = action.payload;
},
},
});
export const { addTodo, toggleTodo, removeTodo, setFilter } = todoSlice.actions;
export default todoSlice.reducer;
reducerのテスト
// features/todos/todoSlice.test.js
import todoReducer, {
addTodo,
toggleTodo,
removeTodo,
setFilter,
} from './todoSlice';
describe('todoSlice', () => {
const initialState = {
items: [],
filter: 'all',
};
// --- addTodo ---
describe('addTodo', () => {
it('should add a new todo to empty list', () => {
const state = todoReducer(initialState, addTodo('Learn Redux'));
expect(state.items).toHaveLength(1);
expect(state.items[0].text).toBe('Learn Redux');
expect(state.items[0].completed).toBe(false);
expect(state.items[0].id).toBeDefined();
});
it('should add a new todo to existing list', () => {
const stateWithOne = {
...initialState,
items: [{ id: '1', text: 'Existing', completed: false }],
};
const state = todoReducer(stateWithOne, addTodo('New Todo'));
expect(state.items).toHaveLength(2);
expect(state.items[1].text).toBe('New Todo');
});
it('should generate unique IDs', () => {
let state = todoReducer(initialState, addTodo('First'));
state = todoReducer(state, addTodo('Second'));
expect(state.items[0].id).not.toBe(state.items[1].id);
});
});
// --- toggleTodo ---
describe('toggleTodo', () => {
it('should toggle completed status', () => {
const stateWithTodo = {
...initialState,
items: [{ id: '1', text: 'Test', completed: false }],
};
const state = todoReducer(stateWithTodo, toggleTodo('1'));
expect(state.items[0].completed).toBe(true);
const toggled = todoReducer(state, toggleTodo('1'));
expect(toggled.items[0].completed).toBe(false);
});
it('should not change state for non-existent ID', () => {
const stateWithTodo = {
...initialState,
items: [{ id: '1', text: 'Test', completed: false }],
};
const state = todoReducer(stateWithTodo, toggleTodo('999'));
expect(state.items[0].completed).toBe(false);
});
});
// --- removeTodo ---
describe('removeTodo', () => {
it('should remove a todo by ID', () => {
const stateWithTodos = {
...initialState,
items: [
{ id: '1', text: 'First', completed: false },
{ id: '2', text: 'Second', completed: true },
],
};
const state = todoReducer(stateWithTodos, removeTodo('1'));
expect(state.items).toHaveLength(1);
expect(state.items[0].id).toBe('2');
});
});
// --- setFilter ---
describe('setFilter', () => {
it('should update filter value', () => {
const state = todoReducer(initialState, setFilter('completed'));
expect(state.filter).toBe('completed');
});
});
});
TypeScript版
// features/todos/todoSlice.test.ts
import todoReducer, {
addTodo,
toggleTodo,
removeTodo,
setFilter,
} from './todoSlice';
import type { TodoState } from './todoSlice';
describe('todoSlice', () => {
const initialState: TodoState = {
items: [],
filter: 'all',
};
describe('addTodo', () => {
it('should add a new todo to empty list', () => {
const state = todoReducer(initialState, addTodo('Learn Redux'));
expect(state.items).toHaveLength(1);
expect(state.items[0].text).toBe('Learn Redux');
expect(state.items[0].completed).toBe(false);
expect(state.items[0].id).toBeDefined();
});
});
// ... same tests with type annotations
});
createAsyncThunkのテスト
非同期thunkのテストでは、APIコールをモックし、成功・失敗それぞれのケースを検証します。
非同期thunkの定義
// features/todos/todoApi.js
import { createAsyncThunk } from '@reduxjs/toolkit';
export const fetchTodos = createAsyncThunk(
'todos/fetchTodos',
async (_, { rejectWithValue }) => {
try {
const response = await fetch('/api/todos');
if (!response.ok) {
throw new Error('Failed to fetch todos');
}
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
export const saveTodo = createAsyncThunk(
'todos/saveTodo',
async (text, { rejectWithValue }) => {
try {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
if (!response.ok) {
throw new Error('Failed to save todo');
}
return await response.json();
} catch (error) {
return rejectWithValue(error.message);
}
}
);
reducerに非同期thunkを組み込む
// todoSlice.js (extraReducers部分)
const todoSlice = createSlice({
name: 'todos',
initialState: {
items: [],
filter: 'all',
status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
error: null,
},
reducers: {
// ... (前述のreducers)
},
extraReducers: (builder) => {
builder
.addCase(fetchTodos.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(fetchTodos.fulfilled, (state, action) => {
state.status = 'succeeded';
state.items = action.payload;
})
.addCase(fetchTodos.rejected, (state, action) => {
state.status = 'failed';
state.error = action.payload;
});
},
});
方法1: reducerを直接テストする
// todoSlice.async.test.js
import todoReducer from './todoSlice';
import { fetchTodos } from './todoApi';
describe('async thunks - reducer tests', () => {
const initialState = {
items: [],
filter: 'all',
status: 'idle',
error: null,
};
it('should set loading state on fetchTodos.pending', () => {
const state = todoReducer(initialState, fetchTodos.pending('requestId'));
expect(state.status).toBe('loading');
expect(state.error).toBeNull();
});
it('should set items on fetchTodos.fulfilled', () => {
const todos = [
{ id: '1', text: 'Test', completed: false },
];
const state = todoReducer(
initialState,
fetchTodos.fulfilled(todos, 'requestId')
);
expect(state.status).toBe('succeeded');
expect(state.items).toEqual(todos);
});
it('should set error on fetchTodos.rejected', () => {
const state = todoReducer(
initialState,
fetchTodos.rejected(null, 'requestId', undefined, 'Network error')
);
expect(state.status).toBe('failed');
expect(state.error).toBe('Network error');
});
});
方法2: 実際のストアでthunkをdispatch
// todoApi.test.js
import { configureStore } from '@reduxjs/toolkit';
import todoReducer from './todoSlice';
import { fetchTodos, saveTodo } from './todoApi';
// fetch APIをモック
global.fetch = jest.fn();
describe('async thunks - store dispatch', () => {
let store;
beforeEach(() => {
store = configureStore({
reducer: { todos: todoReducer },
});
fetch.mockClear();
});
it('should fetch todos successfully', async () => {
const mockTodos = [
{ id: '1', text: 'Learn Testing', completed: false },
{ id: '2', text: 'Write Tests', completed: true },
];
fetch.mockResolvedValueOnce({
ok: true,
json: async () => mockTodos,
});
await store.dispatch(fetchTodos());
const state = store.getState().todos;
expect(state.status).toBe('succeeded');
expect(state.items).toEqual(mockTodos);
expect(fetch).toHaveBeenCalledWith('/api/todos');
});
it('should handle fetch failure', async () => {
fetch.mockResolvedValueOnce({
ok: false,
});
await store.dispatch(fetchTodos());
const state = store.getState().todos;
expect(state.status).toBe('failed');
expect(state.error).toBe('Failed to fetch todos');
});
it('should save a new todo', async () => {
const newTodo = { id: '3', text: 'New Todo', completed: false };
fetch.mockResolvedValueOnce({
ok: true,
json: async () => newTodo,
});
const result = await store.dispatch(saveTodo('New Todo'));
expect(result.payload).toEqual(newTodo);
expect(fetch).toHaveBeenCalledWith('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: 'New Todo' }),
});
});
});
TypeScript版
import { configureStore } from '@reduxjs/toolkit';
import todoReducer from './todoSlice';
import { fetchTodos, saveTodo } from './todoApi';
const globalFetch = global.fetch as jest.MockedFunction<typeof fetch>;
global.fetch = jest.fn();
describe('async thunks - store dispatch', () => {
const createTestStore = () =>
configureStore({
reducer: { todos: todoReducer },
});
let store: ReturnType<typeof createTestStore>;
beforeEach(() => {
store = createTestStore();
globalFetch.mockClear();
});
it('should fetch todos successfully', async () => {
const mockTodos = [
{ id: '1', text: 'Learn Testing', completed: false },
];
globalFetch.mockResolvedValueOnce({
ok: true,
json: async () => mockTodos,
} as Response);
await store.dispatch(fetchTodos());
const state = store.getState().todos;
expect(state.status).toBe('succeeded');
expect(state.items).toEqual(mockTodos);
});
});
統合テスト: React Testing Library
コンポーネントとReduxストアを組み合わせたテストが最も価値があります。まず、テスト用のヘルパー関数を作成しましょう。
renderWithProviders ヘルパー
// test-utils.js
import React from 'react';
import { render } from '@testing-library/react';
import { configureStore } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import todoReducer from './features/todos/todoSlice';
export function setupStore(preloadedState) {
return configureStore({
reducer: {
todos: todoReducer,
},
preloadedState,
});
}
export function renderWithProviders(
ui,
{
preloadedState = {},
store = setupStore(preloadedState),
...renderOptions
} = {}
) {
function Wrapper({ children }) {
return <Provider store={store}>{children}</Provider>;
}
return {
store,
...render(ui, { wrapper: Wrapper, ...renderOptions }),
};
}
TypeScript版
// test-utils.tsx
import React, { PropsWithChildren } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { configureStore, PreloadedState } from '@reduxjs/toolkit';
import { Provider } from 'react-redux';
import todoReducer from './features/todos/todoSlice';
import type { RootState } from './store';
export function setupStore(preloadedState?: PreloadedState<RootState>) {
return configureStore({
reducer: {
todos: todoReducer,
},
preloadedState,
});
}
type AppStore = ReturnType<typeof setupStore>;
interface ExtendedRenderOptions extends Omit<RenderOptions, 'queries'> {
preloadedState?: PreloadedState<RootState>;
store?: AppStore;
}
export function renderWithProviders(
ui: React.ReactElement,
{
preloadedState = {} as PreloadedState<RootState>,
store = setupStore(preloadedState),
...renderOptions
}: ExtendedRenderOptions = {}
) {
function Wrapper({ children }: PropsWithChildren<{}>): JSX.Element {
return <Provider store={store}>{children}</Provider>;
}
return {
store,
...render(ui, { wrapper: Wrapper, ...renderOptions }),
};
}
コンポーネントの統合テスト
テスト対象のコンポーネント:
// features/todos/TodoApp.jsx
import React, { useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { addTodo, toggleTodo, removeTodo, setFilter } from './todoSlice';
function TodoApp() {
const [text, setText] = useState('');
const dispatch = useDispatch();
const { items, filter } = useSelector((state) => state.todos);
const filteredItems = items.filter((item) => {
if (filter === 'active') return !item.completed;
if (filter === 'completed') return item.completed;
return true;
});
const handleSubmit = (e) => {
e.preventDefault();
if (text.trim()) {
dispatch(addTodo(text.trim()));
setText('');
}
};
return (
<div>
<h1>Todo List</h1>
<form onSubmit={handleSubmit}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="What needs to be done?"
aria-label="New todo"
/>
<button type="submit">Add</button>
</form>
<div role="group" aria-label="Filter">
{['all', 'active', 'completed'].map((f) => (
<button
key={f}
onClick={() => dispatch(setFilter(f))}
aria-pressed={filter === f}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
</button>
))}
</div>
<ul>
{filteredItems.map((item) => (
<li key={item.id}>
<label>
<input
type="checkbox"
checked={item.completed}
onChange={() => dispatch(toggleTodo(item.id))}
/>
<span style={{
textDecoration: item.completed ? 'line-through' : 'none'
}}>
{item.text}
</span>
</label>
<button
onClick={() => dispatch(removeTodo(item.id))}
aria-label={`Remove ${item.text}`}
>
Delete
</button>
</li>
))}
</ul>
<p>{items.filter((i) => !i.completed).length} items left</p>
</div>
);
}
export default TodoApp;
テストコード:
// features/todos/TodoApp.test.jsx
import React from 'react';
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test-utils';
import TodoApp from './TodoApp';
describe('TodoApp integration', () => {
it('should add a new todo', async () => {
const user = userEvent.setup();
renderWithProviders(<TodoApp />);
const input = screen.getByLabelText('New todo');
const addButton = screen.getByText('Add');
await user.type(input, 'Learn Redux Testing');
await user.click(addButton);
expect(screen.getByText('Learn Redux Testing')).toBeInTheDocument();
expect(input).toHaveValue('');
});
it('should toggle a todo', async () => {
const user = userEvent.setup();
renderWithProviders(<TodoApp />, {
preloadedState: {
todos: {
items: [{ id: '1', text: 'Test Todo', completed: false }],
filter: 'all',
},
},
});
const checkbox = screen.getByRole('checkbox');
await user.click(checkbox);
expect(checkbox).toBeChecked();
});
it('should remove a todo', async () => {
const user = userEvent.setup();
renderWithProviders(<TodoApp />, {
preloadedState: {
todos: {
items: [{ id: '1', text: 'Delete Me', completed: false }],
filter: 'all',
},
},
});
await user.click(screen.getByLabelText('Remove Delete Me'));
expect(screen.queryByText('Delete Me')).not.toBeInTheDocument();
});
it('should filter todos', async () => {
const user = userEvent.setup();
renderWithProviders(<TodoApp />, {
preloadedState: {
todos: {
items: [
{ id: '1', text: 'Active Task', completed: false },
{ id: '2', text: 'Done Task', completed: true },
],
filter: 'all',
},
},
});
// All todos visible
expect(screen.getByText('Active Task')).toBeInTheDocument();
expect(screen.getByText('Done Task')).toBeInTheDocument();
// Filter to active
await user.click(screen.getByText('Active'));
expect(screen.getByText('Active Task')).toBeInTheDocument();
expect(screen.queryByText('Done Task')).not.toBeInTheDocument();
// Filter to completed
await user.click(screen.getByText('Completed'));
expect(screen.queryByText('Active Task')).not.toBeInTheDocument();
expect(screen.getByText('Done Task')).toBeInTheDocument();
});
it('should show items count', async () => {
const user = userEvent.setup();
renderWithProviders(<TodoApp />);
expect(screen.getByText('0 items left')).toBeInTheDocument();
await user.type(screen.getByLabelText('New todo'), 'Task 1');
await user.click(screen.getByText('Add'));
expect(screen.getByText('1 items left')).toBeInTheDocument();
});
it('should not add empty todos', async () => {
const user = userEvent.setup();
const { store } = renderWithProviders(<TodoApp />);
await user.click(screen.getByText('Add'));
expect(store.getState().todos.items).toHaveLength(0);
});
it('full user workflow: add, complete, filter, delete', async () => {
const user = userEvent.setup();
renderWithProviders(<TodoApp />);
// Add two todos
const input = screen.getByLabelText('New todo');
await user.type(input, 'Buy groceries');
await user.click(screen.getByText('Add'));
await user.type(input, 'Clean house');
await user.click(screen.getByText('Add'));
expect(screen.getByText('2 items left')).toBeInTheDocument();
// Complete one
const checkboxes = screen.getAllByRole('checkbox');
await user.click(checkboxes[0]);
expect(screen.getByText('1 items left')).toBeInTheDocument();
// Filter to completed
await user.click(screen.getByText('Completed'));
expect(screen.getByText('Buy groceries')).toBeInTheDocument();
expect(screen.queryByText('Clean house')).not.toBeInTheDocument();
// Delete the completed item
await user.click(screen.getByLabelText('Remove Buy groceries'));
expect(screen.queryByText('Buy groceries')).not.toBeInTheDocument();
// Back to all - only Clean house remains
await user.click(screen.getByText('All'));
expect(screen.getByText('Clean house')).toBeInTheDocument();
});
});
RTK Queryのテスト
RTK Queryのテストには、MSW(Mock Service Worker)を使う方法が推奨されています。
MSWのセットアップ
// mocks/handlers.js
import { http, HttpResponse } from 'msw';
export const handlers = [
http.get('/api/todos', () => {
return HttpResponse.json([
{ id: '1', text: 'Mock Todo 1', completed: false },
{ id: '2', text: 'Mock Todo 2', completed: true },
]);
}),
http.post('/api/todos', async ({ request }) => {
const body = await request.json();
return HttpResponse.json({
id: '3',
text: body.text,
completed: false,
});
}),
http.delete('/api/todos/:id', ({ params }) => {
return HttpResponse.json({ id: params.id });
}),
];
// mocks/server.js
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
export const server = setupServer(...handlers);
// setupTests.js
import { server } from './mocks/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
RTK Query APIのテスト
// features/todos/todoApiSlice.js
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
export const todoApiSlice = createApi({
reducerPath: 'todoApi',
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Todo'],
endpoints: (builder) => ({
getTodos: builder.query({
query: () => '/todos',
providesTags: ['Todo'],
}),
addTodo: builder.mutation({
query: (text) => ({
url: '/todos',
method: 'POST',
body: { text },
}),
invalidatesTags: ['Todo'],
}),
}),
});
export const { useGetTodosQuery, useAddTodoMutation } = todoApiSlice;
// features/todos/todoApiSlice.test.jsx
import React from 'react';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../mocks/server';
import { renderWithProviders } from '../../test-utils';
import { todoApiSlice } from './todoApiSlice';
import TodoListWithQuery from './TodoListWithQuery';
// setupStore must include the API middleware
import { configureStore } from '@reduxjs/toolkit';
function setupApiStore() {
return configureStore({
reducer: {
[todoApiSlice.reducerPath]: todoApiSlice.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(todoApiSlice.middleware),
});
}
describe('RTK Query - todoApiSlice', () => {
it('should fetch and display todos', async () => {
renderWithProviders(<TodoListWithQuery />);
// Loading state
expect(screen.getByText('Loading...')).toBeInTheDocument();
// Data loaded
await waitFor(() => {
expect(screen.getByText('Mock Todo 1')).toBeInTheDocument();
expect(screen.getByText('Mock Todo 2')).toBeInTheDocument();
});
});
it('should handle server error', async () => {
server.use(
http.get('/api/todos', () => {
return HttpResponse.json(
{ message: 'Internal Server Error' },
{ status: 500 }
);
})
);
renderWithProviders(<TodoListWithQuery />);
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
it('should add a new todo via mutation', async () => {
const user = userEvent.setup();
renderWithProviders(<TodoListWithQuery />);
await waitFor(() => {
expect(screen.getByText('Mock Todo 1')).toBeInTheDocument();
});
await user.type(screen.getByLabelText('New todo'), 'New API Todo');
await user.click(screen.getByText('Add'));
await waitFor(() => {
expect(screen.getByText('New API Todo')).toBeInTheDocument();
});
});
});
非同期フローのエンドツーエンドテスト
非同期操作を含む完全なフローのテスト例です。
// features/todos/AsyncTodoFlow.test.jsx
import React from 'react';
import { screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../test-utils';
import AsyncTodoApp from './AsyncTodoApp';
describe('async todo flow', () => {
it('should load todos, add one, and verify the list updates', async () => {
const user = userEvent.setup();
renderWithProviders(<AsyncTodoApp />);
// Wait for initial load
await waitForElementToBeRemoved(() => screen.getByText('Loading...'));
// Verify initial data
expect(screen.getByText('Mock Todo 1')).toBeInTheDocument();
// Add a new todo
await user.type(screen.getByLabelText('New todo'), 'E2E Test Todo');
await user.click(screen.getByText('Add'));
// Wait for the mutation and refetch
await waitFor(() => {
expect(screen.getByText('E2E Test Todo')).toBeInTheDocument();
});
});
it('should show error message when loading fails and allow retry', async () => {
const user = userEvent.setup();
// Override handler for this test
server.use(
http.get('/api/todos', () => {
return HttpResponse.json(null, { status: 500 });
})
);
renderWithProviders(<AsyncTodoApp />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
// Restore handler and retry
server.resetHandlers();
await user.click(screen.getByText('Retry'));
await waitFor(() => {
expect(screen.getByText('Mock Todo 1')).toBeInTheDocument();
});
});
});
テストのベストプラクティス
flowchart LR
subgraph DO["推奨"]
A["振る舞いをテスト"]
B["統合テスト重視"]
C["preloadedStateを活用"]
D["userEventを使用"]
end
subgraph DONT["避けるべき"]
E["action typeの文字列テスト"]
F["内部state構造のテスト"]
G["fireEventの使用"]
H["実装詳細のテスト"]
end
style DO fill:#22c55e,color:#fff
style DONT fill:#ef4444,color:#fff
まとめ
| テスト対象 | 手法 | ツール | 優先度 |
|---|---|---|---|
| Reducer | 純粋関数テスト | Jest | 中 |
| Async Thunk | ストアdispatch + モック | Jest + fetch mock | 中 |
| コンポーネント + Store | 統合テスト | React Testing Library | 高 |
| RTK Query | MSWでAPIモック | MSW + RTL | 高 |
| セレクタ | 入力→出力テスト | Jest | 低 |
| ミドルウェア | ストア経由テスト | Jest | 低 |
重要な原則:
- 実装ではなく振る舞いをテスト — action typeの文字列やstate構造に依存しない
- 統合テストを優先 — コンポーネントとストアを組み合わせたテストが最も価値がある
renderWithProvidersを活用 — preloadedStateで任意の初期状態を設定できる- MSWを使う — RTK Queryのテストにはネットワークレベルのモックが最適
- userEventを使う —
fireEventよりも実際のユーザー操作に近い
練習問題
問題1: Reducerテストの作成
以下のcounterSliceに対するテストを書いてください。
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0, history: [] },
reducers: {
increment(state) {
state.value += 1;
state.history.push(state.value);
},
decrement(state) {
state.value -= 1;
state.history.push(state.value);
},
reset(state) {
state.value = 0;
state.history = [];
},
},
});
問題2: 統合テストの作成
以下の要件を満たすTodoAppの統合テストを書いてください:
- Todoを3つ追加する
- 2つ目のTodoを完了にする
- 「Active」フィルタに切り替え、完了したTodoが非表示になることを確認
- 残りのアイテム数が正しいことを確認
問題3: 非同期テストの作成
fetchTodosが呼ばれた時に:
- ローディング状態が正しく表示される
- データが正常に読み込まれた後、リストが表示される
- エラーが発生した場合、エラーメッセージが表示される
上記3つのケースをMSWを使ってテストしてください。
問題4: テスト戦略の検討
あなたのプロジェクトで、以下のそれぞれに対してどのようなテスト戦略が最適かを考えてください:
- ショッピングカートの合計金額計算
- ユーザー認証フロー(ログイン→リダイレクト→ダッシュボード表示)
- テーマ切り替え(ダークモード/ライトモード)