T-CREATOR

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

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

React アプリケーションの状態管理において、「テストが書きにくい」「デバッグが困難」といった課題に直面したことはありませんか? 特に複雑な状態管理ライブラリでは、テストコードが本体コードよりも複雑になってしまうケースも少なくありません。

しかし、軽量でシンプルな状態管理ライブラリ ZustandJest を組み合わせることで、この問題を効率的に解決できます。 本記事では、実際のコード例とエラーパターンを交えながら、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 層構造で考えると効率的です:

  1. ユニットテスト層:個別のストア機能をテスト
  2. 統合テスト層:複数ストア間の連携をテスト
  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 との相性が良く、バグの早期発見が可能

実装のポイント

  1. 段階的なテスト構造:ユニット → 統合 →E2E の 3 層でテスト設計
  2. 適切なモック化:外部依存関係を分離してテストを安定化
  3. 非同期処理の考慮:act()や waitFor()を活用した確実なテスト
  4. エラーケースの網羅:正常系だけでなく異常系もしっかりテスト

特に、API 連携を含む実際のアプリケーションでは、適切なモック化と非同期処理のテストが品質向上の鍵となります。 本記事で紹介したパターンを参考に、ぜひ皆様のプロジェクトでも Zustand のテスト自動化を導入してください。

継続的なテスト実行により、安心してリファクタリングや機能追加ができる開発環境を構築し、より良いユーザー体験の提供につなげていきましょう。

関連リンク