Zustand × テスト自動化:Jest でストアをテストする実践ガイド

React アプリケーションの状態管理において、「テストが書きにくい」「デバッグが困難」といった課題に直面したことはありませんか? 特に複雑な状態管理ライブラリでは、テストコードが本体コードよりも複雑になってしまうケースも少なくありません。
しかし、軽量でシンプルな状態管理ライブラリ Zustand と Jest を組み合わせることで、この問題を効率的に解決できます。 本記事では、実際のコード例とエラーパターンを交えながら、Zustand のストアを Jest で徹底的にテストする実践的な手法をご紹介いたします。
初心者の方でも段階的に理解できるよう、基本的な環境構築から応用的なテクニックまで、わかりやすく解説していきますね。
背景:なぜ Zustand のテストが重要なのか
状態管理のバグが引き起こす問題
React アプリケーションにおいて、状態管理のバグは以下のような深刻な問題を引き起こします。
問題の種類 | 具体的な影響 | 発見の困難さ |
---|---|---|
データの不整合 | ユーザー情報の表示ミス、計算結果の誤り | 高 |
メモリリーク | アプリケーションの動作が徐々に重くなる | 非常に高 |
意図しない再レンダリング | パフォーマンスの低下、UX の悪化 | 中 |
状態の競合 | 予期しない動作、データ破損 | 高 |
これらの問題は本番環境で発生すると、ユーザー体験を大きく損ないます。 特に E コマースサイトでの決済処理や、医療システムでの患者データ管理など、重要なデータを扱うアプリケーションでは致命的な問題となりかねません。
テストによる品質向上の効果
適切なテストを導入することで、以下の効果を期待できます:
開発効率の向上
- バグの早期発見により、修正コストを大幅に削減
- リファクタリング時の安心感が向上
- 新機能追加時の既存機能への影響を事前に検知
チーム開発の安定性
- コードレビュー時の品質担保
- 新しいメンバーでも安心してコード変更が可能
- 仕様書としての役割も果たす
課題:従来の状態管理テストの問題点
Redux でのテストの複雑さ
Redux を使った状態管理のテストは、非常に複雑になりがちです。
typescript// Redux のテストコード例(複雑な例)
import { createStore, combineReducers } from 'redux';
import { Provider } from 'react-redux';
import { render, screen } from '@testing-library/react';
import userReducer from '../reducers/userReducer';
import todosReducer from '../reducers/todosReducer';
const rootReducer = combineReducers({
user: userReducer,
todos: todosReducer,
});
const createMockStore = (initialState) => {
return createStore(rootReducer, initialState);
};
// テストのたびにストアをラップする必要がある
const renderWithRedux = (
ui,
{
initialState,
store = createMockStore(initialState),
...renderOptions
} = {}
) => {
const Wrapper = ({ children }) => (
<Provider store={store}>{children}</Provider>
);
return render(ui, { wrapper: Wrapper, ...renderOptions });
};
このように、Redux のテストでは多くの設定コードが必要となり、テスト本来の目的である「動作の検証」よりも「環境構築」に時間を取られてしまいます。
ボイラープレートコードの多さ
Redux では、一つの機能を実装するために複数のファイルが必要です:
ファイル種類 | 役割 | 必要性 |
---|---|---|
Action Types | アクションの定数定義 | 必須 |
Action Creators | アクションオブジェクトの生成 | 必須 |
Reducers | 状態更新ロジック | 必須 |
Selectors | 状態の取得ロジック | 推奨 |
Test Files | 各ファイルのテスト | 必須 |
さらに、これらすべてにテストコードを書く必要があり、メンテナンスコストが非常に高くなります。
テスト環境構築の困難さ
従来の状態管理ライブラリでは、以下のようなエラーに遭遇することが多々あります:
bash# よく見かけるエラーメッセージ
TypeError: Cannot read property 'getState' of undefined
at useSelector (react-redux.js:45:12)
Error: Could not find "store" in the context of "Connect(Component)"
at invariant (invariant.js:40:15)
Warning: Failed prop type: Invalid prop `store` of type `undefined`
これらのエラーは主に、テスト環境でのストアの設定不備によるものですが、初心者には解決が困難な場合が多いのです。
解決策:Zustand と Jest の組み合わせ
Zustand のシンプルなテスト設計
Zustand は、これらの問題を根本的に解決します。 まず、基本的な環境構築から始めましょう。
bash# プロジェクトの初期化
yarn create next-app zustand-test-demo --typescript
cd zustand-test-demo
# 必要なパッケージのインストール
yarn add zustand
yarn add -D jest @types/jest jest-environment-jsdom @testing-library/react @testing-library/jest-dom
package.json の設定
json{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage"
},
"jest": {
"testEnvironment": "jsdom",
"setupFilesAfterEnv": ["<rootDir>/jest.setup.js"],
"moduleNameMapping": {
"^@/(.*)$": "<rootDir>/src/$1"
}
}
}
jest.setup.js の作成
javascript// jest.setup.js
import '@testing-library/jest-dom';
// Zustand のテスト用設定
beforeEach(() => {
// 各テストの前にストアをリセット
localStorage.clear();
sessionStorage.clear();
});
Jest の豊富なテスト機能の活用
Jest は以下の機能を提供しており、Zustand との相性が抜群です:
機能 | 説明 | Zustand での活用例 |
---|---|---|
Mocking | 外部依存関係のモック化 | API 呼び出しのモック |
Snapshot Testing | コンポーネントの出力を記録 | ストア状態のスナップショット |
Coverage Report | テストカバレッジの可視化 | ストアロジックの網羅性確認 |
Watch Mode | ファイル変更時の自動テスト実行 | 開発効率の向上 |
効率的なテスト戦略
Zustand でのテストは、以下の 3 層構造で考えると効率的です:
- ユニットテスト層:個別のストア機能をテスト
- 統合テスト層:複数ストア間の連携をテスト
- E2E テスト層:実際のユーザー操作をテスト
この構造により、バグの早期発見と修正が可能になります。
具体例:実際のストアテスト実装
カウンターストアのテスト
まず、最もシンプルなカウンターストアから始めましょう。
stores/counterStore.ts
typescriptimport { create } from 'zustand';
// カウンターストアの型定義
interface CounterState {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
setCount: (count: number) => void;
}
// カウンターストアの実装
export const useCounterStore = create<CounterState>(
(set) => ({
count: 0,
// カウントを1増加
increment: () =>
set((state) => ({ count: state.count + 1 })),
// カウントを1減少
decrement: () =>
set((state) => ({ count: state.count - 1 })),
// カウントをリセット
reset: () => set({ count: 0 }),
// カウントを指定した値に設定
setCount: (count: number) => set({ count }),
})
);
tests/counterStore.test.ts
typescriptimport { act, renderHook } from '@testing-library/react';
import { useCounterStore } from '../stores/counterStore';
// テスト実行前にストアをリセット
beforeEach(() => {
useCounterStore.setState({ count: 0 });
});
describe('Counter ストアのテスト', () => {
test('初期状態が正しく設定されている', () => {
const { result } = renderHook(() => useCounterStore());
// 初期値が0であることを確認
expect(result.current.count).toBe(0);
});
test('increment でカウントが1増加する', () => {
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
test('decrement でカウントが1減少する', () => {
const { result } = renderHook(() => useCounterStore());
// 初期状態から1回増加させる
act(() => {
result.current.increment();
});
// 減少させる
act(() => {
result.current.decrement();
});
expect(result.current.count).toBe(0);
});
test('reset でカウントが0になる', () => {
const { result } = renderHook(() => useCounterStore());
// カウントを5に設定
act(() => {
result.current.setCount(5);
});
// リセット実行
act(() => {
result.current.reset();
});
expect(result.current.count).toBe(0);
});
test('setCount で任意の値が設定される', () => {
const { result } = renderHook(() => useCounterStore());
const testValue = 42;
act(() => {
result.current.setCount(testValue);
});
expect(result.current.count).toBe(testValue);
});
test('負の値も正しく設定される', () => {
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.setCount(-10);
});
expect(result.current.count).toBe(-10);
});
});
TODO アプリストアのテスト
より実践的な例として、TODO アプリのストアをテストしてみましょう。
stores/todoStore.ts
typescriptimport { create } from 'zustand';
// TODO アイテムの型定義
interface Todo {
id: string;
text: string;
completed: boolean;
createdAt: Date;
}
// TODO ストアの型定義
interface TodoState {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
deleteTodo: (id: string) => void;
updateTodo: (id: string, text: string) => void;
setFilter: (
filter: 'all' | 'active' | 'completed'
) => void;
clearCompleted: () => void;
getFilteredTodos: () => Todo[];
}
export const useTodoStore = create<TodoState>(
(set, get) => ({
todos: [],
filter: 'all',
// 新しいTODOを追加
addTodo: (text: string) => {
const newTodo: Todo = {
id: Date.now().toString(),
text: text.trim(),
completed: false,
createdAt: new Date(),
};
set((state) => ({
todos: [...state.todos, newTodo],
}));
},
// TODOの完了状態を切り替え
toggleTodo: (id: string) => {
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
),
}));
},
// TODOを削除
deleteTodo: (id: string) => {
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
}));
},
// TODOのテキストを更新
updateTodo: (id: string, text: string) => {
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id
? { ...todo, text: text.trim() }
: todo
),
}));
},
// フィルターを設定
setFilter: (filter) => set({ filter }),
// 完了済みTODOをすべて削除
clearCompleted: () => {
set((state) => ({
todos: state.todos.filter(
(todo) => !todo.completed
),
}));
},
// フィルターに基づいてTODOを取得
getFilteredTodos: () => {
const { todos, filter } = get();
switch (filter) {
case 'active':
return todos.filter((todo) => !todo.completed);
case 'completed':
return todos.filter((todo) => todo.completed);
default:
return todos;
}
},
})
);
tests/todoStore.test.ts
typescriptimport { act, renderHook } from '@testing-library/react';
import { useTodoStore } from '../stores/todoStore';
// モック用のDate.nowを設定
const mockDateNow = jest.spyOn(Date, 'now');
beforeEach(() => {
// 各テスト前にストアをリセット
useTodoStore.setState({
todos: [],
filter: 'all',
});
// 固定のタイムスタンプを設定
mockDateNow.mockReturnValue(1640995200000); // 2022-01-01 00:00:00
});
afterEach(() => {
jest.clearAllMocks();
});
describe('TODO ストアのテスト', () => {
test('初期状態が正しく設定されている', () => {
const { result } = renderHook(() => useTodoStore());
expect(result.current.todos).toEqual([]);
expect(result.current.filter).toBe('all');
});
test('addTodo で新しいTODOが追加される', () => {
const { result } = renderHook(() => useTodoStore());
const todoText = 'テスト用TODO';
act(() => {
result.current.addTodo(todoText);
});
expect(result.current.todos).toHaveLength(1);
expect(result.current.todos[0]).toMatchObject({
text: todoText,
completed: false,
});
expect(result.current.todos[0].id).toBeDefined();
});
test('空白のTODOテキストはトリムされる', () => {
const { result } = renderHook(() => useTodoStore());
act(() => {
result.current.addTodo(' 空白付きTODO ');
});
expect(result.current.todos[0].text).toBe(
'空白付きTODO'
);
});
test('toggleTodo でTODOの完了状態が切り替わる', () => {
const { result } = renderHook(() => useTodoStore());
// TODOを追加
act(() => {
result.current.addTodo('テストTODO');
});
const todoId = result.current.todos[0].id;
// 完了状態に切り替え
act(() => {
result.current.toggleTodo(todoId);
});
expect(result.current.todos[0].completed).toBe(true);
// 再度切り替えて未完了に
act(() => {
result.current.toggleTodo(todoId);
});
expect(result.current.todos[0].completed).toBe(false);
});
test('deleteTodo でTODOが削除される', () => {
const { result } = renderHook(() => useTodoStore());
// 複数のTODOを追加
act(() => {
result.current.addTodo('TODO1');
result.current.addTodo('TODO2');
});
const firstTodoId = result.current.todos[0].id;
// 最初のTODOを削除
act(() => {
result.current.deleteTodo(firstTodoId);
});
expect(result.current.todos).toHaveLength(1);
expect(result.current.todos[0].text).toBe('TODO2');
});
test('getFilteredTodos でフィルター機能が正しく動作する', () => {
const { result } = renderHook(() => useTodoStore());
// テスト用のTODOを追加
act(() => {
result.current.addTodo('未完了TODO1');
result.current.addTodo('未完了TODO2');
result.current.addTodo('完了予定TODO');
});
// 一つのTODOを完了状態に
const todoId = result.current.todos[2].id;
act(() => {
result.current.toggleTodo(todoId);
});
// 全て表示
act(() => {
result.current.setFilter('all');
});
expect(result.current.getFilteredTodos()).toHaveLength(
3
);
// 未完了のみ表示
act(() => {
result.current.setFilter('active');
});
expect(result.current.getFilteredTodos()).toHaveLength(
2
);
// 完了済みのみ表示
act(() => {
result.current.setFilter('completed');
});
expect(result.current.getFilteredTodos()).toHaveLength(
1
);
});
test('clearCompleted で完了済みTODOがすべて削除される', () => {
const { result } = renderHook(() => useTodoStore());
// テスト用TODOを追加
act(() => {
result.current.addTodo('未完了TODO');
result.current.addTodo('完了TODO1');
result.current.addTodo('完了TODO2');
});
// 2つのTODOを完了状態に
const todos = result.current.todos;
act(() => {
result.current.toggleTodo(todos[1].id);
result.current.toggleTodo(todos[2].id);
});
// 完了済みTODOを削除
act(() => {
result.current.clearCompleted();
});
expect(result.current.todos).toHaveLength(1);
expect(result.current.todos[0].text).toBe('未完了TODO');
});
});
API と連携するストアのテスト
実際のアプリケーションでは、API との連携が必要です。 非同期処理を含むストアのテスト方法を見てみましょう。
stores/userStore.ts
typescriptimport { create } from 'zustand';
// ユーザー情報の型定義
interface User {
id: number;
name: string;
email: string;
avatar?: string;
}
// ユーザーストアの型定義
interface UserState {
user: User | null;
users: User[];
loading: boolean;
error: string | null;
fetchUser: (id: number) => Promise<void>;
fetchUsers: () => Promise<void>;
updateUser: (
id: number,
data: Partial<User>
) => Promise<void>;
clearError: () => void;
}
// API関数のモック用インターface
interface UserAPI {
getUser: (id: number) => Promise<User>;
getUsers: () => Promise<User[]>;
updateUser: (
id: number,
data: Partial<User>
) => Promise<User>;
}
// 実際のAPI関数(テストではモック化される)
export const userAPI: UserAPI = {
getUser: async (id: number) => {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(
`Failed to fetch user: ${response.status}`
);
}
return response.json();
},
getUsers: async () => {
const response = await fetch('/api/users');
if (!response.ok) {
throw new Error(
`Failed to fetch users: ${response.status}`
);
}
return response.json();
},
updateUser: async (id: number, data: Partial<User>) => {
const response = await fetch(`/api/users/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(
`Failed to update user: ${response.status}`
);
}
return response.json();
},
};
export const useUserStore = create<UserState>((set) => ({
user: null,
users: [],
loading: false,
error: null,
// 個別ユーザー情報を取得
fetchUser: async (id: number) => {
set({ loading: true, error: null });
try {
const user = await userAPI.getUser(id);
set({ user, loading: false });
} catch (error) {
set({
error:
error instanceof Error
? error.message
: 'Unknown error',
loading: false,
});
}
},
// ユーザー一覧を取得
fetchUsers: async () => {
set({ loading: true, error: null });
try {
const users = await userAPI.getUsers();
set({ users, loading: false });
} catch (error) {
set({
error:
error instanceof Error
? error.message
: 'Unknown error',
loading: false,
});
}
},
// ユーザー情報を更新
updateUser: async (id: number, data: Partial<User>) => {
set({ loading: true, error: null });
try {
const updatedUser = await userAPI.updateUser(
id,
data
);
set((state) => ({
user:
state.user?.id === id ? updatedUser : state.user,
users: state.users.map((user) =>
user.id === id ? updatedUser : user
),
loading: false,
}));
} catch (error) {
set({
error:
error instanceof Error
? error.message
: 'Unknown error',
loading: false,
});
}
},
// エラーをクリア
clearError: () => set({ error: null }),
}));
tests/userStore.test.ts
typescriptimport {
act,
renderHook,
waitFor,
} from '@testing-library/react';
import { useUserStore, userAPI } from '../stores/userStore';
// API関数をモック化
jest.mock('../stores/userStore', () => ({
...jest.requireActual('../stores/userStore'),
userAPI: {
getUser: jest.fn(),
getUsers: jest.fn(),
updateUser: jest.fn(),
},
}));
const mockUserAPI = userAPI as jest.Mocked<typeof userAPI>;
// テスト用のサンプルデータ
const mockUser = {
id: 1,
name: '田中太郎',
email: 'tanaka@example.com',
avatar: 'https://example.com/avatar.jpg',
};
const mockUsers = [
mockUser,
{
id: 2,
name: '佐藤花子',
email: 'sato@example.com',
},
];
beforeEach(() => {
// 各テスト前にストアをリセット
useUserStore.setState({
user: null,
users: [],
loading: false,
error: null,
});
// モック関数をリセット
jest.clearAllMocks();
});
describe('User ストアのテスト', () => {
test('初期状態が正しく設定されている', () => {
const { result } = renderHook(() => useUserStore());
expect(result.current.user).toBeNull();
expect(result.current.users).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
test('fetchUser が成功時に正しくユーザー情報を取得する', async () => {
const { result } = renderHook(() => useUserStore());
// API呼び出しをモック化
mockUserAPI.getUser.mockResolvedValue(mockUser);
// ユーザー情報を取得
await act(async () => {
await result.current.fetchUser(1);
});
await waitFor(() => {
expect(result.current.user).toEqual(mockUser);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
expect(mockUserAPI.getUser).toHaveBeenCalledWith(1);
expect(mockUserAPI.getUser).toHaveBeenCalledTimes(1);
});
test('fetchUser がAPI エラー時に適切にエラーを処理する', async () => {
const { result } = renderHook(() => useUserStore());
const errorMessage = 'Failed to fetch user: 404';
mockUserAPI.getUser.mockRejectedValue(
new Error(errorMessage)
);
await act(async () => {
await result.current.fetchUser(999);
});
await waitFor(() => {
expect(result.current.user).toBeNull();
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe(errorMessage);
});
});
test('fetchUsers が成功時にユーザー一覧を取得する', async () => {
const { result } = renderHook(() => useUserStore());
mockUserAPI.getUsers.mockResolvedValue(mockUsers);
await act(async () => {
await result.current.fetchUsers();
});
await waitFor(() => {
expect(result.current.users).toEqual(mockUsers);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
});
test('updateUser が成功時にユーザー情報を更新する', async () => {
const { result } = renderHook(() => useUserStore());
// 初期状態でユーザー情報を設定
act(() => {
useUserStore.setState({
user: mockUser,
users: mockUsers,
});
});
const updatedData = { name: '田中次郎' };
const updatedUser = { ...mockUser, ...updatedData };
mockUserAPI.updateUser.mockResolvedValue(updatedUser);
await act(async () => {
await result.current.updateUser(1, updatedData);
});
await waitFor(() => {
expect(result.current.user?.name).toBe('田中次郎');
expect(result.current.users[0].name).toBe('田中次郎');
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
});
test('ローディング状態が正しく管理される', async () => {
const { result } = renderHook(() => useUserStore());
// 長時間かかるAPI呼び出しをシミュレート
mockUserAPI.getUser.mockImplementation(
() =>
new Promise((resolve) =>
setTimeout(() => resolve(mockUser), 100)
)
);
// API呼び出し開始
act(() => {
result.current.fetchUser(1);
});
// ローディング状態になることを確認
expect(result.current.loading).toBe(true);
// API呼び出し完了まで待機
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
});
test('clearError でエラー状態がクリアされる', () => {
const { result } = renderHook(() => useUserStore());
// エラー状態を設定
act(() => {
useUserStore.setState({ error: 'テストエラー' });
});
expect(result.current.error).toBe('テストエラー');
// エラーをクリア
act(() => {
result.current.clearError();
});
expect(result.current.error).toBeNull();
});
test('ネットワークエラーの場合の処理', async () => {
const { result } = renderHook(() => useUserStore());
// ネットワークエラーをシミュレート
mockUserAPI.getUser.mockRejectedValue(
new TypeError('Network error')
);
await act(async () => {
await result.current.fetchUser(1);
});
await waitFor(() => {
expect(result.current.error).toBe('Network error');
expect(result.current.loading).toBe(false);
});
});
});
よく発生するテストエラーとその対処法も確認しておきましょう:
bash# よくあるテストエラー
Error: Cannot read property 'current' of null
# 原因:renderHookの戻り値を正しく取得できていない
# 解決法:act()でラップして状態更新を待つ
Warning: An update to TestComponent inside a test was not wrapped in act(...)
# 原因:非同期の状態更新がact()でラップされていない
# 解決法:全ての状態更新をact()でラップする
Error: Timeout - Async callback was not invoked within the 5000ms timeout
# 原因:非同期処理が時間内に完了していない
# 解決法:waitFor()のタイムアウトを延長、またはモックの処理を見直す
応用テクニック
テストの高速化
大量のテストがある場合、実行時間の短縮が重要になります。
並列実行の設定
json{
"jest": {
"maxWorkers": "50%",
"testTimeout": 10000,
"cache": true,
"cacheDirectory": "node_modules/.cache/jest"
}
}
テストのグループ化
typescript// 関連するテストをdescribe.eachでまとめて実行
describe.each([
{ initialValue: 0, expected: 1 },
{ initialValue: 5, expected: 6 },
{ initialValue: -1, expected: 0 },
])('カウンター増加テスト', ({ initialValue, expected }) => {
test(`${initialValue} から ${expected} に増加`, () => {
const { result } = renderHook(() => useCounterStore());
act(() => {
result.current.setCount(initialValue);
result.current.increment();
});
expect(result.current.count).toBe(expected);
});
});
CI/CD での活用
GitHub Actions での自動テスト実行設定例:
.github/workflows/test.yml
yamlname: テスト実行
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20]
steps:
- uses: actions/checkout@v3
- name: Node.js セットアップ
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'yarn'
- name: 依存関係インストール
run: yarn install --frozen-lockfile
- name: テスト実行
run: yarn test --coverage --watchAll=false
- name: カバレッジレポート送信
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
チーム開発での運用
テストルールの統一
typescript// test-utils.ts - チーム共通のテストユーティリティ
import { renderHook, act } from '@testing-library/react';
import type { StateCreator } from 'zustand';
// ストアテスト用のヘルパー関数
export const createTestStore = <T>(
storeCreator: StateCreator<T>
) => {
return renderHook(() => {
const store = create(storeCreator);
return store();
});
};
// 非同期アクションのテスト用ヘルパー
export const testAsyncAction = async <T>(
hook: { current: T },
action: keyof T,
...args: any[]
) => {
await act(async () => {
await (hook.current[action] as Function)(...args);
});
};
// エラーケースのテスト用ヘルパー
export const expectError = async (
asyncFn: () => Promise<any>,
expectedMessage: string
) => {
try {
await asyncFn();
throw new Error('Expected function to throw');
} catch (error) {
expect(error.message).toContain(expectedMessage);
}
};
コードレビューでのチェックポイント
項目 | チェック内容 | 重要度 |
---|---|---|
テストケース網羅性 | 正常系・異常系・境界値のテストがある | 高 |
モック化の適切さ | 外部依存関係が適切にモック化されている | 高 |
非同期処理 | act()や waitFor()が適切に使われている | 中 |
テスト名の明確さ | 何をテストしているかが分かりやすい | 中 |
セットアップ・クリーンアップ | beforeEach/afterEach が適切に設定されている | 低 |
まとめ
Zustand と Jest を組み合わせることで、従来の状態管理ライブラリでは困難だったシンプルで保守性の高いテストを実現できることをご紹介しました。
主なメリット
- 設定が簡単:複雑なボイラープレートコードが不要
- テストが書きやすい:直感的な API でテストコードが分かりやすい
- 高速実行:軽量なライブラリのため、テスト実行が高速
- 型安全性:TypeScript との相性が良く、バグの早期発見が可能
実装のポイント
- 段階的なテスト構造:ユニット → 統合 →E2E の 3 層でテスト設計
- 適切なモック化:外部依存関係を分離してテストを安定化
- 非同期処理の考慮:act()や waitFor()を活用した確実なテスト
- エラーケースの網羅:正常系だけでなく異常系もしっかりテスト
特に、API 連携を含む実際のアプリケーションでは、適切なモック化と非同期処理のテストが品質向上の鍵となります。 本記事で紹介したパターンを参考に、ぜひ皆様のプロジェクトでも Zustand のテスト自動化を導入してください。
継続的なテスト実行により、安心してリファクタリングや機能追加ができる開発環境を構築し、より良いユーザー体験の提供につなげていきましょう。
関連リンク
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
- review
人類はなぜ地球を支配できた?『サピエンス全史 上巻』ユヴァル・ノア・ハラリが解き明かす驚愕の真実
- review
え?世界はこんなに良くなってた!『FACTFULNESS』ハンス・ロスリングが暴く 10 の思い込みの正体
- review
瞬時に答えが出る脳に変身!『ゼロ秒思考』赤羽雄二が贈る思考力爆上げトレーニング
- review
関西弁のゾウに人生変えられた!『夢をかなえるゾウ 1』水野敬也が教えてくれた成功の本質