5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-12-17

ACCESS Advent Calendar 2020」 の17日目の記事となります!

概要

ReactでAPIを叩いて非同期処理をするとき一般的にReduxでactionからreducerの処理の間にredux-sagaredux-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で管理するまでのテストコードの書き方とつまずいたポイントについて書きます。
とは言っても書き方については「公式ドキュメント」とQiitaの「redux-saga-test-planを使って、redux-sagaのテストを書く」の記事が参考になり、それらを読めば使い方は理解できるかと思います。

作業環境

使用した各ライブラリのversionを記載します。
また、actionとreducerの処理をtypescriptで書いたため、それらの型定義とaction creatorを簡単に生成してくれる「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のなかで処理を書きます。

Component
const dispatch = useDispatch();

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

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

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が呼ばれます。

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で管理します。

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)を作成しています。

モックデータ
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で困ってる方の一助となれば幸いです。
5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?