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

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

More than 5 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を一切かませずにコンポーネントのテストがかけた。

参考

okmttdhr
dmmcom
総合エンタテイメントサイト「DMM.com」を運営。会員数は2,900万人を突破。動画配信、FX、英会話、ゲーム、太陽光発電、3Dプリンタなど40以上のサービスを展開。沖縄での水族館事業参入、ベルギーでのサッカークラブ経営など、様々な事業を手掛ける。また2018年より若手起業家の支援を強化、「DMM VENTURES」による出資や、M&Aなどを積極的に展開している。
https://dmm-corp.com
Why not register and get more from Qiita?
  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