10日で覚えるReduxDay 9: Reduxのテスト
books.chapter 910日で覚えるRedux

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

重要な原則:

  1. 実装ではなく振る舞いをテスト — action typeの文字列やstate構造に依存しない
  2. 統合テストを優先 — コンポーネントとストアを組み合わせたテストが最も価値がある
  3. renderWithProvidersを活用 — preloadedStateで任意の初期状態を設定できる
  4. MSWを使う — RTK Queryのテストにはネットワークレベルのモックが最適
  5. 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が呼ばれた時に:

  1. ローディング状態が正しく表示される
  2. データが正常に読み込まれた後、リストが表示される
  3. エラーが発生した場合、エラーメッセージが表示される

上記3つのケースをMSWを使ってテストしてください。

問題4: テスト戦略の検討

あなたのプロジェクトで、以下のそれぞれに対してどのようなテスト戦略が最適かを考えてください:

  • ショッピングカートの合計金額計算
  • ユーザー認証フロー(ログイン→リダイレクト→ダッシュボード表示)
  • テーマ切り替え(ダークモード/ライトモード)