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

Reduxのテストファースト開発(第三回 Reduxの導入)

前回

uraway.hatenablog.com

Introducing Actions and Reducers

アプリのコア関数はあるが、Reduxではこれらの関数を直接呼び出すことはしない。関数と外側の世界の中間層となるのが Action だ。

Action はstateの変化を描写するシンプルなデータ構造である。すべてのactionは type 属性を持ち、具体的な操作がどのactionに属するかを説明する。各actionは追加で属性を持つこともある。次に示すのは、これまで書いてきた関数に対応するactionである。

{type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later']}

{type: 'NEXT'}

{type: 'VOTE', entry: 'Trainspotting'}

これに実際のコア関数の呼び出しを追加する。例えば VOTE actionなら次のように関数を呼び出す。

// This action
let voteAction = {type: 'VOTE', entry: 'Trainspotting'}
// should cause this to happen
return vote(state, voteAction.entry);

あらゆる種類のactionと現在のstateを引数に取り、コア関数を発動する関数である、reducer を記述する。

src/reducer.js

export default function reducer(state, action) {
  // Figure out which function to call and call it
}

reducerが3つのactionを実際に扱うことができるかテストを記述する。

test/reducer_spec.js

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

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

describe('reducer', () => {

  it('handles SET_ENTRIES', () => {
    const initialState = Map();
    const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']};
    const nextState = reducer(initialState, action);

    expect(nextState).to.equal(fromJS({
      entries: ['Trainspotting']
    }));
  });

  it('handles NEXT', () => {
    const initialState = fromJS({
      entries: ['Trainspotting', '28 Days Later']
    });
    const action = {type: 'NEXT'};
    const nextState = reducer(initialState, action);

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

  it('handles VOTE', () => {
    const initialState = fromJS({
      vote: {
        pair: ['Trainspotting', '28 Days Later']
      },
      entries: []
    });
    const action = {type: 'VOTE', entry: 'Trainspotting'};
    const nextState = reducer(initialState, action);

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

});

reducerはアクションタイプをもとにしたコア関数を実行する。

src/reducer.js

import {setEntries, next, vote} from './core';

export default function reducer(state, action) {
  switch (action.type) {
  case 'SET_ENTRIES':
    return setEntries(state, action.entries);
  case 'NEXT':
    return next(state);
  case 'VOTE':
    return vote(state, action.entry)
  }
  return state;
}

reducerがactionを識別できない場合、現在のstateを返すようにする。

undefined stateを与えられた時、初期state(Map)を返す。

test/reducer_spec.js

describe('reducer', () => {

  // ...

  it('has an initial state', () => {
    const action = {type: 'SET_ENTRIES', entries: ['Trainspotting']};
    const nextState = reducer(undefined, action);
    expect(nextState).to.equal(fromJS({
      entries: ['Trainspotting']
    }));
  });

});

core.js に初期stateを導入する。

src/core.js

export const INITIAL_STATE = Map();

これをreducerにデフォルトの値として引数に入れる。

src/reducer.js

import {setEntries, next, vote, INITIAL_STATE} from './core';

export default function reducer(state = INITIAL_STATE, action) {
  switch (action.type) {
  case 'SET_ENTRIES':
    return setEntries(state, action.entries);
  case 'NEXT':
    return next(state);
  case 'VOTE':
    return vote(state, action.entry)
  }
  return state;
}

actionの蓄積が与えられた時、現在のstateに還元(reduce)する。そのためにこの関数はreducerと呼ばれる。

test/reducer_spec.js

it('can be used with reduce', () => {
  const actions = [
    {type: 'SET_ENTRIES', entries: ['Trainspotting', '28 Days Later']},
    {type: 'NEXT'},
    {type: 'VOTE', entry: 'Trainspotting'},
    {type: 'VOTE', entry: '28 Days Later'},
    {type: 'VOTE', entry: 'Trainspotting'},
    {type: 'NEXT'}
  ];
  const finalState = actions.reduce(reducer, Map());

  expect(finalState).to.equal(fromJS({
    winner: 'Trainspotting'
  }));
});

コア関数を直接呼び出すことと比較すれば、大きな利点がある。例えば、actionがJSONとなるオブジェクトであれば、Web Workerに送ってreducerロジックをそこで実行することが可能である。また、ネットワークを超えてオブジェクトを送ることもできる。(ただし、Reduxの規定として、Immutable データ構造ではなく、プレーンなオブジェクトとしてactionを指定している。)

A Taste of Reducer Composition

コア関数は、各関数はアプリ全体のstateを取り、アプリ全体の、次のstateを返すように定義されている。

これは大規模なアプリにとっては良い構想ではない。stateの形状を変えるために、アプリ全体の変更を要するからだ。

したがって、オペレーションを出来る限り小さなstate、あるいはサブのstate treeで行う、モジュール化(modularization)を採用する。

今のところ、このアプリは小さいので障害はないが、改善することができる点がある。vote関数は全体のapp stateを受け取る必要が無いので、この考えを反映させるために、ユニットテストを修正する。

test/core_spec.js

describe('vote', () => {

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

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

});

vote関数をvoteパートのみのstateを取るように修正する。

src/core.js

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

reducerにもこれを反映させる。

src/reducer.js

export default function reducer(state = INITIAL_STATE, action) {
  switch (action.type) {
  case 'SET_ENTRIES':
    return setEntries(state, action.entries);
  case 'NEXT':
    return next(state);
  case 'VOTE':
    return state.update('vote',
                        voteState => vote(voteState, action.entry));
  }
  return state;
}
Introducing The Redux Store

すべてのactionの蓄積があるなら、reduce関数を呼び出せば良いが、常にそういった条件下にあるわけではない。そこで、Redux Store を使う。これはオブジェクトで、名前が示す通り、stateを保管する。

Redux Store はreducer関数で初期化される。

import { createStore } from 'redux';

const store = createStore(reducer);

ここで、actionをstoreに発送(dispatch)することができる。storeはreducerを使って現在のstateにactionを適応し、その結果である次のstateを保管する。

store.dispatch({ type: 'NEXT' });

いつでも、storeから現在のstateを取得することができる。

store.getState()

store.jsを作成し、Redux Storeをexportしよう。まずはテストを作成する。storeを作り、初期stateを取得、actionを発送、stateの変更を観察する。

test/store_spec.js

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

import makeStore from '../src/store';

describe('store', () => {

  it('is a Redux store configured with the correct reducer', () => {
    const store = makeStore();
    expect(store.getState()).to.equal(Map());

    store.dispatch({
      type: 'SET_ENTRIES',
      entries: ['Trainspotting', '28 Days Later']
    });
    expect(store.getState()).to.equal(fromJS({
      entries: ['Trainspotting', '28 Days Later']
    }));
  });

});

storeを作成する前に、reduxをプロジェクトにインストールしよう。

npm install --save redux

次にstore.jsを作成し、reducerとともにcreateStoreを呼び出す。

src/store.js

import {createStore} from 'redux';
import reducer from './reducer';

export default function makeStore() {
  return createStore(reducer);
}

storeは現在のstateを保管しており、そのstateをある状態から次の状態へと発展させるactionを受け取る。

アプリケーションのエントリーポイントであるindex.jsを作成すれば、そこでstoreを作成し、exportすることができる。

index.js

import makeStore from './src/store';

export const store = makeStore();

次回: 第四回 Socket.io サーバーのセットアップ 

uraway.hatenablog.com