こちらの記事の続編です。
React + Redux + flowの最小構成
ソースコードはこちらになります。
https://github.com/uryyyyyyy/react-redux-js-sample/tree/async/
この記事のゴール
前回の記事でreduxやテストの導入をしたので、非同期処理の書き方をそのテストをまとめていきます。
環境
- React 15.4
- webpack 2.2-rc
- flow 0.37
- NodeJS 6.X~
- mocha
- enzyme
- isomorphic-fetch
構成
こちらと全く同じです。
非同期を扱うにあたって
ここでは、クライアントサイドでの非同期処理をサーバーとの通信に限ります。
サーバーとのやりとりをする上で、XmlHttpRequest
の代わりにfetch APIというものが徐々にブラウザに実装されているので、ここではそれを使います。
また、非対応の実行環境のためにisomorphicなfetch polyfillを導入します。
npm install --save isomorphic-fetch
テストでもバンドル時にも使うので、polyfillは下記のようにまとめて置いておきましょう。Index.jsxやテストコードの中でこれをimportするだけで実行環境の差異をなくせます。
import "babel-polyfill";
import "isomorphic-fetch";
また、現在ECMA Scriptの方でstage3まで進んでいて、TypeScriptでも実装されておりChromeでも最近搭載されたasync/await
の構文でjsコードを書けるようにしましょう。
npm install --save-dev babel-preset-es2016 babel-preset-es2017
これを.babelrcのpresetに突っ込み、バンドルする前にbabel-polyfillを噛ませれば async/await
が使えるようになります。
ソースコード
mock server
fetchした時の動作確認用に、サーバーを立てます。ここでは高機能なexpressを用います。
const path = require('path');
const express = require('express');
const app = express();
app.use('/dist', express.static('dist'));
app.get('/api/count', (req, res) => {
res.contentType('application/json');
const obj = {"amount": 100};
setTimeout(() => res.json(obj), 500);
//res.status(400).json(obj); //for error testing
});
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});
app.listen(3000, (err) => {
if (err) {
console.log(err);
}
console.log("server start at port 3000")
});
ここではあえて500ms待つことで、読み込みに時間かかってる感を演出しています。
ActionDispacher
さて、reduxでは非同期を扱う箇所はActionCreator(多くは、ミドルウェアというところに隠蔽する)で行うことになっています。なので、前回からの差分は主にcounter/Actions.js
の部分です。
...
export const FETCH_REQUEST_START = 'counter/fetch_request_start';
export const FETCH_REQUEST_FINISH = 'counter/fetch_request_finish';
const myHeaders = {
"Content-Type": "application/json",
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
};
export class ActionDispatcher {
dispatch: (action: any) => any;
constructor(dispatch: (action: any) => any) {
this.dispatch = dispatch
}
...
//①
async fetchAmount(): Promise<void> {
//②
this.dispatch({type: FETCH_REQUEST_START});
try {
//③
const response: Response = await fetch('/api/count', {
method: 'GET',
headers: myHeaders,
credentials: 'include'
});
if (response.status === 200) { //2xx
//④
const json: JsonObject = await response.json();
this.dispatch({type: INCREMENT, amount: json.amount})
} else {
//⑤
throw new Error(`illegal status code: ${response.status}`);
}
} catch (err) { //⑥
console.error(err.message);
} finally { //⑦
this.dispatch({type: FETCH_REQUEST_FINISH})
}
}
}
前回からの差分として見るべきところはfetchAmount()
の部分です。
async/awaitを用いてそこそこわかりやすく書けたものの、順番に説明していきます。
①で、メソッドの頭にasyncが付いていて返り値がPromiseであることから、このメソッドが非同期処理をasync/awaitで取り扱うものであると宣言します。
②では非同期処理する前に、「Loading...」の画面を出すようにActionを飛ばします。
③でfetch APIを用いてサーバーへの通信処理(非同期)を行います。awaitを使うことで、Promiseを明示的に扱わなくて良くなります。
通信が上手く行って200が返ってきた場合は、④でデータを受け取って、reducerへIncrementのActionを飛ばしています。
通信は上手く行ったものの200以外(400など)が返ってきた場合は、そのステータスコードをエラーメッセージとして例外を投げています。
fetchが失敗した(サーバーの応答が無い、通信がそもそもできてないなど)の場合や、上記のエラーが投げられた時は⑥でそれを記録します。
最後に⑦で、①で行った「Loading...」表示を取り消すようにActionを発行しています。
Counter, Reducerはここでは省略しますが、特に変わってないので見ればすぐに分かるかと思います。
テストコード
大きく変わったのがActionDispatcherなので、テストコードもそこがメインです。
import assert from 'assert';
import {ActionTypes} from "../Entities";
import {ActionDispatcher, INCREMENT, FETCH_REQUEST_START, FETCH_REQUEST_FINISH} from "../Actions";
import fetchMock from 'fetch-mock';
import {spy} from "sinon";
describe('ActionDispatcher', () => {
//①
beforeEach(() => {
fetchMock.restore();
});
...
//②
it('fetchAmount success', async () => {
//③
fetchMock.get('/api/count', {body: {amount: 100}, status: 200});
const spyCB:any = spy();
const actions = new ActionDispatcher(spyCB);
//④
await actions.fetchAmount();
const calls = spyCB.getCalls();
assert(calls.length === 3);
assert(deepEqual(calls[0].args, [{ type: FETCH_REQUEST_START }]));
assert(deepEqual(calls[1].args, [{ type: INCREMENT, amount: 100 }]));
assert(deepEqual(calls[2].args, [{ type: FETCH_REQUEST_FINISH }]));
});
it('fetchAmount fail', async () => {
//⑤
fetchMock.get('/api/count', {body: {}, status: 400});
const spyCB:any = spy();
const actions = new ActionDispatcher(spyCB);
await actions.fetchAmount();
const calls = spyCB.getCalls();
assert(calls.length === 2);
assert(deepEqual(calls[0].args, [{ type: FETCH_REQUEST_START }]));
assert(deepEqual(calls[1].args, [{ type: FETCH_REQUEST_FINISH }]));
});
});
(polyfillはnpmスクリプトの中で読んでいるので、各種APIは使えるものとして進めて良いです。)
ここで、fetch-mockというライブラリを使っています。これは、テスト時に実際のサーバーへfetchしては困るので、それのmockをするためのライブラリです。
これを①で各テストの前に初期化していますね。
②では、テストが非同期処理を含むため、asyncを書いています。
ちなみにmochaでは、テストコードの返り値がPromiseだった場合、その処理が終わるまで待機してくれる性質があります。ここではasync定義のため必然的に返り値がPromise(returnを書かなければPromise<void>
)になるため、何もする必要はありません。
③ではfetchの結果として返したいものを用意しています。
④で実際に非同期処理を起こし、awaitで待機します。
そして、「loadingを出すためのアクション」「結果を取得したのでINCREMENTするアクション」「loloadingを消すアクション」が順番に発火していることをテストしています。
⑤ではfetchの結果が失敗だった時にも正しく処理されるかの確認となります。
まとめ
reduxだと非同期でもテストが書きやすいですね。
また、async/awaitは読みやすくてすごく良いです。
Scalaをやっているのであれば、Futureでのfor式みたいなものと思えば理解しやすいと思います。