Help us understand the problem. What is going on with this article?

React+ReduxのコンポーネントをKarma+Jasmineでユニットテストする

More than 3 years have passed since last update.

概要

React+ReduxのコンポーネントをKarma+Jasmineでユニットテストする。

基本

以下の様なカウンターのコンポーネントを想定する。

counter.js
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>
    );
  }
}

これのテストは以下のようになる。仮想DOMを描画しデータ表示のテスト、また、sinon.jsで関数をspyし、実行されたかどうかのテストをしている。これはコンポーネントのテストなので、関数の実行有無までをテストし、関数自体は、actionやreducerでテストしてやれば良い。

counter.spec.js
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('<h1>の描画', function () {
    const h1 = TestUtils.scryRenderedDOMComponentsWithTag(_rendered, 'h1');
    expect(h1.length).toEqual(1);
    expect(h1[0].textContent).toEqual('Sample Counter');
  });

  // propsが描画される
  it('<h2>の描画', function () {
    const h2 = TestUtils.scryRenderedDOMComponentsWithTag(_rendered, 'h2');
    expect(h1.length).toEqual(1);
    expect(h1[0].textContent).toEqual('0');
  });

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

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

propsの変化をテストする

propsが変化したときのコンポーネントの振る舞いをテストしたいという時もある。

例えば、以下の様な場合を考える。counterが3以上になると、別のactionがdispatchされるような実装である。

counter.js
function increment() {
  return { type : COUNTER_INCREMENT };
}

// 追加
function stopIncrement() {
  return { type : COUNTER_STOP_INCREMENT };
}

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

  constructor() {
    super();
  }

  // 追加
  componentWillReceiveProps(nextProps) {
    if (nextProps.counter > 2) {
      this.props.dispatch(stopIncrement);
    }
  }

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

このテストは以下のように書く。

counter.spec.js
// 略

import {callComponentWillReceiveProps} from './path/to/helper.js';

describe('Counter', function () {

  // 略

  it('counterが3以上でdispatch関数が実行される', function () {
    const nextProps = { ..._props, counter: 3 };
    callComponentWillReceiveProps(Counter, _props, nextProps);
    expect(_spies.dispatch.called).toBe(true);
  });  
});

helper.js
import React from 'react';
import TestUtils from 'react-addons-test-utils';

/**
 * 引数に渡したコンポーネントの親の状態を変え、`componentWillReceiveProps`を発火させる。
 * `props`の変更による、描画結果のテストや、関数の発火などをテストする。
 *
 * @param {Object} Component
 * @param {Object} props
 * @param {Object} nextProps
 */
export function callComponentWillReceiveProps(Component, props = {}, nextProps = {}) {
  class ParentClass extends React.Component {
    constructor() {
      super();
      this.state = props;
    }
    render() {
      return <Component {...this.state} />;
    }
  }
  const parent = TestUtils.renderIntoDocument(<ParentClass />);
  parent.setState(nextProps);
}

まず、propsを操るために、ParentClassという親コンポーネントを作成している。そして、引数として渡したnextPropsを親クラスのstateにセットし、それがCounterコンポーネントにpropsとして伝播される。

これにより、componentWillReceivePropsが呼び出されるので、あとは_spies.dispatch.calledをみてやればよい。

関数をstub

コンポーネントでは、関数の中身をテストする必要はないので、余計な処理をしないように、関数をstubすると便利な時がある。stubはsinon.jsで用意されているので、以下のように簡単にかける。

counter.spec.js
  // someMethodはCounterコンポーネントのメソッド

  it('stub', function () {
    sinon
      .stub(Counter.prototype, 'someMethod')
      .onCall(0)
      .returns(() => _spies.someMethod = sinon.spy());
    const someMethodKicker = TestUtils.scryRenderedDOMComponentsWithClass(_rendered, 'someMethodKicker');
    TestUtils.Simulate.click(someMethodKicker[0]);
    expect(_spies.someMethod.called).toBe(true);
  });

まとめ

結果的に、actionやreducerを一切かませずにコンポーネントのテストがかけた。

参考

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away