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

Reduxのテストファースト開発(第二回 投票アプリケーションのロジック)

前回

uraway.hatenablog.com

Writing The Application Logic With Pure Functions

アプリケーションロジックに移ろう。アプリのコアは、木構造と新しい木構造を生み出す一連の関数から成り立つ。

Loading Entries

まずはじめに、このアプリケーションは投票するエントリーの集合の"loading in"を行う。前のstateとエントリーの集合を取り、エントリーを含むstateを生成するsetEntries関数を使う。

test/core_spec.js

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

import {setEntries} from '../src/core';

describe('application logic', () => {

  describe('setEntries', () => {

    it('adds the entries to the state', () => {
      const state = Map();
      const entries = List.of('Trainspotting', '28 Days Later');
      const nextState = setEntries(state, entries);
      expect(nextState).to.equal(Map({
        entries: List.of('Trainspotting', '28 Days Later')
      }));
    });

  });

});

setEntriesはstate Mapにkeyとしてentriesを設定し、エントリーリストに値を設定することで、最初のstate treeを生成する。

export function setEntries(state, entries) {
  return state.set('entries', entries);
}

入力したエントリーはJavaScriptの配列にする。state treeに組み込むまでに、やはり不変の(immutable)リストにする。

test/core_spec.js

it('converts to immutable', () => {
  const state = Map();
  const entries = ['Trainspotting', '28 Days Later'];
  const nextState = setEntries(state, entries);
  expect(nextState).to.equal(Map({
    entries: List.of('Trainspotting', '28 Days Later')
  }));
});

このテストを通すために、与えられたエントリーをimmutableのListコンストラクタに渡す。

src/core.js

import {List} from 'immutable';

export function setEntries(state, entries) {
  return state.set('entries', List(entries));
}
Starting The Vote

投票ロジックに取り掛かろう。next 関数をエントリーがすでにセットされたstateを引数に呼び出して、新しいstate treeに移行する。

この関数は、他に引数を取らない。この関数が pair をkeyとする最初の2つのエントリーを含む、stateに vote Mapを生成する。vote下のエントリーは entries リストには含まれない。

test/core_spec.js

import {List, Map} from 'immutable';
import {expect} from 'chai';
import {setEntries, next} from '../src/core';

describe('application logic', () => {

  // ..

  describe('next', () => {

    it('takes the next two entries under vote', () => {
      const state = Map({
        entries: List.of('Trainspotting', '28 Days Later', 'Sunshine')
      });
      const nextState = next(state);
      expect(nextState).to.equal(Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later')
        }),
        entries: List.of('Sunshine')
      }));
    });

  });

});

このテストを通すために、新しいstateと古いstateをmergeする。最初のエントリーはひとつのリストに入り、残りは新しいentriesに入れる。

src/core.js

import {List, Map} from 'immutable';

// ...

export function next(state) {
  const entries = state.get('entries');
  return state.merge({
    vote: Map({pair: entries.take(2)}),
    entries: entries.skip(2)
  });
}
Voting

エントリーに対して新しい投票がなされた時、"tally"(得票記録)が表示される。もしすでにtallyがあるなら、票数が増加するようにしよう。

test/core_spec.js

import {List, Map} from 'immutable';
import {expect} from 'chai';
import {setEntries, next, vote} from '../src/core';

describe('application logic', () => {

  // ...

  describe('vote', () => {

    it('creates a tally for the voted entry', () => {
      const state = Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later')
        }),
        entries: List()
      });
      const nextState = vote(state, 'Trainspotting');
      expect(nextState).to.equal(Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later'),
          tally: Map({
            'Trainspotting': 1
          })
        }),
        entries: List()
      }));
    });

    it('adds to existing tally for the voted entry', () => {
      const state = Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later'),
          tally: Map({
            'Trainspotting': 3,
            '28 Days Later': 2
          })
        }),
        entries: List()
      });
      const nextState = vote(state, 'Trainspotting');
      expect(nextState).to.equal(Map({
        vote: Map({
          pair: List.of('Trainspotting', '28 Days Later'),
          tally: Map({
            'Trainspotting': 4,
            '28 Days Later': 2
          })
        }),
        entries: List()
      }));
    });

  });

});

このテストを通すためvote関数を追加する。

src/core.js

export function vote(state, entry) {
  return state.updateIn(
    ['vote', 'tally', entry],
    0,
    tally => tally + 1
  );
}

updateInを使えばコードが簡潔になる。['vote', 'tally', 'Trainspotting'] にこの関数を適応、keyがなければ新たにMapを生成し、valueがなければ0で初期化する。

Moving to The Next Pair

あるペアの投票が終われば、次のペアに移る。現在の投票に勝ったエントリーはentriesの最後に入れる。負けたエントリーは捨てる。引き分けなら両方ともentriesに入れておこう。

このロジックをnextに追加する。

test/core_spec.js

describe('next', () => {

  // ...

  it('puts winner of current vote back to entries', () => {
    const state = Map({
      vote: Map({
        pair: List.of('Trainspotting', '28 Days Later'),
        tally: Map({
          'Trainspotting': 4,
          '28 Days Later': 2
        })
      }),
      entries: List.of('Sunshine', 'Millions', '127 Hours')
    });
    const nextState = next(state);
    expect(nextState).to.equal(Map({
      vote: Map({
        pair: List.of('Sunshine', 'Millions')
      }),
      entries: List.of('127 Hours', 'Trainspotting')
    }));
  });

  it('puts both from tied vote back to entries', () => {
    const state = Map({
      vote: Map({
        pair: List.of('Trainspotting', '28 Days Later'),
        tally: Map({
          'Trainspotting': 3,
          '28 Days Later': 3
        })
      }),
      entries: List.of('Sunshine', 'Millions', '127 Hours')
    });
    const nextState = next(state);
    expect(nextState).to.equal(Map({
      vote: Map({
        pair: List.of('Sunshine', 'Millions')
      }),
      entries: List.of('127 Hours', 'Trainspotting', '28 Days Later')
    }));
  });

});

投票の"winner"と entriesgetWinnersを使って結合する。

src/core.js

function getWinners(vote) {
  if (!vote) return [];
  const [a, b] = vote.get('pair');
  const aVotes = vote.getIn(['tally', a], 0);
  const bVotes = vote.getIn(['tally', b], 0);
  if      (aVotes > bVotes)  return [a];
  else if (aVotes < bVotes)  return [b];
  else                       return [a, b];
}

export function next(state) {
  const entries = state.get('entries')
                       .concat(getWinners(state.get('vote')));
  return state.merge({
    vote: Map({pair: entries.take(2)}),
    entries: entries.skip(2)
  });
}
Ending The Vote

エントリーが最後の一つになったら投票を終了する。そのエントリーを勝者としてstateにセットする。

test/core_spec.js

describe('next', () => {

  // ...

  it('marks winner when just one entry left', () => {
    const state = Map({
      vote: Map({
        pair: List.of('Trainspotting', '28 Days Later'),
        tally: Map({
          'Trainspotting': 4,
          '28 Days Later': 2
        })
      }),
      entries: List()
    });
    const nextState = next(state);
    expect(nextState).to.equal(Map({
      winner: 'Trainspotting'
    }));
  });

});

next関数実行中に、entries が最後のひとつになったときの振る舞いを追加する。

src/core.js

export function next(state) {
  const entries = state.get('entries')
                       .concat(getWinners(state.get('vote')));
  if (entries.size === 1) {
    return state.remove('vote')
                .remove('entries')
                .set('winner', entries.first());
  } else {
    return state.merge({
      vote: Map({pair: entries.take(2)}),
      entries: entries.skip(2)
    });
  }
}

ここではMap({winner: entries.first})を返す。以前のstateを取って、vote/entries keyを削除する。つまり、全く新しいstateを作ってwinnerにセットするのではなく、既存のstateから不必要なkeyを削除し、winner のvalueに設定する。

さて、これでコアロジックが出来上がった。それに加えてテストを書いてきた。テストはmocks/stubsがなく、比較的簡単。関数がすべてピュア関数であるため、返り値を調べるだけで済むからだ。

次回: 第三回 Reduxの導入

uraway.hatenablog.com