React+Reduxのテスト方針をまとめた

MMM Corporation
mmmuser

概要

React.js Advent Calendar21日目の記事です。

Reduxというフレームワークがじわじわ広まっている。Reduxは、Fluxの概念を拡張したもので、アプリケーションでひとつの状態をもつと、クライアントでの状態管理がいろいろ便利になるよ、というコンセプトを持つ。詳細は以下の記事が詳しい。

筆者は現在React+Reduxでアプリケーションをつくっているが、今回は、そのテスト方針を書こうと思う。

テスト環境

karma+jasmine+sinonでつくる。E2Eはいろいろと・・・なので・・・メインはユニットテストで実装している。JavaScript DOMをつかってSimulationすれば、最低限は担保できるかなと考える。

テスト方針

大きな方針としては、役割ごとに切り分けてテストを書く、というのがあり、その中でも、view(component), action, reducerに絞って解説をする。そうすると何がいいかというと、テストの1ファイルの見通しがとても良くなり、シンプルになる。

ひとつひとつ見てゆくことにする。

view

view(component)のテストが担当するのは、データの表示や、DOMイベントによっておこる変化である。具体的な例をみてゆく。

以下の様なカウンターのコンポーネントを想定し、クリックした際の挙動をテストすると仮定。

function increment() {
  return { type : COUNTER_INCREMENT };
}

export class Counter extends React.Component {
  static propTypes = {
    dispatch: React.PropTypes.func.isRequired,
    counter: React.PropTypes.number,
  }

  constructor() {
    super();
  }

  render() {
    return (
      <div className='container text-center'>
        <h1>Sample Counter</h1>
        <h2>{this.props.counter}</h2>
        <button onClick={this.props.dispatch(increment)}>
          Increment
        </button>
      </div>
    );
  }
}

こちらのテストは以下のようになる。sinon.jsで関数をspyし、実行されたかどうかのテストをしている。

import React from 'react';
import TestUtils from 'react-addons-test-utils';
import { Counter } from './path/to/counter.js';

function renderWithProps (props = {}) {
  // 仮想DOMを描画する
  return TestUtils.renderIntoDocument(<Counter {...props} />);
}

describe('Counter', function () {
  let _rendered, _props, _spies;

  beforeEach(function () {
    _spies = {};

    // propsのモック
    _props = {
      // sinon.jsを使用し、関数をspyする
      dispatch: _spies.dispatch = sinon.spy(),
      counter: 0,
    };

    _rendered  = renderWithProps(_props);
  });

  // コンポーネントのテストなので関数が実行されるかどうかまでをテストすればよい
  it('Incrementボタンをクリックでdispatchが走ること', function () {
    const btn = TestUtils.scryRenderedDOMComponentsWithTag(_rendered, 'button');
    TestUtils.Simulate.click(btn[0]);

    // spyされた関数が実行されたかどうかをチェックする
    expect(_spies.dispatch.called).toBe(true);
  });
});

非同期なデータ取得や、stateの変更などはここでは責務として持たない。クリックして、dispatchがおこるか?だけをここではテストすればよいので、シンプルな実装になる。詳細は以下に書いておいたので気になる人は見てほしい。

action

次に、actionのテストを実装する。actionのテストが担当するのは、非同期関数などのメインロジックである。担当範囲は、データを取得し、正しいactionがdispatchされているかで、その後はreducerテストの担当範囲となる。

stateの変更をテストしなくて良いので、シンプルになるが、非同期な処理が入ってくるので少し工夫する必要がある。実際に例を見てゆく。

以下の様なactionを想定する。

import {
  REQUEST,
  REQUEST_SUCCESS,
} from 'constants';

// リソース管理はsuperagent
import request from 'superagent';

function request() {
  return {
    type: REQUEST,
  };
}

function requestSuccess(items) {
  return {
    type: REQUEST_SUCCESS,
    items: items,
  };
}

export function fetchSomeResource() {
  return (dispatch, getState) => {
    dispatch(request());
    request
      .get('http://apiserver.com/someuri')
      .end(function(err, res) {
        return dispatch(requestSuccess(res.body.items));
      });
  };
}

このテストは、以下のようになる。mockStoreは、redux-mock-storeというライブラリを使って、store層をモックしている。

内部では、想定されたaction(expectedActions)がstore層でdispatchされるか、というのをテストしてくれている。

import {
  REQUEST,
  REQUEST_SUCCESS,
} from 'constants';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { fetchSomeResource } from './action.js';


describe('(Action)', function() {
  const middlewares = [ thunk ];
  const mockStore = configureMockStore(middlewares);
  const initialState = {
    items: [],
    isFetching: false,
  };

  const expectedActions = [
    { type: REQUEST },
    { type: REQUEST_SUCCESS, items: ['items'] },
  ];

  it('REQUEST_SUCCESSがdisapatchされること', (done) => {
    const store = mockStore(initialState, expectedActions, done);
    store.dispatch(fetchSomeResource());
  });
});

このように、actionを作成するまでのロジックに集中してテストを書くことができる。詳細は以下に書いてあるので気になる人は見てほしい。

reducer

reducerでは、受け取ったデータを加工し、正しいstateを返しているかどうかをテストする。基本的に、reducerはそこまで大きな処理はしないと思うので、テストもシンプルになる。公式のテストをそのままのせてみるが、以下のようになる。

import { ADD_TODO } from '../constants/ActionTypes'

const initialState = [
  {
    text: 'Use Redux',
    completed: false,
    id: 0
  }
]

export default function todos(state = initialState, action) {
  switch (action.type) {
    case ADD_TODO:
      return [
        {
          id: state.reduce((maxId, todo) => Math.max(todo.id, maxId), -1) + 1,
          completed: false,
          text: action.text
        },
        ...state
      ]

    default:
      return state
  }
}

上のようなreducerは以下のようにテストできる。

import expect from 'expect'
import reducer from '../../reducers/todos'
import * as types from '../../constants/ActionTypes'

describe('todos reducer', () => {
  it('should handle ADD_TODO', () => {
    expect(
      reducer([], {
        type: types.ADD_TODO,
        text: 'Run the tests'
      })
    ).toEqual(
      [
        {
          text: 'Run the tests',
          completed: false,
          id: 0
        }
      ]
    )
  })
})

懸念・注意点

  • コンポーネントのテストをすべて書こうと思うと、ファイル数が大変なことになるので、ロジックがはいっているものだけにするとか、適宜判断してゆくことが大事かもしれない。
  • connectされたコンポーネントは、切り出してテストしたほうがいいかもしれない。
  • callComponentWillReceiveProps, callComponentWillUnmountなどをよぶ時はやや工夫が必要。
  • まだ出会ったことはないが、view, action, reducerが密接に絡んでいる時は、切り分けてテストするというのができないかもしれない。

まとめ

ある程度パターンが決まってくるので、テストが書きやすい。また、変に、view, action, reducerを一緒くたにテストするよりも、問題を見つけやすいのかなとも思った。

また、テスト方針についてだけでなく、公式ドキュメントがいろいろ参考になるので見ておくといいかもしれない。

今回ご紹介したテスト手法を取り入れ、品質の見える化を実現したサービス開発をご検討の企業様は、是非MMMにご相談下さいませ!

AUTHOR
デロイト トーマツ ウェブサービス株式会社(DWS)
デロイト トーマツ ウェブサービス株式会社(DWS)
デロイト トーマツ ウェブサービス株式会社はアマゾン ウェブ サービス(AWS)に 専門性や実績を認定された公式パートナーです。
記事URLをコピーしました