はじめに
redux-saga-test-planとは
redux-saga-test-planはredux-sagaを使って書かれたコードのテストを行うためのライブラリです。
redux-saga(以下、Saga)って何?という方、あるいは、もう少し復習したい方は、Qiitaならば『redux-sagaで非同期処理と戦う』が分かりやすくておススメなので、ご一読ください。
サンプルコードをベースにして説明しますが、そのサンプルコードを実行するための環境構築から始めます。
(テストコードは、create-react-appで標準で付いてくるテスト用フレームワークのJestを使って書いていきます)
環境構築
実行環境
- Mac OS Sierra: v10.12.6
- node: v8.2.0
- npm: v5.3.0
- yarn: v1.2.1
Reactアプリの構築
create-react-appを用いてReact環境を作り、プロジェクトルートまで移動します。
cd ~/
create-react-app redux-saga-test-plan-sample
cd redux-saga-test-plan-sample
redux-sagaのインストール
続けて、Sagaをインストールします。
yarn add redux-saga
redux-saga-test-planのインストール
redux-saga-test-planは開発用テストツールなので、devDependenciesとしてインストールします。
yarn add -D redux-saga-test-plan
サンプルのテストコード
通常、アプリで使用する際は、Sagaで書かれたコード(Sagaタスク)と、テストコードはそれぞれ別ファイルに保存しますが、便宜上、同じファイル内に書きます。
テストコードはsrcディレクトリに保存し、yarn test
でテストを実行できます。
ここからのコードは、redux-saga-test-plan - Test Redux Saga with an easy plan - Interview with Jeremy Fairbank(redux-saga-test-plan作者のインタビュー記事)およびredux-saga-test-plan公式より引用しています。
パラメータ無しのGETリクエストによるAPIをcallするSagaタスクのテスト
import { call, put } from "redux-saga/effects";
import { expectSaga } from "redux-saga-test-plan";
function* fetchUsersSaga(api) {
const users = yield call(api.getUsers);
yield put({ type: "FETCH_USERS_SUCCESS", payload: users });
}
it("fetches users", () => {
const users = ["Jeremy", "Tucker"];
const api = {
getUsers: () => users
};
return expectSaga(fetchUsersSaga, api)
// 最終的に期待する結果
.put({ type: "FETCH_USERS_SUCCESS", payload: users })
// Sagaタスクをforkし、テストを開始
.run();
});
パラメータ無しでGETリクエストし、単純に二人のユーザー名が格納された配列を返すAPIを、it
で始まるテストコードの中に定義しています。
redux-saga-test-plan
からimportされたexpectSaga
関数に対して、実引数として、Sagaタスク(fetchUsersSaga
)とそのタスクが受け取る引数(api
)を渡します。
expectSaga
は、run
メソッドによってSagaタスクをforkし、Sagaで実行される作用(put
など)を期待するAPIを返します。
(上記の例では、expectSagaのput
メソッドによって、SagaがFETCH_USERS_SUCCESS
actionを伴ったput
作用をyieldすることをassertしています)
パラメータ有りのGETリクエストによるAPIをcallするSagaタスクのテスト
import { call, put, take } from 'redux-saga/effects';
import { expectSaga } from 'redux-saga-test-plan';
function* userSaga(api) {
const action = yield take('REQUEST_USER');
const user = yield call(api.fetchUser, action.payload);
yield put({ type: 'RECEIVE_USER', payload: user });
}
it('fetch user', () => {
const api = {
fetchUser: id => ({ id, name: 'Tucker' }),
};
expectSaga(userSaga, api)
// 最終的に期待する結果
.put({
type: 'RECEIVE_USER',
payload: { id: 42, name: 'Tucker' },
})
// userSagaタスクがtakeするactionをdispatch
.dispatch({ type: 'REQUEST_USER', payload: 42 })
.run();
});
同じく、GETリクエストによるAPIのテストですが、パラメータ無しのケースと比較すると、expectSagaは、payload: 42
を含んだREQUEST_USER
actionのオブジェクトを実引数としてdispatch
メソッドを実行しています。
run
メソッドによってSagaタスクが開始されると、dispatchされたactionは、Sagaのtake
作用によって受け取られ、一旦はaction
に格納し、次のcall
作用の第2引数にaction.payloadで値が渡されます。それをapi.fetchUser
関数の実引数として受け取り、オブジェクトを返して、user
に格納します。
(api.fetchUserはテストコードの中で定義されており、id(=42)を受け取ると、{ id: 42, name: 'Tucker' }
を返します)
さらに、Sagaはpayloadとして受け取った上記user
を含むRECEIVE_USER
actionをput
し、expectSagaで期待(assert)したput
の内容と一致し、テストが通ることになります。
モックデータを用いたテスト
前述の2つのテストではAPIをテストコードの中で定義しましたが、redux-saga-test-planのprovide
メソッドを使って、既存のAPIの返却値として、テストコード内で指定したモックデータを返すようにしてテストを行うことができます。
import { call, put } from "redux-saga/effects";
import { expectSaga } from 'redux-saga-test-plan';
const api = {
getUsers: () => '',
};
function* fetchUsersSaga() {
const users = yield call(api.getUsers);
yield put({ type: "FETCH_USERS_SUCCESS", payload: users });
}
it("fetches users", () => {
const users = ["Jeremy", "Tucker"];
return expectSaga(fetchUsersSaga)
.provide([[call(api.getUsers), users]])
.put({ type: "FETCH_USERS_SUCCESS", payload: users })
.run();
});
expectSagaのprovide
メソッドはmatcherとモックデータの組み合わせ(配列)を要素として格納した1つの配列を引数に取ります。
redux-saga-test-planは、matcherで指定された作用に対して、任意のモックデータを返すことができます。
matcherのcall
作用で呼び出される既存のAPIの中身が何であれ、テストで実行されるSagaタスクでは、必ず任意に指定したモックデータが返ります。
エラーハンドリングのテスト
Sagaタスクのエラーハンドリング部分についても同様にテストを行うことができます。
redux-saga-test-plan/providers
から、任意のエラーを発生させるthrowError
関数をimportします。
import { call, put } from "redux-saga/effects";
import { expectSaga } from "redux-saga-test-plan";
import { throwError } from "redux-saga-test-plan/providers";
const api = {
getUsers: () => ""
};
function* fetchUsersSaga() {
try {
const users = yield call(api.getUsers);
yield put({ type: "FETCH_USERS_SUCCESS", payload: users });
} catch (e) {
yield put({ type: "FETCH_USERS_FAIL", payload: e });
}
}
it("handles errors", () => {
const error = new Error("Whoops");
return expectSaga(fetchUsersSaga)
.provide([[call(api.getUsers), throwError(error)]])
.put({ type: "FETCH_USERS_FAIL", payload: error })
.run();
});
前述のモックデータを用いたテスト同様に、expectSaga のprovide
メソッドを使って、matcherのcall
作用に対して、エラーオブジェクト(error
)を伴ったエラーが発生するように仕向けます。
そうすることで、fetchUsersSaga
タスク内でcall作用が発生した際、必ずエラーが発生し、catch
内の処理が実行されます。
特定のaction発行を監視して、別のSagaタスクをforkするSagaのテスト
takeLatest
作用によって、特定のactionを監視して、別のSagaタスクをforkする(その前までに発行されたactionによってforkされたSagaタスクが未完了の場合は、そのタスクをキャンセルし、最新で発行されたactionを優先させる)Sagaタスクのテストを行います。
import { call, put, takeLatest } from "redux-saga/effects";
import { expectSaga } from "redux-saga-test-plan";
const api = {
getUser: () => ""
};
function* fetchUserSaga(action) {
const id = action.payload;
const user = yield call(api.getUser, id);
yield put({ type: "FETCH_USER_SUCCESS", payload: user });
}
function* watchFetchUserSaga() {
yield takeLatest("FETCH_USER_REQUEST", fetchUserSaga);
}
it("fetches a user", () => {
const id = 42;
const user = { id, name: "Jeremy" };
return expectSaga(watchFetchUserSaga)
.provide([[call(api.getUser, id), user]])
.put({ type: "FETCH_USER_SUCCESS", payload: user })
.dispatch({ type: "FETCH_USER_REQUEST", payload: id })
.silentRun();
});
expectSaga
の引数にはwatchFetchUserSaga
を割り当てていますが、watchFetchUserSagaからforkされるfetchUserSaga
のテストも行います。
expectSagaのsilentRun
によってテストが開始されると、watchFetchUserSagaに向けて、payload: 42
を含んだFETCH_USER_REQUEST
actionをdispatchします。
takeLatest
作用はループで処理されるので、silentRun
の代わりにrun
メソッドでテストを実行すると、redux-saga-test-planは下記のようなワーニングを発生させて、Sagaタスクをタイムアウトさせてしまいます。
[WARNING]: Saga exceeded async timeout of 250ms
silentRun
メソッドを使用すると、このような警告が発生することなく、タイムアウトされます。
デフォルトでは250msでタイムアウトさせますが、silentRun
の引数にミリ秒単位の数字を指定することで、タイムアウトするタイミングを調整することができます。
サンプルコード完成品
以下のリポジトリに上記のサンプルコードが置いてあります。