読者です 読者をやめる 読者になる 読者になる

Reduxのテストファースト開発(第六回 クライアントサイドのRedux)

React.js テストファースト Redux

前回

uraway.hatenablog.com

Introducing A Client-Side Redux Store

Reduxはサーバーアプリケーションですでに使用し、その素晴らしさを確認した。次に、ReduxがReactアプリケーションとどのように作用するのかを見よう。

サーバーの時と同じように、アプリケーションのstateから考える。

2つのUIうち、ひとつは投票中のエントリーのペアが表示される。stateそうしたペアを持つと考えよう。

これに加えて、結果スクリーンは得票数を表示する。これもvote stateが持つと考える。

現在のペアに対して、すでに投票が済んでいれば、Votingコンポーネントは違った風に描画する。これもまたstateとして考える。

winnerが出現すれば、stateにはwinnerのみ。

stateの変更について考えを進めよう。変更の可能性としては、ひとつはユーザーのアクションがある。このUIに関しては、次の2つのユーザーインタラクションの可能性が考えられる。

  • 投票スクリーンでユーザーが投票ボタンのひとつをクリックする
  • 結果スクリーンでユーザーがNEXTボタンをクリックする。

加えて、サーバーが現在のstateを送信するように設定しているため、これが3つめのstate変更の可能性となる。

まずはサーバーのstate更新からはじめよう。チュートリアルのはじめの方で、サーバーがstateイベントを放つように設定した。そのペイロードは先ほど書いたクライアントサイドのstate treeとほとんど同じだが、これは偶然ではなく、そうなるように設計している。クライントのreducerからすれば、サーバーからstateのスナップショットを受け取るactionを持ち、クライアントのstateと結合することは、合理的である。actionは次のような形になるだろう。

{
  type: 'SET_STATE',
  state: {
    vote: {...}
  }
}

これについて、すこしテストを記述しよう。

test/reducer_spec.js

import {List, Map, fromJS} from 'immutable';
import {expect} from 'chai';

import reducer from '../src/reducer';

describe('reducer', () => {

  it('handles SET_STATE', () => {
    const initialState = Map();
    const action = {
      type: 'SET_STATE',
      state: Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later'),
          tally: Map({Trainspotting: 1})
        })
      })
    };
    const nextState = reducer(initialState, action);

    expect(nextState).to.equal(fromJS({
      vote: {
        pair: ['Trainspotting', '28 Days Later'],
        tally: {Trainspotting: 1}
      }
    }));
  });

});

reducerはプレーンなJavaScriptオブジェクトを受け取る。

test/reducer_spec.js

it('handles SET_STATE with plain JS payload', () => {
  const initialState = Map();
  const action = {
    type: 'SET_STATE',
    state: {
      vote: {
        pair: ['Trainspotting', '28 Days Later'],
        tally: {Trainspotting: 1}
      }
    }
  };
  const nextState = reducer(initialState, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  }));
});

undefined stateを初期値として受け取った時、初期化を行う。

test/reducer_spec.js

it('handles SET_STATE without initial state', () => {
  const action = {
    type: 'SET_STATE',
    state: {
      vote: {
        pair: ['Trainspotting', '28 Days Later'],
        tally: {Trainspotting: 1}
      }
    }
  };
  const nextState = reducer(undefined, action);

  expect(nextState).to.equal(fromJS({
    vote: {
      pair: ['Trainspotting', '28 Days Later'],
      tally: {Trainspotting: 1}
    }
  }));
});

specはこれ完了した。reducer関数を作成しよう。

src/reducer.js

import {Map} from 'immutable';

export default function(state = Map(), action) {

  return state;
}

reducerはSET_STATEアクションを扱う。与えられたstateと古いstateをmergeする。

src/reducer.js

import {Map} from 'immutable';

function setState(state, newState) {
  return state.merge(newState);
}

export default function(state = Map(), action) {
  switch (action.type) {
  case 'SET_STATE':
    return setState(state, action.state);
  }
  return state;
}

ユーザーアクションについて考えよう。まずはサーバーインタラクションのためのアーキテクチャを導入する。

npm install --save redux

エントリーポイントのindex.jsxにstoreをセットアップする。また、一時的にSET_STATEアクションを、ハードコーディングしたデータとともに発送しておく。

src/index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import reducer from './reducer';
import App from './components/App';
import Voting from './components/Voting';
import Results from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const routes = <Route component={App}>
  <Route path="/results" component={Results} />
  <Route path="/" component={Voting} />
</Route>;

ReactDOM.render(
  <Router history={hashHistory}>{routes}</Router>,
  document.getElementById('app')
);

では、storeからどのようにReactコンポーネントにデータを流すのか?

Getting Data In from Redux to React

ここでは、Redux storeはアプリケーションのimmutable stateを保持する。また、stateless Reactコンポーネントはインプットとしてimmutable dataを取る。Reactはstateの変化があれば再描画し、pure render mixinを使えば再描画する必要が無いUIの部分を確実にすることができる。

ReduxとReactの結合したパッケージであるreact-reduxを使おう。

npm install --save react-redux

react-reduxを使う大きな利点としては、次のふたつのことを行い、ピュアコンポーネントをRedux storeにまとめ上げる。

  1. コンポーネントのインプットプロパティにstoreのstateをマッピングする
  2. コンポーネントのアウトプットコールバック関数プロパティにactionsをマッピングする。

これらを可能にさせる前に、react-reduxのProviderコンポーネントでトップレベルコンポーネントをラップする。これにより、アプリのコンポーネントtreeが、Redux storeと結合し、それぞれのコンポーネントでのマッピングが可能になる。

RouterコンポーネントをProviderコンポーネントでラップしよう。

src/index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import Results from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const routes = <Route component={App}>
  <Route path="/results" component={Results} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

});

次に5つのコンポーネントを次の3つのカテゴリーに分ける。

  • rootコンポーネントのAppはデータを扱わない。
  • VoteWinnerは親のコンポーネントから必要なプロパティを与えられる。
  • VotingResultsは現在、データの取得をAppからのハードコーディングされたプレースホルダのプロパティとして渡されている。したがって、実際のデータをstoreから取得する必要がある。

まずはVotingコンポーネントからはじめよう。react-reduxにはコンポーネントをつなぐ、connectと呼ばれる関数があり、マッピング関数を引数として取り、Reactコンポーネントを取る別の関数を返す。

connect(mapStateToProps)(SomeComponent)

マッピング関数の役割としては、Redux storeから受け取ったstateをプロパティのオブジェクトに対してマップする。これらのプロパティはコンポーネントのプロパティに結合される。Votingにおいては、pairwinnerをマップする必要がある。

src/components/Voting.jsx

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin'
import {connect} from 'react-redux';
import Winner from './Winner';
import Vote from './Vote';

const Voting = React.createClass({
  mixins: [PureRenderMixin],
  render: function() {
    return <div>
      {this.props.winner ?
        <Winner ref="winner" winner={this.props.winner} /> :
        <Vote {...this.props} />}
    </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    winner: state.get('winner')
  };
}

connect(mapStateToProps)(Voting);

export default Voting;

connect関数はVotingコンポーネントを変更しない。Votingはピュアでunconnectedなコンポーネントを保つ。その代わり、connectはconnectedのバージョンのVotingを返す。つまり、現在のコードでは動作しない。この戻り値をVotingContainerと呼ぶことにしよう。

src/components/Voting.jsx

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {connect} from 'react-redux';
import Winner from './Winner';
import Vote from './Vote';

export const Voting = React.createClass({
  mixins: [PureRenderMixin],
  render: function() {
    return <div>
      {this.props.winner ?
        <Winner ref="winner" winner={this.props.winner} /> :
        <Vote {...this.props} />}
    </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    winner: state.get('winner')
  };
}

export const VotingContainer = connect(mapStateToProps)(Voting);

このモジュールは、ピュアコンポーネントのVotingとconnectedコンポーネントのVotingContainerという2つのコンポーネントをexportする。react-reduxのドキュメントによれば、前者は"dumb"(愚かな)、後者は"smart"(賢い)コンポーネントと呼ばれる。このチュートリアル内では、"pure"と"connected"と呼ぶが、呼び方より、次の違いを理解することが重要だ。

  • pure/dumbコンポーネントは完全に与えられたプロパティに左右される。ピュア関数に等しい。
  • connected/smartコンポーネントはRedux Storeでのstateの変更との同期を保つロジックでピュアコンポーネントをラップする。このロジックはreact-reduxに提供される。

ルーティングテーブルを更新しよう。Votingの代わりにVotingContainerを使用する。Redux storeに入れたデータによって投票スクリーンが動作するはずだ。

src/index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import Results from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const routes = <Route component={App}>
  <Route path="/results" component={Results} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

Votingのユニットテストでも、default exportではなくなったので、修正する。

test/components/Voting_spec.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import {
  renderIntoDocument,
  scryRenderedDOMComponentsWithTag,
  Simulate
} from 'react-addons-test-utils';
import {List} from 'immutable';
import {Voting} from '../../src/components/Voting';
import {expect} from 'chai';

これらのテストはpure Votingコンポーネントのために記述されたものなので、これ以上変更は必要ない。

次は結果スクリーンにも同じトリックを適用しよう。stateのpairwinner要素を必要とする。加えて、得票数を表示するため、tallyも必要になる。

src/components/Results.jsx

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {connect} from 'react-redux';
import Winner from './Winner';

export const Results = React.createClass({
  mixins: [PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  getVotes: function(entry) {
    if (this.props.tally && this.props.tally.has(entry)) {
      return this.props.tally.get(entry);
    }
    return 0;
  },
  render: function() {
    return this.props.winner ?
      <Winner ref="winner" winner={this.props.winner} /> :
      <div className="results">
        <div className="tally">
          {this.getPair().map(entry =>
            <div key={entry} className="entry">
              <h1>{entry}</h1>
              <div className="voteCount">
                {this.getVotes(entry)}
              </div>
            </div>
          )}
        </div>
        <div className="management">
          <button ref="next"
                   className="next"
                   onClick={this.props.next}>
            Next
          </button>
      </div>
      </div>;
  }
});

function mapStateToProps(state) {
  return {
    pair: state.getIn(['vote', 'pair']),
    tally: state.getIn(['vote', 'tally']),
    winner: state.get('winner')
  }
}

export const ResultsContainer = connect(mapStateToProps)(Results)

index.jsxでは、Resultsの代わりにResultsContainerを使う。

src/index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import {Router, Route, hashHistory} from 'react-router';
import {createStore} from 'redux';
import {Provider} from 'react-redux';
import reducer from './reducer';
import App from './components/App';
import {VotingContainer} from './components/Voting';
import {ResultsContainer} from './components/Results';

const store = createStore(reducer);
store.dispatch({
  type: 'SET_STATE',
  state: {
    vote: {
      pair: ['Sunshine', '28 Days Later'],
      tally: {Sunshine: 2}
    }
  }
});

const routes = <Route component={App}>
  <Route path="/results" component={ResultsContainer} />
  <Route path="/" component={VotingContainer} />
</Route>;

ReactDOM.render(
  <Provider store={store}>
    <Router history={hashHistory}>{routes}</Router>
  </Provider>,
  document.getElementById('app')
);

テストのimport statementを変更する。

test/components/Results_spec.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import {
  renderIntoDocument,
  scryRenderedDOMComponentsWithClass,
  Simulate
} from 'react-addons-test-utils';
import {List, Map} from 'immutable';
import {Results} from '../../src/components/Results';
import {expect} from 'chai';

さて、このようにして、ピュアコンポーネントをRedux storeにconnectし、storeからデータを取得する。

single root コンポーネントであり、ルーティングのないような小さなアプリでは、殆どの場合、そのrootコンポーネントをconnectするだけで十分だ。rootはプロパティとしてデータを子のコンポーネントに渡すことができる。ルーティングのあるアプリでは、ルーターのコンポーネントにそれぞれconnectする方が良い。

次に、App.jsxでハードコーディングされたデータを取り除く。

src/components/App.jsx

import React from 'react';

export default React.createClass({
  render: function() {
    return this.props.children;
  }
});

次回: 第七回 サーバー・アプリケーションとの接続

uraway.hatenablog.com