「redux-saga-test-plan」でredux-sagaとreducerのテストコードとつまずいた点について

「[ACCESS Advent Calendar 2020](https://qiita.com/advent-calendar/2020/access)」 の17日目の記事となります！

## 概要

ReactでAPIを叩いて非同期処理をするとき一般的にReduxでactionからreducerの処理の間に`redux-saga`や`redux-thunk`で非同期処理を行い、受け取ったdataをreducerに渡します。
その実装で`redux-saga`を使用してテストコードを書く場合、多くのテストコードライブラリが存在します。

- redux-saga-test
- redux-saga-testing
- redux-saga-test-plan
- redux-saga-test-engine	
- redux-saga-tester

この中から`redux-saga-test-plan`を使用して`redux-saga`とその値を`reducer`で管理するまでのテストコードの書き方とつまずいたポイントについて書きます。
とは言っても書き方については「[公式ドキュメント](https://github.com/jfairbank/redux-saga-test-plan)」とQiitaの「[redux-saga-test-planを使って、redux-sagaのテストを書く](https://qiita.com/yasuhiro-yamada/items/a57d286b5cfc47a22c15)」の記事が参考になり、それらを読めば使い方は理解できるかと思います。

## 作業環境

使用した各ライブラリのversionを記載します。
また、actionとreducerの処理をtypescriptで書いたため、それらの型定義とaction creatorを簡単に生成してくれる「[typescript-fsa](https://github.com/aikoven/typescript-fsa)」のライブラリを使用しています。

※`Next.js`は使用せずSPAでの実装となります

```
"react-redux": "7.2.1",
"redux": "4.0.5",
"redux-saga": "1.1.3",
"redux-saga-test-plan": "4.0.0-rc.3",
"typescript-fsa": "3.0.0",
"typescript": "4.0.2",
"@types/react-redux": "7.1.9",
```

## Reduxとredux-sagaの処理

ではテストコードについて書く前にdispatchしてactionを呼び出し、`redux-saga`でAPIを叩いてreducerを更新するまでのコードを`typescript-fsa`を使用して説明します。

### GETメソッド

getメソッドでdataを取得する際は`use-effect`のなかで処理を書きます。

```javascript:Component
const dispatch = useDispatch();

useEffect(() => {
  dispatch(getAllAction.started(null));
}, []);
```

actionはこのように書きます。
`typescript-fsa`を使用すると簡単にaction creatorが作れるからいいですね。

```javascript:Action
type Data = {
  id: string;
  name: string;
}

type ResultData<T> = {
  data: T[];
};

export const FETCH_GET_ALL = 'FETCH_GET_ALL' as const;
export const FETCH_GET_ALL_STARTED = 'FETCH_GET_ALL_STARTED' as const;

const actionCreator = actionCreatorFactory();

export const getAllAction = actionCreator.async<
  null,
  ResultData<Data>,
  ResponseError
>(FETCH_GET_ALL);
```

action creatorを作成したので、次にredux-sagaを使用して非同期処理でAPIのdataを取得します。
fetch処理は`axios`ライブラリを使用しています。
`getAllAction.started(null)`をトリガーにジェネレーター関数`fetchGetAll`が呼ばれます。

``` javascript:Saga
export function* fetchGetAll(): Generator<unknown, void, AxiosResponse> {
  try {
    const res = yield call(api);
    yield put(
      getAllAction.done({ params: null, result: { data: res.data } })
    );
  } catch (error) {
    yield put(getAllAction.failed({ params: null, error }));
  }
}

function* getAllWatcher(): SagaIterator {
  yield takeEvery(FETCH_GET_ALL_STARTED, fetchGetAll);
}

export default getAllWatcher;
```

apiのresponseデータを`typescript-fsa`のdone関数に設定します。
そのdataをreducerに渡してstateで管理します。

``` javascript:Reducer
if (isType(action, getAllAction.done)) {
  const {
    payload: {
      result: { data },
    },
  } = action;

  return {
    loading: false,
    data,
    error: {
      description: '',
    },
  };
}
```

## redux-saga-test-planでテストコードを書く

では実際に`redux-saga-test-plan`を使ってテストコードを書きます。
上記のactionからredux-saga、reducerまでの処理に対してテストコードを書きますので記述する関数は上記から引用します。

### テストコード

テストコードを書く際は、APIから取得する実際のdataやstateの実際のdataは使用せずにモックデータ(ダミーデータ)を使用して期待した通りのdataになっているかをテストします。そのためにまずはモックデータ(fakeData)を作成しています。

````javascript:モックデータ
const fakeData = {
  data: [
    {
      id: '12345ABC',
      name: 'hoge',
    },
    {
      id: '6789DEFG',
      name: 'foo',
    },
  ],
};
```

次に`redux-saga-test-plan`のredux-sagaのテスト関数`expectSaga`を使用してテストコードを書きます。


```javascript:テストコード

return expectSaga(fetchGetAll)
  .withReducer(reducer)
  .provide([[call(api), fakeData]])
  .put(
     getAllAction.done({
       params: null,
       result: { data: fakeData.data },
    })
  )
  .run()
  .then((result) => {
    expect(result.storeState).toEqual({
      data: [
        {
          id: '12345ABC',
          name: 'hoge',
        },
        {
          id: '6789DEFG',
          name: 'foo',
        },
      ],
      error: {
        description: '',
      },
      loading: false,
    });
});
```

処理の流れは以下になります。

1.  `expectSaga`関数の引数にredux-sagaのジェネレーター関数を呼びます。
2.  `withReducer`メソッドの引数にreducerの関数を指定してreducerを接続します。
3.  `provide`メソッドの引数の配列で最初の要素でAPIを呼び、その次の要素にAPIの結果(モックデータ)を指定します。
4.  モックデータをreducerに渡すために`put`メソッドの引数にdataを設定します。
5.  reducerに渡したデータの期待するデータを`toEqual`の引数に指定します。

APIの結果をモックデータにしてreducerで管理するデータがそれと同じになるというテストコードになります。
GETメソッドに限らず、PUT、POST、DELETEも同じような手順を踏めばおおよそのテストコードは書けるかと思います。

## つまずきポイント

上記の手順を踏めばおおよそのテストコードが書けますが、一部テストコードがPASS出来なかったのでその対処法について書きます。
DELETEメソッドやPUTメソッドを実装する際に、対象のidやcallback関数を`redux-saga`に付与するケースがあるかと思います。それを例に説明します。

### DELETEメソッド(ReduxとRedux-sagaの処理)

DELETEメソッドでidを`redux-saga`に付与する場合、削除ボタンを押下したときにdispatchでdeleteActionを呼ぶようにします。
`typeacript-fsa`のstarted関数では第一引数にpayloadのdataを設定し、第二引数にはmetaのdataを設定することができますので、APIにvalueを渡したいときはpayloadに、PUTやDELTEメソッドでその対象のidを付与するときはmetaに設定します。
ここではDELETEメソッドについて書くのでidを第二引数に設定します。

```javascript:Component
const handleClick = () => {
  dispatch(
    deleteAction.started(null, {
      id: '12345ABC',
    })
  );
};
```

次にactionですが、上記のGETメソッドの相違としてmetaのidが付与されるのでその型定義とAPIでdeleteを実行したときにそのidを結果として受け取りたいので`ResultData`にidを付与しています。

```javascript:Action
type Meta = {
  meta: {
    id: string;
  };
};

type ResultData = {
  id: string;
};

export type DeleteAction = Action & Meta;

export const FETCH_DELETE = 'FETCH_DELETE' as const;
export const FETCH_DELETE_STARTED = 'FETCH_DELETE_STARTED' as const;

const actionCreator = actionCreatorFactory();

export const deleteAction = actionCreator.async<
  null,
  ResultData,
  ResponseError
>(FETCH_DELETE);
```

`redux-saga`の処理は上記のGETメソッドとほぼ同じなのですが、ジェネレーター関数`fetchDelete`の引数にpayloadとmetaが含まれたactionを設定しています。そのactionにdelete対象のidが付与されていてAPIを叩くとき(`call(api, action)`)で使用しています。
また、reducerにidを渡したいので`typescript-fsa`のdone関数`deleteAction.done({ params: null, result: { id } })`にも付与しています。
このidの付与がテストコードを書く際につまずいたポイントとなります。

```javascript:Saga
export function* fetchDelete(
  action: DeleteAction
): Generator<unknown, void, AxiosResponse> {
  try {
    yield call(api, action);
    const {
      meta: { id },
    } = action;
    yield put(deleteAction.done({ params: null, result: { id } }));
  } catch (error) {
    yield put(deleteAction.failed({ params: null, error }));
  }
}

function* deleteWatcher(): SagaIterator {
  yield takeLatest(FETCH_DELETE_STARTED, fetchDelete);
}

export default deleteWatcher;
```

reducerはdeleteの対象idを受け取って、そのid以外をstateとして更新します。

```javascript:Reducer
if (isType(action, deleteAction.done)) {
  const {
    payload: {
      result: { id },
    },
  } = action;

  return {
    loading: false,
    data: state.data.filter((state: State) => state.id !== id),
    error: {
      description: '',
    },
  };
}
```

### テストコード

上記のGETメソッドのテストコードを元に書くとこのようになります。

```javascript:テストコード
const id = '12345ABC';

it('テストコード', () => {
  return expectSaga(fetchDelete)
    .withReducer(reducer)
    .provide([[call(api), null]])
    .put(
      deleteAction.done({
        params: null,
        result: { id },
      })
    )
    .run()
    .then((result) => {
      expect(result.storeState).toEqual({
        data: [],
        error: {
          description: '',
        },
        loading: false,
      });
    });
});
```

これでjestコマンドを叩くとerrorになります。

```
    SagaTestError: 
    put expectation unmet:

    Expected
    --------
    { '@@redux-saga/IO': true,
      combinator: false,
      type: 'PUT',
      payload: 
       { channel: undefined,
         action: 
          { type: 'FETCH_DELETE_DONE',
            payload: { params: null, result: [Object] } } } }

    Actual:
    ------
    1. { '@@redux-saga/IO': true,
      combinator: false,
      type: 'PUT',
      payload: 
       { channel: undefined,
         action: 
          { type: 'FETCH_DELETE_DONE',
            payload: { params: null, result: [Object] } } } }

      at new SagaTestError (node_modules/redux-saga-test-plan/lib/shared/SagaTestError.js:17:57)
      at node_modules/redux-saga-test-plan/lib/expectSaga/expectations.js:67:13
      at node_modules/redux-saga-test-plan/lib/expectSaga/index.js:563:7
          at Array.forEach (<anonymous>)
      at checkExpectations (node_modules/redux-saga-test-plan/lib/expectSaga/index.js:562:18)
```

エラーの原因を調べるためデバッグをしてみると`redux-saga`がcatchブロックで処理されてることが分かりました。
さらにデバッグをするとidが`undefined`となっていることが分かりました。

```javascript
const {
  meta: { id },
} = action;
```

### idをテストコードに付与する

jestコマンドを叩くとidが`undefined`でcatchブロックで処理されるためテストコードがerrorになることが分かったので、テストコードにidを付与すればいいのですが、上記のactionオブジェクトを`redux-saga-test-plan`のどこで受け取るのかが分からずに途方に暮れてしまいました。
結論から書くと`expectSaga`関数の第二引数でactionオブジェクトが受け取れます。

```javascript:テストコード
return expectSaga(fetchDelete, {
  meta: {
    id: '12345ABC'
  },
})
```

公式ドキュメントで「[expectSaga](https://github.com/jfairbank/redux-saga-test-plan#readme)」について記載されている箇所を引用します。

> Import the expectSaga function and pass in your saga function as an argument. Any additional arguments to expectSaga will become arguments to the saga function. The return value is a chainable API with assertions for the different effect creators available in Redux Saga.

> expectSagaへの追加の引数は、saga関数への引数になります。

ジェネレーター関数で指定したこのコードのことですね。

```javascript:Saga
export function* fetchDelete(
  action: DeleteAction
)
```

`expectSaga`関数の第二引数にidを付与したらテストコードがPASSされました！
以上となります。同じerrorで困ってる方の一助となれば幸いです。