「ACCESS Advent Calendar 2020」 の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
で管理するまでのテストコードの書き方とつまずいたポイントについて書きます。
とは言っても書き方については「公式ドキュメント」と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
のなかで処理を書きます。
const dispatch = useDispatch();
useEffect(() => {
dispatch(getAllAction.started(null));
}, []);
actionはこのように書きます。
typescript-fsa
を使用すると簡単にaction creatorが作れるからいいですね。
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
が呼ばれます。
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で管理します。
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で困ってる方の一助となれば幸いです。