Reduxのテストファースト開発(第一回 前準備)

teropa.info

こちらの要約。流石に長過ぎるので、キリの良い所で分割する。

A Comprehensive Guide to Test-First Development with Redux, React, and Immutable

Reduxは怖くない。ReduxはとてもシンプルなライブラリなのでAPIをすべて学ぶことは難しいことではない。

このチュートリアルでは、full-stackのReduxとImmutable.jsをscrachからビルドする。アプリは、Node+ReduxバックエンドとReact+Reduxフロントエンドで構築し、テストファースト開発を行うことにしよう。

The App

会議等で使える投票アプリを開発する。アプリの中身としては、アイテムごとにペアを作り、各ペアごとに投票、勝者がさらに上位のペアを作り、アイテムが残り1つになるまで、投票を続ける。つまりトーナメント形式。

アプリは、モバイルデバイスやウェブブラウザに使用される投票UIと、より大きなスクリーン用のリアルタイムで投票結果を反映する結果UIの、2つのUIを持つ。

The Architecture

システムは2つのアプリから構成される: 2つのUIを提供するブラウザアプリと、投票ロジックを扱うサーバーアプリ。これら2つの間はWebSocketsが取り持ってくれる。

クライアントとサーバーのアプリ両方を Redux を使って構成する。stateの保持には Immutable データ構造を使用する。

The Server Application

まずはNode(サーバー)アプリを書いてからReact(クライアント)アプリを書く。こうすればUIについて考え始める前に、コアロジックにのみ集中できる。

Designing The Application State Tree

Reduxアプリの設計はstateデータ構造を考えることから始まる。

すべてのフレームワーク、アーキテクチャはstateを持つ。EmberやBackBoneではstateはModelに。AngularではFactoryやServiceに。ほとんどのFluxフレームワークではStoreに。では、Reduxはどうだろう?

大きな違いは、Reduxでは、stateはひとつの木構造に保存される。

投票アプリではどうなるか見てみよう。最初のstateは投票するアイテムの集合 entries とする。

一度投票が始まれば、投票中のアイテムは区別されるべきだろう。このstateでは投票中のペアを保持するvoteを作る。

投票開始後には、それぞれ投票数を保存する。

投票終了後、負けたアイテムは消滅。勝ったアイテムは最後のアイテムとしてentriesに戻る。このアイテムは後に別のペアを作って再度投票される。次の2つのentriesが投票される。

アイテムが最後の一つになったら投票を終わって、それをwinnerとして宣言する。

Project Setup

さて、実際に手を動かそう。まずはプロジェクトのセットアップ。

mkdir voting-server
cd voting-server
npm init -y

ES6でコードを書くのでトランスパイル用のBabelを入れる。

npm install --save-dev babel-core babel-cli babel-preset-es2015

テスト用のライブラリもインストール。

npm install --save-dev mocha chai

Mochaのテストコマンドを設定しておこう。

package.json

"scripts": {
  "test": "mocha --compilers js:babel-core/register --recursive",
  "test:watch": "npm run test -- --watch"
},

Babelもpackage.jsonに設定。

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

これでテストコマンドが実行可能になる。

npm run test

データ構造を提供してくれる Immutable とそれ用の chai-immutable をインストールしておく。

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

chai-immutableを使うようにtest_helper.jsファイルを作成する。

import chai from 'chai';
import chaiImmutable from 'chai-immutable';

chai.use(chaiImmutable);

テストコマンドをこのファイルを使用するように編集する。

"scripts": {
  "test": "mocha --compilers js:babel-core/register --require ./test/test_helper.js  --recursive",
  "test:watch": "npm run test -- --watch"
},
Getting Comfortable With Immutable

Reduxアプリのstateツリーはimmutable data structure(不変のデータ構造)で、別のstateツリーを生成することで次のstateに移行する。

つまり2つの連続的なstateは2つの独立したツリーに格納されている。現在のstateを引数に取り、新しいstateを返す関数を使用することで、stateツリー間の移動を行う。

stateの変更前、変更後の状態が保存されているのでredo/undo(やり直し/取り消し)が容易になる。

それだけでなく、コードもシンプルなものになる。stateの変更にはデータを引数に取り、データを返すピュア関数を使うので、関数の動作や変更後のstateが予測できる。

immutability(データの不変性)を理解するために、最もシンプルなデータ構造である"カウンター"を考えてみよう。stateは0から1,2,3...と増えていく。

カウンターがインクリメント(増加)したとき、数字自体は変更しない。ピュア関数を用いて現在のstateや数字を変更するのではなく、次のstateを取得する。この関数のテストを考えてみよう。

test/immutable_spec.js

import { expect } from 'chai';

describe('immutability', () => {

  describe('a number', () => {

    function increment(currentState) {
      return currentState + 1;
    }

    it('is immutable', () => {
      let state = 42;
      let nextState = increment(state);

      expect(nextState).to.equal(43);
      expect(state).to.equal(42);
    });
  });
});

新しいstateが生成されているが、stateに変更が加えられているわけではない。

このデータ構造の考えを発展させよう。

Immutable Listを用いた、stateが映画のリストであるようなアプリを考えてみる。映画を加えると、古いリストとその映画を合わせた、新しいリストを生成する。

test/immutable_spec.js

import { expect } from 'chai';
import { List } from 'immutable';

// ...

  describe('A List', () => {

    function addMovie(currentState, movie) {
      return currentState.push(movie);
    }

    it('is immutable', () => {
      let state = List.of('Trainspotting', '28 Days Later');
      let nextState = addMovie(state, 'Sunshine');

      expect(nextState).to.equal(List.of(
        'Trainspotting',
        '28 Days Later',
        'Sunshine'
      ));
      expect(state).to.equal(List.of(
        'Trainspotting',
        '28 Days Later'
      ));
    });
  });
});

配列にpushしても、古いstateは変わらずに存在している。この考え方はstate tree にも応用される。state treeはList, Map 等の集合の入れ子になったデータ構造にすぎない。何かの操作をすることで新しいstate treeを生成するが、以前のstate treeには全く変更を加えない。state treeが映画のリストを含む movies をkeyとするMapであるなら、映画を加えるということは、新しいMapを作り出す必要がある。

test/immutable_spec.js

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

describe('immutability', () => {

  // ...

  describe('a tree', () => {

    function addMovie(currentState, movie) {
      return currentState.set(
        'movies',
        currentState.get('movies').push(movie)
      );
    }

    it('is immutable', () => {
      let state = Map({
        movies: List.of('Trainspotting', '28 Days Later')
      });
      let nextState = addMovie(state, 'Sunshine');

      expect(nextState).to.equal(Map({
        movies: List.of(
          'Trainspotting',
          '28 Days Later',
          'Sunshine'
        )
      }));
      expect(state).to.equal(Map({
        movies: List.of(
          'Trainspotting',
          '28 Days Later'
        )
      }));
    });

  });

});

これは前のものと全く同じで、ネストされたデータ構造にも応用される。

Immutableはこうしたネストされたデータに到達し、値を更新するために、いくつかの関数を提供している。ここでは、updateを使用する。

test/immutable_spec.js

function addMovie(currentState, movie) {
  return currentState.update('movies', movies => movies.push(movie));
}

次回: 第二回 投票アプリケーションのロジック

uraway.hatenablog.com