T-CREATOR

Jest でストア(Zustand・Redux)の状態をテストする

Jest でストア(Zustand・Redux)の状態をテストする

現代のフロントエンド開発において、状態管理はアプリケーションの核となる部分です。Zustand や Redux などのストア管理ライブラリを使用することで、アプリケーションの状態を効率的に管理できるようになりました。

しかし、ストアの状態が正しく動作していることを保証するためには、適切なテストが不可欠です。Jest とストア管理ライブラリを組み合わせることで、状態の変化やアクションの実行結果を確実に検証できます。

この記事では、実際のコード例とエラーケースを含めて、ストアの状態テストを実践的に学んでいきます。初心者の方でも理解しやすいよう、段階的に解説していきますので、ぜひ最後までお付き合いください。

ストアテストの基礎知識

なぜストアの状態テストが必要なのか

ストアの状態テストが重要な理由は、アプリケーションの信頼性を確保するためです。状態管理に問題があると、以下のような深刻なバグが発生する可能性があります。

  • ユーザーの操作が正しく反映されない
  • データの整合性が保たれない
  • 予期しない副作用が発生する

実際の開発現場では、以下のようなエラーがよく発生します:

typescript// よくあるエラー例:状態の更新が反映されない
const useStore = create((set) => ({
  count: 0,
  increment: () =>
    set((state) => ({ count: state.count + 1 })),
}));

// テストで検出できる問題
test('increment should update count', () => {
  const store = useStore.getState();
  store.increment();
  // エラー:状態が更新されていない
  expect(store.count).toBe(1); // 実際は 0 のまま
});

このような問題を事前に発見し、修正するために、ストアの状態テストが重要な役割を果たします。

Jest とストア管理ライブラリの関係性

Jest は JavaScript のテストフレームワークとして広く使用されており、ストア管理ライブラリとの相性も抜群です。Jest の特徴的な機能が、ストアテストを効率的に行うことを可能にします。

Jest の主要な機能:

  • モック機能: 外部依存関係を簡単にモック化
  • スナップショットテスト: 状態の変化を視覚的に確認
  • 非同期テスト: Promise や async/await のテストが容易
  • カバレッジレポート: テストの網羅性を確認

これらの機能を活用することで、ストアの状態テストを効果的に実行できます。

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

まず、必要なパッケージをインストールしてテスト環境を構築しましょう。

bash# 必要なパッケージのインストール
yarn add -D jest @types/jest @testing-library/react @testing-library/jest-dom
yarn add zustand redux @reduxjs/toolkit react-redux

次に、Jest の設定ファイルを作成します。

javascript// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/src/setupTests.js'],
  moduleNameMapping: {
    '^@/(.*)$': '<rootDir>/src/$1',
  },
  collectCoverageFrom: [
    'src/**/*.{js,jsx,ts,tsx}',
    '!src/**/*.d.ts',
  ],
};

テストのセットアップファイルも作成します。

javascript// src/setupTests.js
import '@testing-library/jest-dom';

// グローバルなモック設定
global.console = {
  ...console,
  // テスト中の警告を抑制
  warn: jest.fn(),
  error: jest.fn(),
};

Zustand ストアのテスト実践

Zustand ストアの基本構造

Zustand は軽量で使いやすい状態管理ライブラリです。まず、基本的なストアの構造を理解しましょう。

typescript// src/stores/counterStore.ts
import { create } from 'zustand';

interface CounterState {
  count: number;
  isLoading: boolean;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
  asyncIncrement: () => Promise<void>;
}

export const useCounterStore = create<CounterState>(
  (set, get) => ({
    count: 0,
    isLoading: false,

    increment: () =>
      set((state) => ({ count: state.count + 1 })),
    decrement: () =>
      set((state) => ({ count: state.count - 1 })),
    reset: () => set({ count: 0 }),

    asyncIncrement: async () => {
      set({ isLoading: true });
      await new Promise((resolve) =>
        setTimeout(resolve, 100)
      );
      set((state) => ({
        count: state.count + 1,
        isLoading: false,
      }));
    },
  })
);

このストアには、同期的なアクションと非同期のアクションが含まれています。それぞれのテスト方法を見ていきましょう。

状態の初期値テスト

まず、ストアの初期状態が正しく設定されているかをテストします。

typescript// src/stores/__tests__/counterStore.test.ts
import { useCounterStore } from '../counterStore';

describe('Counter Store', () => {
  beforeEach(() => {
    // 各テスト前にストアをリセット
    useCounterStore.setState({
      count: 0,
      isLoading: false,
    });
  });

  test('初期状態が正しく設定されている', () => {
    const state = useCounterStore.getState();

    expect(state.count).toBe(0);
    expect(state.isLoading).toBe(false);
  });
});

このテストでは、beforeEach フックを使用して各テストの前にストアをリセットしています。これにより、テスト間の状態の干渉を防げます。

アクション実行後の状態変化テスト

次に、アクションを実行した後の状態変化をテストします。

typescript// 同期的なアクションのテスト
describe('Counter Store Actions', () => {
  beforeEach(() => {
    useCounterStore.setState({
      count: 0,
      isLoading: false,
    });
  });

  test('increment アクションでカウントが増加する', () => {
    const { increment } = useCounterStore.getState();

    increment();

    const { count } = useCounterStore.getState();
    expect(count).toBe(1);
  });

  test('decrement アクションでカウントが減少する', () => {
    // 初期値を設定
    useCounterStore.setState({ count: 5 });
    const { decrement } = useCounterStore.getState();

    decrement();

    const { count } = useCounterStore.getState();
    expect(count).toBe(4);
  });

  test('reset アクションでカウントが0にリセットされる', () => {
    // 初期値を設定
    useCounterStore.setState({ count: 10 });
    const { reset } = useCounterStore.getState();

    reset();

    const { count } = useCounterStore.getState();
    expect(count).toBe(0);
  });
});

このテストでは、各アクションの実行前後で状態が正しく変化することを確認しています。特に重要なのは、テストの独立性を保つために beforeEach で状態をリセットしている点です。

非同期アクションのテスト

非同期アクションのテストは少し複雑になりますが、Jest の非同期テスト機能を活用することで確実にテストできます。

typescript// 非同期アクションのテスト
describe('Async Actions', () => {
  beforeEach(() => {
    useCounterStore.setState({
      count: 0,
      isLoading: false,
    });
  });

  test('asyncIncrement アクションでローディング状態が正しく管理される', async () => {
    const { asyncIncrement } = useCounterStore.getState();

    // 非同期アクションを開始
    const incrementPromise = asyncIncrement();

    // ローディング状態を確認
    expect(useCounterStore.getState().isLoading).toBe(true);

    // アクションの完了を待つ
    await incrementPromise;

    // 最終状態を確認
    const { count, isLoading } = useCounterStore.getState();
    expect(count).toBe(1);
    expect(isLoading).toBe(false);
  });

  test('複数の非同期アクションが同時実行されても正しく動作する', async () => {
    const { asyncIncrement } = useCounterStore.getState();

    // 複数の非同期アクションを同時実行
    const promises = [
      asyncIncrement(),
      asyncIncrement(),
      asyncIncrement(),
    ];

    await Promise.all(promises);

    const { count } = useCounterStore.getState();
    expect(count).toBe(3);
  });
});

非同期アクションのテストでは、ローディング状態の管理や複数のアクションの同時実行など、実際のアプリケーションで発生しうるシナリオを考慮することが重要です。

Redux ストアのテスト実践

Redux ストアの基本構造

Redux はより構造化された状態管理ライブラリです。まず、基本的な Redux ストアの構造を見てみましょう。

typescript// src/stores/redux/counterSlice.ts
import {
  createSlice,
  createAsyncThunk,
  PayloadAction,
} from '@reduxjs/toolkit';

interface CounterState {
  count: number;
  isLoading: boolean;
  error: string | null;
}

const initialState: CounterState = {
  count: 0,
  isLoading: false,
  error: null,
};

// 非同期アクション
export const asyncIncrement = createAsyncThunk(
  'counter/asyncIncrement',
  async (_, { rejectWithValue }) => {
    try {
      await new Promise((resolve) =>
        setTimeout(resolve, 100)
      );
      return 1;
    } catch (error) {
      return rejectWithValue('非同期処理に失敗しました');
    }
  }
);

const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      state.count += 1;
    },
    decrement: (state) => {
      state.count -= 1;
    },
    reset: (state) => {
      state.count = 0;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(asyncIncrement.pending, (state) => {
        state.isLoading = true;
        state.error = null;
      })
      .addCase(
        asyncIncrement.fulfilled,
        (state, action) => {
          state.isLoading = false;
          state.count += action.payload;
        }
      )
      .addCase(asyncIncrement.rejected, (state, action) => {
        state.isLoading = false;
        state.error = action.payload as string;
      });
  },
});

export const { increment, decrement, reset } =
  counterSlice.actions;
export default counterSlice.reducer;

Redux ストアの設定も行います。

typescript// src/stores/redux/store.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Reducer の単体テスト

Redux では、Reducer を単体でテストすることが一般的です。Reducer は純粋関数なので、テストが書きやすいという利点があります。

typescript// src/stores/redux/__tests__/counterSlice.test.ts
import counterReducer, {
  increment,
  decrement,
  reset,
} from '../counterSlice';

describe('Counter Reducer', () => {
  const initialState = {
    count: 0,
    isLoading: false,
    error: null,
  };

  test('初期状態が正しく設定されている', () => {
    const state = counterReducer(undefined, {
      type: 'unknown',
    });
    expect(state).toEqual(initialState);
  });

  test('increment アクションでカウントが増加する', () => {
    const state = counterReducer(initialState, increment());
    expect(state.count).toBe(1);
  });

  test('decrement アクションでカウントが減少する', () => {
    const state = counterReducer(
      { ...initialState, count: 5 },
      decrement()
    );
    expect(state.count).toBe(4);
  });

  test('reset アクションでカウントが0にリセットされる', () => {
    const state = counterReducer(
      { ...initialState, count: 10 },
      reset()
    );
    expect(state.count).toBe(0);
  });
});

Reducer のテストでは、各アクションに対して期待される状態変化を確認します。純粋関数なので、副作用を気にすることなくテストできます。

Action Creator のテスト

Action Creator のテストも重要です。特に、ペイロードを持つアクションの場合は、正しいデータが渡されているかを確認する必要があります。

typescript// Action Creator のテスト例
import {
  createSlice,
  PayloadAction,
} from '@reduxjs/toolkit';

// ペイロードを持つアクションの例
const userSlice = createSlice({
  name: 'user',
  initialState: { name: '', age: 0 },
  reducers: {
    setUser: (
      state,
      action: PayloadAction<{ name: string; age: number }>
    ) => {
      state.name = action.payload.name;
      state.age = action.payload.age;
    },
  },
});

// テスト
describe('User Actions', () => {
  test('setUser アクションが正しいペイロードを持つ', () => {
    const { setUser } = userSlice.actions;
    const action = setUser({ name: '田中太郎', age: 30 });

    expect(action.type).toBe('user/setUser');
    expect(action.payload).toEqual({
      name: '田中太郎',
      age: 30,
    });
  });
});

非同期 Action(Redux Thunk)のテスト

Redux Thunk を使用した非同期アクションのテストは、少し複雑になりますが、Jest のモック機能を活用することで効果的にテストできます。

typescript// 非同期アクションのテスト
import { asyncIncrement } from '../counterSlice';

describe('Async Actions', () => {
  test('asyncIncrement が成功した場合の状態変化', async () => {
    const initialState = {
      count: 0,
      isLoading: false,
      error: null,
    };

    // pending 状態のテスト
    const pendingState = counterReducer(
      initialState,
      asyncIncrement.pending
    );
    expect(pendingState.isLoading).toBe(true);
    expect(pendingState.error).toBe(null);

    // fulfilled 状態のテスト
    const fulfilledState = counterReducer(
      pendingState,
      asyncIncrement.fulfilled(1, '')
    );
    expect(fulfilledState.isLoading).toBe(false);
    expect(fulfilledState.count).toBe(1);
  });

  test('asyncIncrement が失敗した場合の状態変化', async () => {
    const initialState = {
      count: 0,
      isLoading: false,
      error: null,
    };

    // pending 状態のテスト
    const pendingState = counterReducer(
      initialState,
      asyncIncrement.pending
    );

    // rejected 状態のテスト
    const rejectedState = counterReducer(
      pendingState,
      asyncIncrement.rejected(
        new Error('テストエラー'),
        '',
        undefined,
        '非同期処理に失敗しました'
      )
    );
    expect(rejectedState.isLoading).toBe(false);
    expect(rejectedState.error).toBe(
      '非同期処理に失敗しました'
    );
  });
});

テストパターンとベストプラクティス

モックの活用方法

外部依存関係や API コールをテストする際は、モックを活用することが重要です。

typescript// API コールのモック例
import { createAsyncThunk } from '@reduxjs/toolkit';

// API 関数のモック
const mockApi = {
  fetchUser: jest.fn(),
  updateUser: jest.fn(),
};

// 非同期アクション
export const fetchUser = createAsyncThunk(
  'user/fetchUser',
  async (userId: string) => {
    const response = await mockApi.fetchUser(userId);
    return response.data;
  }
);

// テスト
describe('API Actions', () => {
  beforeEach(() => {
    jest.clearAllMocks();
  });

  test('fetchUser が成功した場合', async () => {
    const mockUser = { id: '1', name: '田中太郎' };
    mockApi.fetchUser.mockResolvedValue({ data: mockUser });

    const action = await fetchUser('1');

    expect(mockApi.fetchUser).toHaveBeenCalledWith('1');
    expect(action.payload).toEqual(mockUser);
  });

  test('fetchUser が失敗した場合', async () => {
    const error = new Error('API エラー');
    mockApi.fetchUser.mockRejectedValue(error);

    const action = await fetchUser('1');

    expect(action.error.message).toBe('API エラー');
  });
});

テストデータの管理

テストデータを効率的に管理するために、ファクトリーパターンを使用することが推奨されます。

typescript// テストデータファクトリー
export const createTestUser = (overrides = {}) => ({
  id: '1',
  name: '田中太郎',
  email: 'tanaka@example.com',
  age: 30,
  ...overrides,
});

export const createTestCounterState = (overrides = {}) => ({
  count: 0,
  isLoading: false,
  error: null,
  ...overrides,
});

// テストでの使用例
test('ユーザー情報の更新', () => {
  const initialState = createTestCounterState({ count: 5 });
  const user = createTestUser({ name: '佐藤花子' });

  // テストロジック...
});

エラーハンドリングのテスト

エラーハンドリングのテストは、アプリケーションの堅牢性を確保するために重要です。

typescript// エラーハンドリングのテスト例
describe('Error Handling', () => {
  test('ネットワークエラーの処理', async () => {
    // ネットワークエラーをシミュレート
    global.fetch = jest
      .fn()
      .mockRejectedValue(new Error('Network Error'));

    const { asyncIncrement } = useCounterStore.getState();

    try {
      await asyncIncrement();
    } catch (error) {
      expect(error.message).toBe('Network Error');
    }
  });

  test('無効なデータの処理', () => {
    const invalidData = null;

    // 無効なデータに対する処理をテスト
    expect(() => {
      // データの検証ロジック
      if (!invalidData) {
        throw new Error('データが無効です');
      }
    }).toThrow('データが無効です');
  });
});

まとめ

Jest を使用したストア(Zustand・Redux)の状態テストについて、実践的なアプローチで解説してきました。

重要なポイント:

  1. 初期状態のテスト: ストアが正しく初期化されていることを確認
  2. アクションのテスト: 各アクションが期待通りの状態変化を引き起こすことを検証
  3. 非同期処理のテスト: ローディング状態やエラーハンドリングを含めた包括的なテスト
  4. モックの活用: 外部依存関係を適切にモック化してテストの独立性を確保
  5. エラーハンドリング: 異常系のテストでアプリケーションの堅牢性を向上

これらのテストを実装することで、ストアの状態管理が正しく動作していることを確信を持って保証できます。また、リファクタリングや新機能の追加時にも、既存の機能が壊れていないことを素早く確認できるようになります。

テストは開発の負担ではなく、むしろ開発効率を向上させる投資です。適切なテストを書くことで、バグの早期発見や、コードの品質向上につながります。

ぜひ、この記事で学んだ内容を実際のプロジェクトに適用して、より信頼性の高いアプリケーションを開発してください。

関連リンク