こちらの記事の続編です。
React + flow + Webpackの最小構成
ソースコードはこちらになります。
https://github.com/uryyyyyyy/react-redux-js-sample/tree/redux/
この記事のゴール
前回の記事でbabelやflowの導入をしたので、ここではreduxとテストを書いてみましょう。
環境
- React 15.4
- Webpack 2.2-rc
- flow 0.37
- NodeJS 6.X~
- mocha
- enzyme
構成
今回の構成は以下です。
$ tree .
.
├── index.html
├── package.json
├── src
│ ├── counter
│ │ ├── __tests__
│ │ │ ├── Actions.spec.js
│ │ │ ├── Counter.spec.js
│ │ │ └── Reducer.spec.js
│ │ ├── Actions.js
│ │ ├── Counter.jsx
│ │ ├── Entities.js
│ │ ├── Reducer.js
│ │ └── Root.js
│ ├── Srore.js
│ └── Index.jsx
├── .babelrc
├── .flowconfig
└── webpack.config.dev.js
ファイル構成については、慣れてきたらReduxのファイル構成は『Ducks』がオススメを参考にして頂ければ良いのではと思いますが、ここではcounterフォルダの中に個々に切り出して書いていきます。
各種設定ファイル
こちらの記事を参照ください。
ソースコード
redux, react-reduxの依存を追加
npm install redux react-redux --save
npm run flow-typed
Index.jsx
// @flow
import '../polyfill'
import React from 'react';
import ReactDOM from 'react-dom';
import Counter from './counter/Root';
import {Provider} from "react-redux";
import store from "./Store";
ReactDOM.render(
<Provider store={store}>
<Counter content="hello world"/>
</Provider>
, document.getElementById('app')
);
ここでは、reduxのstoreをProviderの中に入れることで、その子コンポーネントにstoreとdispatch関数を入れられるようになります。
そして、Providerの子コンポーネントに当たるCounterでもreduxとの紐付けを行うのですが、そちらは後述します。
Store.js
// @flow
import counter from './counter/Reducer'
import { createStore, combineReducers } from 'redux'
export default createStore(
combineReducers({
counter: counter
})
);
ここでは、シングルトンのstoreを生成しています。
createStoreでイベントを処理させるreducerを紐付けておくことで、良い感じにstateを変更してくれるようになります。
(combineReducersを使っているのは、後々reducerが増えることが予想されるからです。)
counter/Root.tsx
// @flow
import * as React from "react";
import {Counter} from "./Counter";
import {connect} from "react-redux";
import type {Dispatch} from "redux";
import {ActionDispatcher} from "./Actions";
export default connect(
(store: any) => ({value: store.counter}),
(dispatch: Dispatch<any>) => ({actions: new ActionDispatcher(dispatch)})
)(Counter);
上記Index.jsxで呼ばれるのがこれです。かなりややこしいのでご注意ください。
この中で、react-reduxのconnect関数によってそのstoreとdispatchを必要に応じて加工して、Counterコンポーネントに渡しています。
ここで store.counter としているのは、reduxのstoreに先ほど定義したcounterというreducerからデータを取得するよ、という意味になります。
この仕掛けによって、Counterコンポーネントでは valueとactionsが渡ることになり、reduxを意識することなく扱えるため、テストしやすくなります。
counter/Counter.jsx
// @flow
import React, {Component} from 'react';
import {CounterState} from "./Entities";
import {ActionDispatcher} from "./Actions";
type Props = {
value: CounterState;
actions: ActionDispatcher;
};
export class Counter extends Component<void, Props, void> {
render() {
return (
<div>
<p>{`score: ${this.props.value.num}`}</p>
<button onClick={() => this.props.actions.increment(3)}>Increment 3</button>
<button onClick={() => this.props.actions.decrement(2)}>Decrement 2</button>
</div>
);
}
};
今回は最小構成なので1コンポーネントです。上述の通りreduxに一切依存しないように書けています。
ここでは、storeからCounterStateが渡ってくるので、それを良い感じに表示しています。
イベントの発火は、親から渡ってきたActionDispatcherのメソッドを使います。
これによって、このコンポーネントは上から渡されたもの以外の状態を持たなくなり、テストしやすくなります。
reduxでは、stateを変化させるにはActionを投げる必要がありますが、ここでは気にする必要がありません。上から流れてくるActionDispatcherのメソッドの中でActionの生成と発火を行うためです。これによりテストが容易になります。
counter/Actions.js
// @flow
export const INCREMENT: string = 'counter/increment';
export const DECREMENT: string = 'counter/decrement';
export class ActionDispatcher {
dispatch: (action: any) => any;
constructor(dispatch: (action: any) => any) {
this.dispatch = dispatch
}
increment(amount: number) {
this.dispatch({type: INCREMENT, amount: amount})
}
decrement(amount: number) {
this.dispatch({type: DECREMENT, amount: amount})
}
}
reduxでいうAction Creatorに当たります。ここでcreateしてdispatchまで行っています。これは型安全性のために個人的にやっている方法になります。
ちなみに、今後サーバーとの通信などの副作用はここで行います。(他では外部への副作用は一切起こしてはなりません。)
ミドルウェアに関しては必要さを感じるまではなしでいいと思います。僕は未だに使っていません。
counter/Reducer.js
// @flow
import {DECREMENT, INCREMENT} from "./Actions";
import {CounterState, MyAction} from "./Entities";
const initialState: CounterState = {num: 0};
export default function reducer(state: CounterState = initialState, action: MyAction): CounterState {
switch (action.type) {
case INCREMENT: {
if (!action.amount) return state;
const amount: number = action.amount;
return {num: state.num + amount};
}
case DECREMENT: {
if (!action.amount) return state;
const amount: number = action.amount;
return {num: state.num - amount};
}
default:
return state
}
}
上述のstoreに取り込んだreducerになります。
reducerは実質一つの大きな関数(副作用が無いという意味です。)で、そのシグニチャはreducer(<現在のstate>, <発火されたAction>): <変更後のState>
という形になります。
内部でやっていることは、Actionがreducerに流れてきたらそのtypeを見て、stateを新しいstateに変換する、と言った形です。
また、初期状態を与える必要があるので initialState
を組み込んでいます。ES6の記法ですね。
counter/Entities.js
// @flow
export interface CounterState {
num: number;
}
export interface MyAction {
type: string;
amount: ?number;
}
ここではただflowで使うinterfaceを定義しただけです。
Buildしてみる
以上でreduxの基本的なところを押さえた実装ができました。
npm run build
してからindex.htmlを開くと、IncrementボタンとDecrementボタンが見えると思います。挙動は見たまんまで、数字が加減されます。
テストコード
ではテストも見ていきましょう。
その前にテストの準備をします。
npm install --save-dev babel-preset-power-assert enzyme mocha power-assert react-addons-test-utils sinon deep-equal
- power-assert
- assertだけで書け、失敗時の内容が良い感じ見れるライブラリ
- babel-preset-power-assert
- babelの中でassertをpower-assertにしてくれるpreset
- mocha
- シンプルなテストランナー
- sinon
- モック用ライブラリ
- enzyme
- reactのコンポーネントのテスト用ライブラリ
- peerでreact-addons-test-utilsに依存しているので、そちらもinstall
さらに、.babelrc
のpresetにpower-assertを追加し、npmコマンドに以下を追加します。
"test:ut": "mocha --compilers js:babel-register ./polyfill.js",
"test:all": "mocha --compilers js:babel-register ./polyfill.js **/*.spec.js"
今回は、テストコード全てを実行する test:all
と、単体ファイルのみテストするtest:ut
を用意しています。
どちらもmocha(実行環境はnodejs)でテストを実行して、テストコードはbabelでnodejsでも読めるようにしています。さらにテストコード実行前にpolyfillを入れることで、実行環境の差異をなくしています。
準備ができました。テストコードを用意しましょう。
ちなみに、上記のコードの中でテスト対象なのは3ファイルです。reduxの紐付けを行う部分はテスト不要です。
では順番に見ていきましょう。
Reducer.spec.js
import assert from 'assert'
import React from 'react'
import {CounterState} from "../Entities";
import reducer from "../Reducer";
import {spy} from "sinon";
import {INCREMENT, DECREMENT} from "../Actions";
describe('Reducer', function () {
it('INCREMENT', () => {
const state: CounterState = {num: 4, loadingCount: 0};
const action = { type: INCREMENT, amount: 3};
const result = reducer(state, action);
assert(result.num === state.num + 3);
});
it('DECREMENT', () => {
const state: CounterState = {num: 4, loadingCount: 0};
const action = { type: DECREMENT, amount: 3};
const result = reducer(state, action);
assert(result.num === state.num - 3);
});
});
ここでは、reducerにActionのtypeが渡ってきた時に、ちゃんとそれぞれ加算・減算されるかをテストしています。
stateを受け取ってstateを返すだけなので確認が簡単ですね。
実行する時はこんな感じです。
npm run test:ut ./src/counter/__tests__/Reducer.spec.js
Actions.spec.js
import assert from 'assert';
import deepEqual from 'deep-equal';
import {ActionTypes} from "../Entities";
import {ActionDispatcher, INCREMENT} from "../Actions";
import {spy} from "sinon";
describe('ActionDispatcher', () => {
it('increment', () => {
const spyCB = spy();
const actions = new ActionDispatcher(spyCB);
actions.increment(100);
const calls = spyCB.getCalls();
assert(calls.length === 1);
assert(deepEqual(calls[0].args, [{ type: INCREMENT, amount: 100 }]));
});
});
increment()を叩いた時に適切なイベントが飛んでることを確認しています。ここでspyCBはdispatchを模しています。spyCBの方で、呼ばれたのが一回だけで、その時の引数は何かをチェックしていますね。
arrayやObjectへのdeep-equal的なことをしたいのでdeep-equal
を導入しています。
npm run test:ut ./src/counter/__tests__/Actions.spec.js
Counter.spec.js
import assert from 'assert';
import deepEqual from 'deep-equal';
import React from 'react';
import { shallow } from 'enzyme';
import {Counter} from '../Counter';
import {CounterState} from "../Entities";
import {spy} from "sinon";
describe('<Counter />', function () {
it('rendering', () => {
const actions = {};
const state: CounterState = {num: 1};
const wrapper = shallow(<Counter value={state} actions={actions} />);
assert(wrapper.find('p').at(0).prop('children') === 'score: 1');
});
it('click increment button', () => {
const spyCB = spy();
const actions = {increment: spyCB};
const state: CounterState = {num: 0};
const wrapper = shallow(<Counter value={state} actions={actions} />);
wrapper.find('button').at(0).simulate('click');
const calls = spyCB.getCalls();
assert(calls.length === 1);
assert(deepEqual(calls[0].args, [3]));
});
it('click decrement button', () => {
const spyCB = spy();
const actions = {decrement: spyCB};
const state: CounterState = {num: 0};
const wrapper = shallow(<Counter value={state} actions={actions} />);
wrapper.find('button').at(1).simulate('click');
const calls = spyCB.getCalls();
assert(calls.length === 1);
assert(deepEqual(calls[0].args, [2]));
});
});
最後にコンポーネントのテストです。
1つめのテストでは、p要素にレンダリングされた文字が本当に期待通りかをテストしています。
ここでは、numに1を入れているので、 「score: 1」と出るのが正しいです。
2つめのテストでは、ボタンをclickしてみて期待通りの関数が呼び出されるかを確認しています。ここではincrementの一度だけ呼ばれて、第一引数が3であることが確認できていますね。
3つ目も同様です。
npm run test:ut ./src/counter/__tests__/Counter.spec.js
まとめ
reduxはテストしやすくていいですね。次は非同期を扱います。