Reduxのテストファースト開発(第五回 クライアント・アプリケーション)

前回

uraway.hatenablog.com

The Client Application

クライアントアプリケーションでもReudxを使用する。Redux自体がどのように働くかはすでに見た。Reduxは、Reactとどのように合わさるのか、Reactアプリのデザインにどのように影響するのか見ていこう。

Client Project Setup

まずは、プロジェクトのセットアップ。

mkdir voting-client
cd voting-client
npm init -y

アプリのホストページとなるHTMLを作成しよう。

dist/index.html

<!DOCTYPE html>
<html>
<body>
  <div id="app"></div>
  <script src="bundle.js"></script>
</body>
</html>

アプリケーションのエントリーポイントとなるJavaScriptファイルを作成しよう。

src/index.js

console.log('I am alive!');

クライアント開発にWebpackを使用する。必要な物をインストールしよう。

npm install --save-dev webpack webpack-dev-server

次に、Webpack設定ファイルをプロジェクトのルートに作成する。

webpack.config.js

module.exports = {
  entry: [
    './src/index.js'
  ],
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'bundle.js'
  },
  devServer: {
    contentBase: './dist'
  }
};

index.jsをエントリーポイントとして、すべてをdist/bundle.jsファイルにバンドルする。ディベロップメントサーバーのベースとしてdistディレクトリを用いる。

bundle.jsを作成するにはWeboackを実行する。

webpack

ディベロップメントサーバーを開始するには次のコマンドを実行する。

webpack-dev-server

クライアントでは、ES6とReactのJSX構文を使用するため、Babelを用いる。

npm install --save-dev babel-core babel-loader babel-preset-es2015 babel-preset-react

package.jsonにBabelのプリセットを指定する。

package.json

"babel": {
  "presets": ["es2015", "react"]
}

Webpack設定ファイルを修正する。

webpack.config.js

module.exports = {
  entry: [
    './src/index.js'
  ],
  module: {
    loaders: [{
      test: /\.jsx?$/,
      exclude: /node_modules/,
      loader: 'babel'
    }]
  },
  resolve: {
    extensions: ['', '.js', '.jsx']
  },
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'bundle.js'
  },
  devServer: {
    contentBase: './dist'
  }
};
Unit Testing support

クライアントコードでもユニットテストを記述する。

npm install --save-dev mocha chai

React コンポーネントも同様にテストする。Karma といったライブラリを使用し、実際のウェブブラウザ上でテストを実行するのも一つの手だが、NodeでJavaScriptのDOMを実行できるjsdomを使用し、 テストを行う

npm install --save-dev jsdom

Reactを使用する前にjsdomのセットアップが必要だ。ウェブブラウザに標準で提供されているdocument/windowオブジェクトをグローバルオブジェクトに指定し、Reactからアクセスできるようにする。

test/test_helper.js

import jsdom from 'jsdom';

const doc = jsdom.jsdom('<!doctype html><html><body></body></html>');
const win = doc.defaultView;

global.document = doc;
global.window = win;

加えて、navigatorといったjsdomのwindowオブジェクトがもつすべてのプロパティを取り、それらをNode.jsのグローバルオブジェクトにまで引き上げる。そうすることでwindowなしでもプロパティを使用することができる。

test/test_helper.js

import jsdom from 'jsdom';

const doc = jsdom.jsdom('<!doctype html><html><body></body></html>');
const win = doc.defaultView;

global.document = doc;
global.window = win;

Object.keys(window).forEach((key) => {
  if (!(key in global)) {
    global[key] = window[key];
  }
});

immutableとchai-immutableをインストールし、test_helper.jsファイルを修正する。

npm install --save immutable
npm install --save-dev chai-immutable

テストを実行するためのコマンドを追加しよう。

package.json

"scripts": {
  "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js 'test/**/*.@(js|jsx)'",
  "test:watch": "npm run test -- --watch"
},

これはサーバーのpackage.jsonのコマンドとほとんど同じもの。違う点はサーバーでは、--recursiveを使い、.jsxファイルを入れない。すべての.js/.jsxテストファイルを取り込むためにglobを使う。

React and react-hot-loader

WeboackとBabelインフラを設定したら、次はReactについて。

ReduxとImmutableによるReactアプリケーションでは、ピュアコンポーネントを記述する。コンセプトとしては、これはピュア関数と似ている。

関数がデータを引数に取るように、ピュアコンポーネントはデータをプロパティ(props)として受け取る。また、ピュアコンポーネントは内的なstateをもたず、受け取ったプロパティによって描画が変わる。

コンポーネントがstateを持たないなら、stateはどこに置かれるのか?それはRedux storeのimmutableデータ構造の中だ。Reactコンポーネントは与えられたstateの、statelessな投射である。

しかし、まずはプロジェクトにReactを追加しよう。

npm install --save react react-dom

react-hot-loaderもセットアップする。現在のstateを失わずにコードをリロードすることでき、ディベロップメントワークフローをかなり速くしてくれる。

npm install --save-dev react-hot-loader

react-hot-loaderを使用するために、コンフィグファイルを修正する。

webpack.config.js

var webpack = require('webpack');

module.exports = {
  entry: [
    'webpack-dev-server/client?http://localhost:8080',
    'webpack/hot/only-dev-server',
    './src/index.js'
  ],
  module: {
    loaders: [{
      test: /\.jsx?$/,
      exclude: /node_modules/,
      loader: 'react-hot!babel'
    }]
  },
  resolve: {
    extensions: ['', '.js', '.jsx']
  },
  output: {
    path: __dirname + '/dist',
    publicPath: '/',
    filename: 'bundle.js'
  },
  devServer: {
    contentBase: './dist',
    hot: true
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin()
  ]
};

entryセクションでは2つ、新たに追加している。webpackのディベロップメントサーバーのクライアントサイドライブラリとhot module loaderを読み込んでいる。これらはhot module replacement (HMR)に必要なインフラを提供する。HMRは初期状態ではサポートされておらず、それ用のpluginを読み込んで、devServerセクションにおいて、可能(hot: true)にさせておく必要がある。

loadersセクションにおいては、Babelに加えてreact-hot loaderを設定している。

Writing The UI for The Voting Screen

アプリケーションの投票UIは極めてシンプル。投票中は2つのボタンが表示され、それぞれに対して投票がなされる。投票が終了すれば、winnerが表示される。

ここまで主にテストファースト開発を行ってきたが、Reactコンポーネントに対しては先にコンポーネントを記述してから、テストを記述する。これは、Webpackとreact-hot-loaderがより厳重なfeedback loopを行うためである。もちろん、UIを実際に見て確かめる以上のフィードバックはない。

さて、アプリケーションのエントリーポイントにVotingコンポーネントをレンダリングする。また、このコンポーネントをindex.html# app divにマウントする。さらに、JSXマークアップを使用しているため、index.jsindex.jsxにリネームする。

src/index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import Voting from './components/Voting';

const pair = ['Trainspotting', '28 Days Later'];

ReactDOM.render(
  <Voting pair={pair} />,
  document.getElementById('app')
);

Votingコンポーネントは現在のエントリーペアをpropsとして受け取る。現状、入力ペアをハードコーディングしておくが、後で本当のデータと入れ替える。

webpack.config.jsのエントリーポイントのファイルネームも変更しておく。

webpack.config.js

entry: [
  'webpack-dev-server/client?http://localhost:8080',
  'webpack/hot/only-dev-server',
  './src/index.jsx'
],

wepack-dev-serverをリスタートすると、Votingコンポーネントがないためにエラーを吐く。これを修正しよう。

src/components/Voting.jsx

import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}>
          <h1>{entry}</h1>
        </button>
      )}
    </div>;
  }
});

ペアエントリーがボタンとしてレンダリングされているのが、ウェブブラウザーで見ることができるはずだ。変更を加えればすぐに反映されるのがわかる。

ひとつ目のユニットテストを記述しよう。

test/components/Voting_spec.jsx

import Voting from '../../src/components/Voting';

describe('Voting', () => {

});

pairプロパティに基づいてボタンを描画するコンポーネントをテストするために、このコンポーネントを描画して、出力を確認する。ユニットテストにおいてコンポーネントを描画するために、renderIntoDocumentと呼ばれる関数を使用する。

npm install --save react-addons-test-utils

test/components/Voting_spec.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import {
  renderIntoDocument
} from 'react-addons-test-utils';
import Voting from '../../src/components/Voting';

describe('Voting', () => {

  it('renders a pair of buttons', () => {
    const component = renderIntoDocument(
      <Voting pair={["Trainspotting", "28 Days Later"]} />
    );
  });

});

コンポーネントが描画されたら、scryRenderedDOMComponentsWithTag関数を使ってbuttonエレメントを取得し、テクストをそれぞれテストする。

test/components/Voting_spec.jsx

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

describe('Voting', () => {

  it('renders a pair of buttons', () => {
    const component = renderIntoDocument(
      <Voting pair={["Trainspotting", "28 Days Later"]} />
    );
    const buttons = scryRenderedDOMComponentsWithTag(component, 'button');

    expect(buttons.length).to.equal(2);
    expect(buttons[0].textContent).to.equal('Trainspotting');
    expect(buttons[1].textContent).to.equal('28 Days Later');
  });

});

テストを実行して、通ることを確認しよう。

npm run test

ボタンのひとつがクリックされたら、コンポーネントはコールバック関数を発火する。エントリーペアと同じように、コールバック関数もプロパティとしてコンポーネントに与えられる。

これに対してもユニットテストを記述する。Simulateを使って、クリックイベントをシュミレートする。

test/components/Voting_spec.jsx

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

describe('Voting', () => {

  // ...

  it('invokes callback when a button is clicked', () => {
    let votedWith;
    const vote = (entry) => votedWith = entry;

    const component = renderIntoDocument(
      <Voting pair={["Trainspotting", "28 Days Later"]}
              vote={vote}/>
    );
    const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
    Simulate.click(buttons[0]);

    expect(votedWith).to.equal('Trainspotting');
  });

});

このテストを通すために、現在のエントリーにvoteを発火するonClickハンドラを設定する。

src/components/Voting.jsx

import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}
                onClick={() => this.props.vote(entry)}>
          <h1>{entry}</h1>
        </button>
      )}
    </div>;
  }
});

ユーザーがすでにペアのどちらかに投票をしていれば、それ以上の投票をさせるべきではない。コンポーネント内でこのロジックを完結することもできるが、コンポーネントをピュアに保つために、外部に表そう。コンポーネントはhasVotedプロパティを取り、値は今のところはハードコーディングしておく。

src/index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import Voting from './components/Voting';

const pair = ['Trainspotting', '28 Days Later'];

ReactDOM.render(
  <Voting pair={pair} hasVoted="Trainspotting" />,
  document.getElementById('app')
);

簡単に実装することができる。

src/components/Voting.jsx

import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  isDisabled: function() {
    return !!this.props.hasVoted;
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}
                disabled={this.isDisabled()}
                onClick={() => this.props.vote(entry)}>
          <h1>{entry}</h1>
        </button>
      )}
    </div>;
  }
});

ユーザーが投票したボタンに小さなlabelを加え、どちらに投票したのかをわかりやすくしよう。labelはhasVotedプロパティのエントリー一致したボタンの下に表示される。

import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  isDisabled: function() {
    return !!this.props.hasVoted;
  },
  hasVotedFor: function(entry) {
    return this.props.hasVoted === entry;
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}
                disabled={this.isDisabled()}
                onClick={() => this.props.vote(entry)}>
          <h1>{entry}</h1>
          {this.hasVotedFor(entry) ?
            <div className="label">Voted</div> :
            null}
        </button>
      )}
    </div>;
  }
});

最後に、winnerが決まったらボタンではなくwinnerのみを描画するようにしよう。やはり、値はハードコーディングしておく。

src/index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import Voting from './components/Voting';

const pair = ['Trainspotting', '28 Days Later'];

ReactDOM.render(
  <Voting pair={pair} winner="Trainspotting" />,
  document.getElementById('app')
);

src/components/Voting.jsx

import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  isDisabled: function() {
    return !!this.props.hasVoted;
  },
  hasVotedFor: function(entry) {
    return this.props.hasVoted === entry;
  },
  render: function() {
    return <div className="voting">
      {this.props.winner ?
        <div ref="winner">Winner is {this.props.winner}!</div> :
        this.getPair().map(entry =>
          <button key={entry}
                  disabled={this.isDisabled()}
                  onClick={() => this.props.vote(entry)}>
            <h1>{entry}</h1>
            {this.hasVotedFor(entry) ?
              <div className="label">Voted</div> :
              null}
          </button>
        )}
    </div>;
  }
});

少しコードが複雑になってきた。ここで、このコンポーネントからWinnerコンポーネントとVoteコンポーネントを切り出そう。投票スクリーンはどちらかを描画する。

src/components/Winner.jsx

import React from 'react';

export default React.createClass({
  render: function() {
    return <div className="winner">
      Winner is {this.props.winner}!
    </div>;
  }
});

Voteコンポーネントはほとんど前のVotingコンポーネントと同じ。

src/components/Vote.jsx

import React from 'react';

export default React.createClass({
  getPair: function() {
    return this.props.pair || [];
  },
  isDisabled: function() {
    return !!this.props.hasVoted;
  },
  hasVotedFor: function(entry) {
    return this.props.hasVoted === entry;
  },
  render: function() {
    return <div className="voting">
      {this.getPair().map(entry =>
        <button key={entry}
                disabled={this.isDisabled()}
                onClick={() => this.props.vote(entry)}>
          <h1>{entry}</h1>
          {this.hasVotedFor(entry) ?
            <div className="label">Voted</div> :
            null}
        </button>
      )}
    </div>;
  }
});

Votingコンポーネントはどちらを描画するのかを選択する。

src/components/Voting.jsx

import React from 'react';
import Winner from './Winner';
import Vote from './Vote';

export default React.createClass({
  render: function() {
    return <div>
      {this.props.winner ?
        <Winner ref="winner" winner={this.props.winner} /> :
        <Vote {...this.props} />}
    </div>;
  }
});

Winnerコンポーネントにrefを追加している。これはユニットテスト内でDOMを取得するために使用する。

さて、ユニットテストを記述しよう。はじめに、hasVotedプロパティがあれば、ボタンはdisabled(使用不可)になる。

test/components/Voting_spec.jsx

it('disables buttons when user has voted', () => {
  const component = renderIntoDocument(
    <Voting pair={["Trainspotting", "28 Days Later"]}
            hasVoted="Trainspotting" />
  );
  const buttons = scryRenderedDOMComponentsWithTag(component, 'button');

  expect(buttons.length).to.equal(2);
  expect(buttons[0].hasAttribute('disabled')).to.equal(true);
  expect(buttons[1].hasAttribute('disabled')).to.equal(true);
});

Voted labelはhasVotedの値が一致したエントリーのボタンに表示される。

test/components/Voting_spec.jsx

it('adds label to the voted entry', () => {
  const component = renderIntoDocument(
    <Voting pair={["Trainspotting", "28 Days Later"]}
            hasVoted="Trainspotting" />
  );
  const buttons = scryRenderedDOMComponentsWithTag(component, 'button');

  expect(buttons[0].textContent).to.contain('Voted');
});

winnerが決まると、ボタンは表示されず、'winner' refのエレメントが表示される。

test/components/Voting_spec.jsx

it('renders just the winner when there is one', () => {
  const component = renderIntoDocument(
    <Voting winner="Trainspotting" />
  );
  const buttons = scryRenderedDOMComponentsWithTag(component, 'button');
  expect(buttons.length).to.equal(0);

  const winner = ReactDOM.findDOMNode(component.refs.winner);
  expect(winner).to.be.ok;
  expect(winner.textContent).to.contain('Trainspotting');
});
Immutable Data And Pure Rendering

immutable dataとReactを併用することの利点はもう一つある。もし、コンポーネントのプロパティにのみimmutable dataを使用し、コンポーネントをピュアコンポーネントとして記述すれば、Reactはプロパティの変化を察知するために使用することができる。

PureRenderMixinは、そのために使われる。定義として、不変データ構造内では変化が生じないために、コンポーネントのプロパティはすべて変化しない値であり、再描画する必要が無い。

ユニットテストを記述し、具体的に確認しよう。このコンポーネントはピュアであり、変化する配列を入れ、その中で変化しても、再描画するべきではない。

test/components/Voting_spec.jsx

it('renders as a pure component', () => {
  const pair = ['Trainspotting', '28 Days Later'];
  const container = document.createElement('div');
  let component = ReactDOM.render(
    <Voting pair={pair} />,
    container
  );

  let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
  expect(firstButton.textContent).to.equal('Trainspotting');

  pair[0] = 'Sunshine';
  component = ReactDOM.render(
    <Voting pair={pair} />,
    container
  );
  firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
  expect(firstButton.textContent).to.equal('Trainspotting');
});

UIに変更を反映させるために、プロパティに新しいimmutable listをセットする。

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';

describe('Voting', () => {

  // ...

  it('does update DOM when prop changes', () => {
    const pair = List.of('Trainspotting', '28 Days Later');
    const container = document.createElement('div');
    let component = ReactDOM.render(
      <Voting pair={pair} />,
      container
    );

    let firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
    expect(firstButton.textContent).to.equal('Trainspotting');

    const newPair = pair.set(0, 'Sunshine');
    component = ReactDOM.render(
      <Voting pair={newPair} />,
      container
    );
    firstButton = scryRenderedDOMComponentsWithTag(component, 'button')[0];
    expect(firstButton.textContent).to.equal('Sunshine');
  });

});

今のままでは、テストを実行しても、両方のケースで再描画するので期待通りにはならない。

まずはパッケージをインストールしよう。

npm install --save react-addons-pure-render-mixin

このmixinをそれぞれのコンポーネントに導入すれば、テストは通る。

src/components/Voting.jsx

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

export default React.createClass({
  mixins: [PureRenderMixin],
  // ...
});

src/components/Vote.jsx

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default React.createClass({
  mixins: [PureRenderMixin],
  // ...
});

src/components/Winner.jsx

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default React.createClass({
  mixins: [PureRenderMixin],
  // ...
});
Writing The UI for The Results Screen And Handling Routing

結果が表示されるスクリーンに移ろう。

投票スクリーンで表示されるペアと同じものと、それぞれの投票結果に加えて、投票を管理する人にのみ、次のペアに移るためのボタンが表示される。

投票スクリーンの表示にルートパス#/を設定し、結果スクリーンを#/resultsに設定する。

異なったコンポーネントを異なったパスに結ぶために、react-routerライブラリを使用する。

npm install --save react-router@2.0.0

Routeと呼ばれるReactコンポーネントを用いて、ルーティングテーブルを定義する。

src/index.jsx

import React from 'react';
import ReactDOM from 'react-dom';
import {Route} from 'react-router';
import App from './components/App';
import Voting from './components/Voting';

const pair = ['Trainspotting', '28 Days Later'];

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

ReactDOM.render(
  <Voting pair={pair} />,
  document.getElementById('app')
);

Votingコンポーネントに対するシングルルートを作成した。また、ここでは後に作成するAppコンポーネントに対するroot Routeのコンポーネントを定義している。

root Routeコンポーネントを作成する目的は、すべてのRouteに共通のマークアップを描画するため。

src/components/App.jsx

import React from 'react';
import {List} from 'immutable';

const pair = List.of('Trainspotting', '28 Days Later');

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

このコンポーネントはchildrenプロパティとして与えられたchildコンポーネントを描画する以外には何もしていない。今のところはVotingコンポーネントのRouteしかないため、常にVoitngを描画している。

pairデータのplaceholderはindex.jsxからApp.jsxに移している。cloneElement APIを使って、もとのコンポーネントを複製する。これは一時的なもので、後に取り除く。

index.jsxに戻って、Routeに手を加えよう。

src/index.jsx

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

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

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

rootコンポーネントとして、Routerコンポーネントを供給し、#hashベースのヒストリーメカニズムを利用している。

新しくResultコンポーネントをRouterに追加する。

src/index.jsx

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

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')
);

ここではやはり<Route>コンポーネントを使用し、/resultsパスにResultsコンポーネントを指定している。

簡単にResultsコンポーネントを作成して、ルーティングを確かめてみよう。

src/components/Results.jsx

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default React.createClass({
  mixins: [PureRenderMixin],
  render: function() {
    return <div>Hello from results!</div>
  }
});

ブラウザでhttp://localhost:8080/#/resultsを開くと、Resultsコンポーネントからのメッセージが表示されるはずだ。それに対してroot pathは投票ボタンを表示している。

次にResultsコンポーネントを使えるものにしよう。投票中のエントリーがまず表示される。

src/components/Results.jsx

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default React.createClass({
  mixins: [PureRenderMixin],
  getPair: function() {
    return this.props.pair || [];
  },
  render: function() {
    return <div className="results">
      {this.getPair().map(entry =>
        <div key={entry} className="entry">
          <h1>{entry}</h1>
        </div>
      )}
    </div>;
  }
});

これは結果スクリーンなので、投票数も表示するべきだ。まずはAppコンポーネントからtally Mapを渡す。

src/components/App.jsx

import React from 'react';
import {List, Map} from 'immutable';

const pair = List.of('Trainspotting', '28 Days Later');
const tally = Map({'Trainspotting': 5, '28 Days Later': 4});

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

これらの数字を表示させるために、Resultsコンポーネントを調節しよう。

src/components/Results.jsx

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default 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 <div className="results">
      {this.getPair().map(entry =>
        <div key={entry} className="entry">
          <h1>{entry}</h1>
          <div className="voteCount">
            {this.getVotes(entry)}
          </div>
        </div>
      )}
    </div>;
  }
});

ここまで、現在のResultコンポーネントの振る舞いに対してのユニットテストを追加してきた。

それぞれのentryに対して、divエレメントを描画し、両方のentryの名前と投票数を表示する。投票がないentryに対しても、投票数ゼロを表示する。

test/components/Results_spec.jsx

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

describe('Results', () => {

  it('renders entries with vote counts or zero', () => {
    const pair = List.of('Trainspotting', '28 Days Later');
    const tally = Map({'Trainspotting': 5});
    const component = renderIntoDocument(
      <Results pair={pair} tally={tally} />
    );
    const entries = scryRenderedDOMComponentsWithClass(component, 'entry');
    const [train, days] = entries.map(e => e.textContent);

    expect(entries.length).to.equal(2);
    expect(train).to.contain('Trainspotting');
    expect(train).to.contain('5');
    expect(days).to.contain('28 Days Later');
    expect(days).to.contain('0');
  });

});

次に、"NEXT"ボタンについて話を進めよう。

コンポーネントのプロパティにコールバック関数を置いて、"NEXT"ボタンがクリックされたら、そのコールバック関数を発火する。これについて、投票ボタンとよく似た形でユニットテストを記述することができる。

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';


describe('Results', () => {

  // ...

  it('invokes the next callback when next button is clicked', () => {
    let nextInvoked = false;
    const next = () => nextInvoked = true;

    const pair = List.of('Trainspotting', '28 Days Later');
    const component = renderIntoDocument(
      <Results pair={pair}
               tally={Map()}
               next={next}/>
    );
    Simulate.click(ReactDOM.findDOMNode(component.refs.next));

    expect(nextInvoked).to.equal(true);
  });

});

実装についても、投票ボタンとほとんど同じ。引数がないので幾分かはシンプルなものになる。

src/components/Results.jsx

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

export default 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 <div className="results">
      <div className="tally">
        {this.getPair().map(entry =>
          <div key={entry} className="entry">
            <h1>{entry}</h1>
            <div class="voteCount">
              {this.getVotes(entry)}
            </div>
          </div>
        )}
      </div>
      <div className="management">
        <button ref="next"
                className="next"
                onClick={this.props.next}>
          Next
        </button>
      </div>
    </div>;
  }
});

最後に、投票スクリーンと同じく、結果スクリーンにもwinnerを表示する。

test/components/Results_spec.jsx

it('renders the winner when there is one', () => {
  const component = renderIntoDocument(
    <Results winner="Trainspotting"
             pair={["Trainspotting", "28 Days Later"]}
             tally={Map()} />
  );
  const winner = ReactDOM.findDOMNode(component.refs.winner);
  expect(winner).to.be.ok;
  expect(winner.textContent).to.contain('Trainspotting');
});

すでに投票スクリーンに使用したWinnerコンポーネントを再利用すれば、簡単に実装することができる。winnerがあれば、通常の結果スクリーンに変わり、それを描画する。

src/components/Results.jsx

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import Winner from './Winner';

export default 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>;
  }
});

実際のデータやアクションと結びついていないため、これらのコンポーネントは全く動作をしない。ここまでは、ただシンプルなプレースホルダのデータをコンポーネントに入れて、UIの構造を具体化したにすぎない。

次は、Redux storeに入力・出力を結びつけて、UIに命を吹きこもう。

次回: 第六回 クライアントサイドのRedux

uraway.hatenablog.com