dispatch({ type: 'FETCH_USER_SUCCESS' }) が正確なペイロードの形で呼ばれたかどうかを検証するテストを書いたことがあるなら、それは誰かが定数の名前を変更するたびに壊れるテストを書いたということです。

それは状態のロジックをテストしているのではありません。あなたの指が正しい文字列を打ったかどうかをテストしているだけです。

Reduxのテストに関するチュートリアルは、Jestのモックから始まることが多いです:dispatch をスパイし、アクションクリエータが呼ばれたことをアサートし、タイプが一致することをアサートして、完了。チュートリアルではそれでうまくいきます。しかし実際のコードベースでは、アクションのタイプが変わったり、ペイロードがリファクタリングされたりすると崩壊し、テストはアプリ内のすべての dispatch 呼び出しのToDoリストになってしまいます。

代替案は、個々のアクションクリエータではなく、ストアを一つの単位としてテストすることです。

アクションごとのテストがスケールで崩壊する理由

データを取得し、ローディング状態を処理し、エラーを表示するユーザープロフィールコンポーネントを想像してみてください。素朴なテストは次のようになります:

// The mock spiral
jest.mock('./api', () => ({ fetchUser: jest.fn() }));

it('dispatches fetch user action', async () => {
  const dispatch = jest.fn();
  await loadUser(42)(dispatch);
  expect(dispatch).toHaveBeenCalledWith({
    type: 'FETCH_USER_REQUEST',
  });
  expect(dispatch).toHaveBeenCalledWith({
    type: 'FETCH_USER_SUCCESS',
    payload: { id: 42, name: 'Ada' },
  });
});

このテストは通ります。しかし、誰かが成功アクションに meta フィールドを追加したり、ペイロードを data キーでラップしたり、createAsyncThunk に切り替えたりした瞬間に失敗します。

さらに重要なことに、このテストはそのアクションに対してreducerが実際に 何かをしたかどうか について、何も教えてくれません。そのアクションはreducerに無視されても、テストは依然としてグリーンのままかもしれません。

インテグレーションテストのアプローチ:ストアをブラックボックスとして扱う

ストアはすでに純粋関数です。状態とアクションが与えられれば、新しい状態を返します。最も誠実なテストは、アクションを与えて、その結果の状態を検査することです。

reducerをモックする必要はありません。アクションクリエータをモックする必要もありません。実際のストアをセットアップし、実際のアクション(またはアクションクリエータ)をディスパッチし、結果の状態を読み取ります。

import { configureStore } from '@reduxjs/toolkit';
import { userReducer } from './userSlice';

function createTestStore(preloadedState) {
  return configureStore({
    reducer: { user: userReducer },
    preloadedState,
  });
}

it('loads a user into state', () => {
  const store = createTestStore();
  store.dispatch({ type: 'user/fetchUser/fulfilled', payload: { id: 42, name: 'Ada' } });

  expect(store.getState().user.entities[42]).toEqual({ id: 42, name: 'Ada' });
});

これは1行のdispatchと1つのアサーションです。UIが実際に依存している契約、つまり何かが起こった後に状態が特定の形になっているかどうかをテストしています。

実際のストアを使ったthunkと非同期ロジックのテスト

非同期ミドルウェアのテストは、多くのチームがモックに頼る場所です。しかし、そうする必要はありません。

Redux Toolkitを使用している場合、configureStore にはすでに redux-thunk とdevtoolsミドルウェアが含まれています。同じミドルウェアスタックを持つテストストアは、本番のストアと同じように動作します。

import { configureStore } from '@reduxjs/toolkit';
import { userReducer, fetchUser } from './userSlice';

function createTestStore(preloadedState) {
  return configureStore({
    reducer: { user: userReducer },
    preloadedState,
    middleware: (getDefault) => getDefault(), // same thunk middleware
  });
}

it('handles the full fetch lifecycle', async () => {
  const store = createTestStore();

  // dispatch the thunk
  const promise = store.dispatch(fetchUser(42));

  // check loading state immediately
  expect(store.getState().user.loading).toBe('pending');

  await promise;

  // check final state
  expect(store.getState().user.loading).toBe('idle');
  expect(store.getState().user.entities[42].name).toBe('Ada');
});

fetchUser がAPIを呼び出す場合、ネットワークレイヤーを処理する必要は依然としてあります。しかし、それはReduxの問題ではなくfetchの問題です。HTTPリクエストをインターセプトするために msw を使用するか、thunkの extraArgument を通じてカスタムAPIクライアントを渡してください。どちらにしても、アサーションは状態に集中し、どの関数がどの順序で呼ばれたかではなくなります。

モックすべき時とそうでない時

私はモックをゼロにしろと主張しているわけではありません。モックはシステムの境界で適切です:HTTPリクエスト、ブラウザAPI、タイマー、副作用のある外部ライブラリなどです。

reducerはシステムの境界ではありません。それらはあなたのコードベース内の決定論的関数です。それらをモックすることは、filter 呼び出しをモックすることのようなものです。

アクションクリエータ、特にthunkは、オーケストレーションロジックです。それらの内部のdispatch呼び出しではなく、状態への影響を通じてテストすることができます。これにより、テストとアクションの形状との結合が取り除かれます。

トレードオフは速度です。完全なreducerスタックとミドルウェアを実行するテストは、モックをアサートするテストよりも遅くなります。実際には、その差はミリ秒単位です。テストスイートが遅い場合、犯人はほとんどの場合Reduxストアのテストではありません。ファイルごとにJSDOMインスタンスを立ち上げたり、実際のデータベースを呼び出したり、タイマーのクリーンアップを怠ったりしていることが原因です。

Redux Toolkitを使っていない場合はどうすればよいか?

プレーンなReduxも同じように動作します。createStore でストアを構築し、ミドルウェアを適用して、dispatchします。

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import { rootReducer } from './reducers';

function createTestStore(preloadedState) {
  return createStore(rootReducer, preloadedState, applyMiddleware(thunk));
}

it('toggles the feature flag', () => {
  const store = createTestStore({ features: { darkMode: false } });
  store.dispatch({ type: 'features/TOGGLE_DARK_MODE' });

  expect(store.getState().features.darkMode).toBe(true);
});

複雑なミドルウェアチェーン(ロギング、アナリティクス、sagaなど)がある場合は、テストストアに含めるか、削減してください。目標は、コンポーネントが依存する動作をテストすることであり、本番環境を正確に再現することではありません。

セレクタテスト:契約のもう半分

ストアを単独でテストするのは、物語の半分にすぎません。コンポーネントはセレクタを通じて状態を読み取ります。セレクタのテストも同様にシンプルで、同様に価値があります。

import { selectUserById, selectActiveUsers } from './userSelectors';

it('selects only active users', () => {
  const state = {
    user: {
      entities: {
        1: { id: 1, name: 'Ada', active: true },
        2: { id: 2, name: 'Grace', active: false },
      },
      ids: [1, 2],
    },
  };

  expect(selectActiveUsers(state)).toHaveLength(1);
  expect(selectActiveUsers(state)[0].name).toBe('Ada');
});

セレクタのテストはストアに全く触れないことに注目してください。状態の形を受け取り、派生値を返します。これがそれを高速にし、精密にします。reducerとセレクタの間の契約は状態の形であり、両方は独立してテストできます。

大規模なコードベース向けのパターン

多くのスライスを持つコードベースでは、テストファイルごとにテストストアを作成するのは繰り返しになります。一元化しましょう。

// test-utils/store.js
import { configureStore } from '@reduxjs/toolkit';
import { rootReducer } from '../src/store';

export function createTestStore(preloadedState = {}) {
  return configureStore({
    reducer: rootReducer,
    preloadedState,
    middleware: (getDefault) =>
      getDefault({
        // disable immutable and serializable checks in tests
        // to avoid noise when testing edge cases
        immutableCheck: false,
        serializableCheck: false,
      }),
  });
}

次に、すべてのテストで createTestStore をインポートします。一貫性により、チームメイトが同じように動作するテストを書きやすくなり、ミドルウェアの調整やテスト専用のreducerの追加を行う場所が1つにまとまります。

このアプローチが通用しなくなる場面

アクションレベルでテストしたい場合があります。アクションを変換するカスタムミドルウェアがある場合は、変換後のアクションをアサートする必要があります。アクションクリエータをエクスポートするライブラリを構築している場合、アクションの形状 パブリックAPIです。

しかし、アプリケーションコードでは、アクションは実装の詳細です。重要なのは状態です。

FAQ

Reactのコネクテッドコンポーネントをテストすべきですか?

モックされたpropsでコンポーネントを単独でテストするか、@testing-library/react と実際のプロバイダーでインテグレーションをテストしてください。どちらも有効です。重要なのは、コンポーネントを通じてReduxの配管をテストしないことです。ユーザーが見るものをテストしてください。

sagaはどうですか?

Redux-Sagaは独自の制御フローを管理するため、別物です。sagaは redux-saga-test-plan で実行して状態変化をアサートすることでテストでき、あるいは cloneableGenerator で単独でテストすることもできます。sagaは、エフェクトをモックする価値が通常ある唯一の場所です。

アナリティクスのような副作用はどう処理すればよいですか?

それらをミドルウェアに抽出して別々にテストするか、extraArgument パターンを使用してthunkにモックのアナリティクスクライアントを注入してください。

これはZustandや他のステートマネージャーでも機能しますか?

はい。原則は同じです:ストアを初期化し、状態変化をトリガーし、状態を読み取ります。Zustandのストアは、多くの場合ただの関数であるため、さらに簡単です。

1つのスライスで試してみよう

最も頻繁に触るスライスを選んでください。モックだらけのテストを1つ、実際のストアを使ったテストに置き換えてください。実行してみてください。10ms以上遅くなったら、コーヒーをおごります。

多くのチームは、dispatch呼び出しを検証するのをやめて状態を検証し始めると、Reduxのテストが短くなり、リファクタリングを生き延び、実際にバグを捕まえることがわかります。