前回 QiitaにReact.js の React.js の公式Tutorial を gulp を利用して簡単に実行できる環境を作って、ES6も試した
で公式TutorialをECMA2015(ECMA6)を利用して書き直しを行ったのですが、ReactといえばFlux、Flux といえばRedux ということで、流行りのRedux を利用して記述したらどうなるのかを試してみました。
また 同じく流行りのテスト環境である Mocha + power-assert を利用して、テストを追加してみました。
React および Redux はテストも非常に行いやすいのを体感しました。
前回の続編として、以下GitHub で公開しています。
https://github.com/ma-tu/react-babel-gulp-browserify-tutorial/tree/es6-redux
React.js の version は 0.14.5 で Redux の version は 3.0.5 となります。
2016年1月時点のライブラリで作成しています。Windows 環境で確認しています。
Redux について
以下の和訳した情報が非常に読みやすくて参考になります。私がつたない文章で説明するよりも、**Redux入門【ダイジェスト版】10分で理解するReduxの基礎**を見ていただく方がわかりやすい。
Redux 対応のための変更ポイント
Action の作成
Redux では、状態を変更する操作については、Actionと呼ばれる、JavaScriptのオブジェクトを作成して、それを Store に連携することで実施するのが原則となります。今回のTutorialでいうと、以下の二つがActionとなります。
- コメントを追加する。
- サーバーからのコメント一覧取得を反映する。
以下の addComment , setComments が Action と呼ばれるJavaScriptのオブジェクトを作成する関数となります。Actionオブジェクトは必ず type という属性を持つ必要があります。
//src/actions/commentAction.js
import * as ActionTypes from '../constants/ActionTypes'
export function addComment(comment) {
  return {
    type: ActionTypes.ADD_COMMENT,
    comment
  };
}
export function setComments(comments) {
  return {
    type: ActionTypes.SET_COMMENTS,
    comments
  };
}
Reducer の作成
Reducer には Action を受けて、Store の State を変更するための手続きを記述することになります。
Actionオブジェクトには type属性が必ず存在するルールがあるので、それに従って分岐して記述していきます。
//src/reducers/commentReducer.js
import * as ActionTypes from '../constants/ActionTypes'
export default function commentReducer(state={comments : []}, action) {
  switch(action.type){
    case ActionTypes.ADD_COMMENT:
      return {comments: state.comments.concat([action.comment])}
    case ActionTypes.SET_COMMENTS:
      return {comments: action.comments}
    default:
      return state;
  }
}
Store の作成と React と Redux の接続
Store 作成および React と Redux の接続に関しては、基本的には記述がある程度決まっているので Redux の情報に従って記述していきます。
- Store を作成するための関数です。Redux の logger を利用して、ChromeのDevTool に Redux 変更時にログが出力されるようにしています。
//src/store/reduxStore.js
import {createStore,  applyMiddleware} from 'redux';
import commentReducer from '../reducers/commentReducer'
import createLogger from 'redux-logger';
export default function configureStore() {
  const logger = createLogger({logger:console});
  const createStoreWithMiddleware = applyMiddleware(
    logger
  )(createStore);
  const store = createStoreWithMiddleware(commentReducer);
  return store;
}
- Redux と React をつなげる部分です。connect 関数の第一引数に React に対して Storeの情報を propsを利用して渡すための変換ロジックを記述します。第二引数は、ReactからStoreを変更するための関数をActionを利用して作成しているロジックです。
//src/containers/App.js
import { bindActionCreators } from 'redux'
import { connect } from 'react-redux'
import CommentBox from '../jsx/commentBox.jsx';
import * as commentActions from '../actions/commentAction';
function mapStateToProps(state) {
  return {
    url: '/api/comments',
    pollInterval: 2000,
    comments: state.comments
  };
}
function mapDispatchToProps(dispatch) {
  return bindActionCreators(commentActions, dispatch)
}
export default connect(mapStateToProps, mapDispatchToProps)(CommentBox)
- store を作成して、Reactのアプリ(<App>)を react-redux の Provider にくるむ感じで記述します。
//src/jsx/example.jsx
import React  from 'react';
import ReactDOM  from 'react-dom';
import { Provider } from 'react-redux';
import configureStore from '../store/reduxStore';
import App from '../containers/app';
const store = configureStore();
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('content')
);
React の state を利用している部分を Redux を利用するように変更
- React のロジック中の stateを利用している部分および setState()を利用している部分を Redux を利用するように置き換えます。上記のbindActionCreators(commentActions, dispatch)のロジックにより commentActions の関数が React の props で利用できるようになっているのでそれを利用します。現在のStoreの値もmapStateToProps(state) {//}で指定した形でprops 経由で利用可能となっています。
  //src/jsx/commentBox.jsx の diff 抜粋
   loadCommentsFromServer() {
@@ -19,36 +16,42 @@ export default class CommentBox extends React.Component{
       url: this.props.url,
       dataType: 'json',
       cache: false,
-      success: (data) => this.setState({data: data}),
+      success: (data) => {
+        this.props.setComments(data)
+      },
       error: (xhr, status, err) => console.error(this.props.url, status, err.toString())
     });
   }
   handleCommentSubmit(comment) {
-    let comments = this.state.data;
+    let comments = this.props.comments;
     comment.id = Date.now();
     let newComments = comments.concat([comment]);
-    this.setState({data: newComments});
+    this.props.setComments(newComments)
対応完了版
以下のGitHubで公開しています。
https://github.com/ma-tu/react-babel-gulp-browserify-tutorial/tree/es6-redux
実行方法については 上記 GItHub の README.md で記述しています。
git clone https://github.com/ma-tu/react-babel-gulp-browserify-tutorial.git
cd react-babel-gulp-browserify-tutorial
git checkout es6-redux
npm install
gulp
Mocha + power-assert を利用したテストについて
Mocha + powerについては参考情報は以下を参考にしました。感謝
0からはじめるpower-assert
Action , Store , Reducer に関するテスト
Action , Store , Reducer は UI からも分離されているので、素直に実行した結果のJavaScriptオブジェクトを比較する形でテストは記述可能です。
//test/actions/commentActionTest.js
import assert from 'power-assert';
import * as commentActions from '../../src/actions/commentAction';
import * as ActionTypes from '../../src/constants/ActionTypes'
import * as commentUtil from '../testUtil/commentUtil'
describe('actions', () => {
    it('addComment', () => {
        const actual = commentActions.addComment(commentUtil.getPeteComment())
        const expected = {type: ActionTypes.ADD_COMMENT, comment: commentUtil.getPeteComment()}
        assert.deepEqual(actual, expected)
    });
    it('setComments', () => {
        const actual = commentActions.setComments([commentUtil.getPeteComment()])
        const expected = {type: ActionTypes.SET_COMMENTS, comments: [commentUtil.getPeteComment()]}
        assert.deepEqual(actual, expected)
    });
});
React の UI を含んだテスト
jsdom と呼ばれるライブラリを利用してテストをします。jsdom を利用すると仮想的なDOM環境をテストコードで利用できるのでブラウザを利用しない形で、Reactのテストを行うことができます。
- 以下のスクリプトで jsdom をテストで利用するための準備を行っています。この setup.js は mocha 実行時に呼び出されます。
//test/setup.js
import { jsdom } from 'jsdom'
global.document = jsdom('<!doctype html><html><body></body></html>')
global.window = document.defaultView
global.navigator = global.window.navigator
以下が React の UI を含んだテスト例となります。
- 
setup()で React で用意されている TestUtils を利用してテスト用のMockを呼び出すようにしています。また初期データとして Paul さんのコメントデータを追加しています。
- 
TestUtils.findRenderedDOMComponentWithTag関数でTagを利用してReactの仮想DOMを取得しています。
- 
TestUtils.scryRenderedDOMComponentsWithTag上記と同じですが、scry…は取得結果が複数件である場合に利用します。
- 
container/App-displayのテストでは初期表示状態で Paul さんが表示されていること。またはコメントにはMarkdownの装飾が入っていることを確認しています。
- 
container/append commentのテストでは TestUtils.Simulate の機能を利用して、コメント欄に入力を行って、submitを実行して、その結果が期待通りであるかを確認しています。
//test/containers/AppTest.js
import assert from 'power-assert';
import React from 'react'
import ReactDOM  from 'react-dom';
import { Provider } from 'react-redux';
import TestUtils from 'react-addons-test-utils'
import configureStore from '../../src/store/reduxStore';
import MockApp from '../testUtil/MockApp';
import CommentList from '../../src/jsx/commentList.jsx';
import * as commentActions from '../../src/actions/commentAction';
import * as commentUtil from '../testUtil/commentUtil'
function setup() {
  const store = configureStore();
  store.dispatch(commentActions.addComment(commentUtil.getPaulComment()))
  const component = TestUtils.renderIntoDocument(
    <Provider store={store}>
      <MockApp />
    </Provider>
  )
  return {
    component: component,
    h2: TestUtils.findRenderedDOMComponentWithTag(component, 'h2'),
    span: TestUtils.findRenderedDOMComponentWithTag(component, 'span'),
    inputs: TestUtils.scryRenderedDOMComponentsWithTag(component, 'input')
  }
}
describe('container/App', () => {
  it('display', () => {
    const { h2, span } = setup()
    assert.equal(h2.innerHTML, "Paul O’Shannessy")
    assert.equal(span.innerHTML.trim(), "<p>React is <em>great</em>!</p>")
  })
  it('append comment', () => {
    const { component, inputs } = setup()
    TestUtils.Simulate.change(inputs[0], {target: {value: 'Test Author'}});
    TestUtils.Simulate.change(inputs[1], {target: {value: 'Test **Text**'}});
    TestUtils.Simulate.submit(inputs[2]);
    const h2List = TestUtils.scryRenderedDOMComponentsWithTag(component, 'h2')
    assert.deepEqual(h2List.map((item) => item.innerHTML), [ 'Paul O’Shannessy', 'Test Author' ])
    const spanList = TestUtils.scryRenderedDOMComponentsWithTag(component, 'span')
    assert.deepEqual(spanList.map((item) => item.innerHTML.trim()), [ '<p>React is <em>great</em>!</p>','<p>Test <strong>Text</strong></p>' ])
  })
})
テスト実行
npm の package.json で mocha をコマンドで実行するようにしています。
npm run test で実行できます。
  //package.json
  "scripts": {
    "test": "mocha --compilers js:espower-babel/guess test/**/*.js --require ./test/setup.js"
  },