React + TypeScript + Jest + react-testing-libraryのテスト その4: Redux後編

前回からの続きです。
Fetch APIでの非同期処理と、middlewareのテストについてやっていきます。

はじめに

カラーパレットに、「本日のラッキーカラー」を取得する機能を追加するとします。
仮引数にトークンを渡すと、その日のおすすめカラーのlabelとcolorCodeを出力するAPIがあるとします。
そのエンドポイントへの接続はFetchLuckyColor関数で行います。

fetchのテスト

FetchLuckyColor関数を作成します。

api.ts
import { DataType } from '@/modules/ColorPanelList/types';

export const FetchLuckyColor = (token: string | null): Promise<DataType> => {
  return new Promise((resolve, reject) => {
    if (!token) reject(new Error('invalid token'));
    setTimeout(() => {
      resolve({
        label: '情熱の赤',
        colorCode: 'red',
      });
    }, 1000);
  });
};

今回は、エラーを想定したテストも記述する必要があります。

specs/api/FetchLuckyColor.spec.ts
import 'regenerator-runtime/runtime';
import { FetchLuckyColor } from '@/modules/ColorPanelList/api';

describe('FetchLuckyColor', () => {
  it('トークンを入れると、labelとcolorCodeを含むデータが返る', async () => {
    const data = await FetchLuckyColor('myToken');
    expect(data).toHaveProperty('label');
    expect(data).toHaveProperty('colorCode');
  });

  it('トークンがない場合はreject', async () => {
    await FetchLuckyColor('').catch(error => {
      expect(error.message).toBe('invalid token');
    });
  });
});

mock

先ほどは例としてsetTimeoutを用いましたが、実際ここがfetchの処理になります。
次はFetch APIで、データを受信するケースでのテストを書きます。
https://foo.bar.baz/api/v1/hoge/にPOSTリクエストを送って、トークンの検証などを行なってからデータを返すものです。

api.ts
import { DataType } from '@/modules/ColorPanelList/types';

export const FetchLuckyColor = (token: string | null): Promise<DataType> => {
  return new Promise((resolve, reject) => {
    if (!token) reject(new Error('invalid token'));
    fetch('https://foo.bar.baz/api/v1/hoge/', {
      method: 'POST',
      mode: 'cors',
      headers: {
        'Content-Type': 'application/json; charset=utf-8',
        'X-Requested-With': 'XMLHttpRequest',
      },
      body: JSON.stringify({ token }),
    })
      .then(res => resolve(res.json()))
      .catch(error => reject(new Error(error.message)));
  });
};

2つの問題があります。
まず、実際にブラウザで動作させるわけではないので、テストを実行してもfetch is not definedが出ます。
そして、関数をテストするためにhttps://foo.bar.bazへアクセスするのは非現実的です。

この関数のテストは、Fetch APIの動作を検証するものではなくて、仮引数にトークンを渡し、値やエラーが正しく出力されるかを期待するものです。
そこで、Fetch APIの振る舞いをモック化して、任意の値を返せるようにします。

Fetch API向けにjest-fetch-mockというライブラリがあるので、これをインストールします。
https://github.com/jefflau/jest-fetch-mock/issues

shell
yarn add -D jest-fetch-mock

rootにsetupJest.tsを作成して、下記で保存します。

setupJest.ts
import { GlobalWithFetchMock } from 'jest-fetch-mock';

const customGlobal: GlobalWithFetchMock = global as GlobalWithFetchMock;
customGlobal.fetch = require('jest-fetch-mock');
customGlobal.fetchMock = customGlobal.fetch;

jest.configファイルに下記の2項目を追加します。

jest.config.js
module.exports = {
  // ...(既存の設定),
  automock: false,
  setupFiles: ['./setupJest.ts'],
};

テストコードを書いていきます。
fetchMock.mockResponseOnce()fetchMock.mockReject()を使って、Fetch APIの結果を偽装しています。
これでFetch APIの振る舞いを気にせず、FetchLuckyColorに集中したテストを書くことができます。

(fetchMockではなくfetchをそのまま使用することもできますが、元のfetchの型を使用するので、エディタ上でエラーを出し補完も効きません。)
(fetchMockを使用する場合、esLintのno-undefに引っかかるので、インラインで無効化するなどします)

specs/API/FetchLuckyColor.spec.ts
import 'regenerator-runtime/runtime';
import { FetchLuckyColor } from '@/modules/ColorPanelList/api';

describe('FetchLuckyColor', () => {

  // 初期化処理
  beforeEach(() => {
    fetchMock.resetMocks();
  });

  it('labelとcolorCodeを含んでいる', async () => {

    // fetchで想定されるレスポンスのmockを用意する
    fetchMock.mockResponseOnce(
      JSON.stringify({ label: '情熱の赤', colorCode: 'red' })
    );

    // この中で行われるfetch()は、上記のmockを返す
    const data = await FetchLuckyColor('myToken');

    expect(data).toHaveProperty('label');
    expect(data).toHaveProperty('colorCode');
  });

  it('トークンがない場合はrejectする', async () => {
    await FetchLuckyColor('').catch(error => {
      expect(error.message).toBe('invalid token');
    });
  });

  it('送信したトークンが無効の時はエラー', async () => {
    // rejectした時の値をmockする
    fetchMock.mockReject(new Error('invalid token'));
    await FetchLuckyColor('').catch(error => {
      expect(error.message).toBe('invalid token');
    });
  });
});

middlewareのテスト

ではこのAPIをmiddleware経由で呼び出して、Redux側でdispatchするものをテストします。
今回はredux-thunkredux-sagaの2種類によるテストを行ってみます。

redux-thunkのテスト

まず、redux-thunkでのテストです。
redux-thunkは、Actionの代わりとしてthunkのようなもの(遅延評価させるために関数を返す)を導入します。
具体的には、Action Creatorが関数を返せるようになるものです。

仕組みは非常にシンプルで、受け取ったactionが関数かどうかを判定しているだけのようです。
(また、任意の関数などを注入するためにextraArgumentというものがあります)

redux-thunk
function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => (next) => (action) => {
    if (typeof action === 'function') {      return action(dispatch, getState, extraArgument);    }
    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;

このmiddlewareを用いると、非同期処理はAction Creator側に書かれます。
成功した場合はaddColorPanelsuccessFetchLuckyColorDataをdispatchします。
失敗した場合はfailureFetchLuckyColorData()をdispatchします。

actions.ts
import { FetchLuckyColor } from './api';

export const fetchLuckyColorDataWithThunk = () => {
  return (dispatch: Dispatch, getState: () => UserStateType) => {
    return FetchLuckyColor(getState().token)
      .then(res => {
        dispatch(addColorPanel({ id: 12345, ...res }));
        dispatch(successFetchLuckyColorData());
      })
      .catch(() => {
        dispatch(failureFetchLuckyColorData());
      });
  };
};

このアクションのテストは、少し面倒になります。
FetchLuckyColorの仮引数には、Store内にあるトークン情報が必要です。
そして、successやfailureでdispatchされるアクションについても、テストしていかないといけません。
また、実際のstoreを読み込むと、テストがstore内のmiddlewareに依存してしまいます。

storeの振る舞いをmock化するために、redux-mock-storeというのを使います。

shell
yarn add -D redux-mock-store @types/redux-mock-store

そして、configureStoreの引数にmiddlwareの配列を渡します。
これで、thunk以外のmiddlewareを考えないようにすることができました。

specs/ColorPanelList/thunk.spec.ts
import thunk from 'redux-thunk';

const mockStore = configureStore([thunk]);

redux-mock-storeでstoreのmockを作成し、テストを書きます。
先ほどの項目でやったように、Fetch APIのmockにはjest-fetch-mockを使っています。
redux-mock-storestore.getActions()を使用すると、実行したアクションの配列を返します。
これをmatcherで判定し、Actionがdispatchされたか調べていきます。

specs/ColorPanelList/thunk.spec.ts
import 'regenerator-runtime/runtime';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import { fetchLuckyColorDataWithThunk } from '@/modules/ColorPanelList/actions';

// configureStoreの仮引数に、middlewareを渡す
const mockStore = configureStore([thunk]);

describe('fetchLuckyColorDataWithThunk', () => {
  beforeEach(() => {
    fetchMock.resetMocks();
  });

  it('addColorPanelとsuccessアクションをdispatchする', async () => {
    // jest-fetch-mock
    fetchMock.mockResponseOnce(
      JSON.stringify({ id: 12345, label: '情熱の赤', colorCode: 'red' })
    );

    // fetchLuckyColorDataWithThunkがstateの値に依存しているので、仮のstateを渡す
    const store = mockStore({ token: 'foo' });

    await store.dispatch(fetchLuckyColorDataWithThunk());

    // getActions()で実行したActionが返るので、期待したActionが実行されているかテストする
    expect(store.getActions()).toEqual([
      {
        type: 'addColorPanel',
        payload: {
          id: 12345,
          label: '情熱の赤',
          colorCode: 'red',
        },
      },
      {
        type: 'successFetchLuckyColorData',
      },
    ]);
  });

  it('トークンがない時は、failureアクションをdispatchする', async () => {
    fetchMock.mockResponseOnce(
      JSON.stringify({ id: 12345, label: '情熱の赤', colorCode: 'red' })
    );
    const store = mockStore({ token: '' });
    await store.dispatch(fetchLuckyColorDataWithThunk());
    expect(store.getActions()).toEqual([
      {
        type: 'failureFetchLuckyColorData',
      },
    ]);
  });
});

色々とmockを用意しなければならないのが面倒です。

redux-sagaのテスト

次はredux-sagaのテストです。
redux-sagaは、Sagaと呼ばれるプロセスのようなものを導入し、その中に副作用を押し付けます。

redux-thunkと違って、Sagaはプロセスのようなものを個々に切り出せます。
そこにテストを集中して書くことができます。

saga.ts
import { select, call, put, takeEvery } from 'redux-saga/effects';
import {
  addColorPanel,
  failureFetchLuckyColorData,
  successFetchLuckyColorData,
} from '@/modules/ColorPanelList/actions';
import { AppState } from '@/store/';
import { DataType } from '@/modules/ColorPanelList/types';
import * as Api from './api';

export const getUser = (state: AppState) => state.User;
export const getItems = (state: AppState) => state.ColorPanelList.items;

export function* handleFetchLuckyColorData() {
  try {
    const user: ReturnType<typeof getUser> = yield select(getUser);
    const items: ReturnType<typeof getItems> = yield select(getItems);
    const token = user && user.data && user.data.token ? user.data.token : null;
    const data: DataType = yield call(Api.FetchLuckyColor, token);
    yield put<ReturnType<typeof addColorPanel>>({
      type: 'addColorPanel',
      payload: {
        id: items.length,
        label: data.label,
        colorCode: data.colorCode,
      },
    });
    yield put<ReturnType<typeof successFetchLuckyColorData>>({
      type: 'successFetchLuckyColorData',
    });
  } catch (error) {
    yield put<ReturnType<typeof failureFetchLuckyColorData>>({
      type: 'failureFetchLuckyColorData',
    });
  }
}

function* mySaga() {
  yield takeEvery('fetchLuckyColorData', handleFetchLuckyColorData);
}

export default mySaga;

Sagaの実態はyield式で、next()を使ったステップ形式でのテストができます。
外部のAPI(例で言えば、FetchLuckyColorなど)の結果を知らなくても問題ありません。
実行できているかどうかをひたすらテストするだけです。が…。

spec/ColorPanelList/saga.spec.ts
import 'regenerator-runtime/runtime';
import { addColor, failureFetchLuckyColor } from '@/modules/ColorPanelList/actions';
import { handleFetchLuckyColorData } from '@/modules/ColorPanelList/sagas';
import * as Api from '@/modules/ColorPanelList/api';
import { getUser } from '@/modules/ColorPanelList/sagas';
import { select, put, call } from 'redux-saga/effects';

describe('Redux Sa・Ga', () => {
  let gen: ReturnType<typeof handleFetchLuckyColorData>;

  beforeEach(() => {
    gen = handleFetchLuckyColorData();
  });

  it('ラッキーカラーとカラーコードを取得して、AddActionを実行', () => {
    const result: ReturnType<typeof addColor> = {
      type: 'addColorPanel',
      payload: { id: 3, label: '情熱の赤', colorCode: 'red' },
    };
    expect(gen.next().value).toEqual(select(getUser));
    expect(gen.next(null).value).toEqual(call(Api.FetchLuckyColor, null));
    expect(gen.next(result.payload).value).toEqual(put(result));
    expect(gen.next().done).toBe(true);
  });

  it('エラーの時', () => {
    expect(gen.next().value).toEqual(select(getUser));
    gen.next();
    expect(gen.next().value).toEqual(put(failureFetchLuckyColor()));
    expect(gen.next().done).toBe(true);
  });
});

何をテストしているのかが全くわかりません。
しかもこのテストは色々と間違っているのですが、通るので、やっぱり全くわかりません。
そこでredux-saga-test-planexpectSagaを使用してみます。
https://github.com/jfairbank/redux-saga-test-plan

shell
yarn add -D redux-saga-test-plan

先ほどのより、だいぶスッキリしました。

spec/ColorPanelList/saga.spec.ts
import 'regenerator-runtime/runtime';
import { expectSaga } from 'redux-saga-test-plan';
import { throwError } from 'redux-saga-test-plan/providers';
import { select, call } from 'redux-saga/effects';
import {
  addColorPanel,
  successFetchLuckyColorData,
  failureFetchLuckyColorData,
} from '@/modules/ColorPanelList/actions';
import { handleFetchLuckyColorData } from '@/modules/ColorPanelList/saga';
import * as Api from '@/modules/ColorPanelList/api';
import { getUser, getItems } from '@/modules/ColorPanelList/saga';
import { ColorPanelType, DataType } from '@/modules/ColorPanelList/types';

describe('Redux Sa・Ga', () => {
  const data: DataType = {
    label: '情熱の赤',
    colorCode: 'red',
  };
  const fakeItems: ColorPanelType[] = [];
  const fakeUser = {
    data: {
      token: '12345678',
    },
  };
  const fakeAddColorPanel = (): ReturnType<typeof addColorPanel> => ({
    type: 'addColorPanel',
    payload: { id: fakeItems.length, ...data },
  });

  it('ラッキーカラーとカラーコードを取得して、AddActionを実行', () => {
    return expectSaga(handleFetchLuckyColorData)
      .provide([
        [select(getUser), fakeUser],
        [select(getItems), fakeItems],
        [call(Api.FetchLuckyColor, fakeUser.data.token), data],
      ])
      .put(fakeAddColorPanel())
      .put(successFetchLuckyColorData())
      .run();
  });

  it('エラー', () => {
    return expectSaga(handleFetchLuckyColorData)
      .provide([
        [select(getUser), null],
        [select(getItems), fakeItems],
        [
          call(Api.FetchLuckyColor, null),
          Promise.reject(new Error('invalid token')),
        ],
      ])
      .put(failureFetchLuckyColorData())
      .run();
  });
});

最初に、仮データとMock関数をあらかじめ設定しています。
それらを使ってexpectSagaprovideで、selectとcallのMockを作成します。
あとはActionのputを確認するだけです。

withReducerを組み合わせると、Reducerとの結合テストも書けます。

import 'regenerator-runtime/runtime';
import { expectSaga } from 'redux-saga-test-plan';
import { select, call } from 'redux-saga/effects';
import { handleFetchLuckyColorData } from '@/modules/ColorPanelList/saga';
import * as Api from '@/modules/ColorPanelList/api';
import { getUser, getItems } from '@/modules/ColorPanelList/saga';
import { ColorPanelType, DataType } from '@/modules/ColorPanelList/types';
import ColorPanelListReducer from '@/modules/ColorPanelList/reducer';

describe('Redux Sa・Ga 3', () => {
  it('state', () => {
    const data: DataType = {
      label: '情熱の赤',
      colorCode: 'red',
    };
    const fakeItems: ColorPanelType[] = [];
    const fakeUser = {
      data: {
        token: '12345678',
      },
    };
    return expectSaga(handleFetchLuckyColorData)
      .withReducer(ColorPanelListReducer)
      .provide([
        [select(getUser), fakeUser],
        [select(getItems), fakeItems],
        [call(Api.FetchLuckyColor, fakeUser.data.token), data],
      ])
      // ColorPanelListのreducerを通したstate
      .hasFinalState({
        items: [
          {
            id: 0,
            label: '情熱の赤',
            colorCode: 'red',
          },
        ],
      })
      .run();
  });
});

補足
上記2つのテストコードは現在、TS3.6以降でエラーとなります。
expectSagaの型引数で使われるSagaTypeIterableIterator型に対応しているのですが、3.6以降ではGenerator型になるためです。
(Generator<any>などを加えることで対処はできます)

redux-saga-test-plan/index.d.ts
export type SagaType = (...params: any[]) => SagaIterator | IterableIterator<any>;

次回からはコンポーネント側のテストをやる予定です。