公開されている React コンポーネントのテストコードを見てみると、enzymeを使ってテストしているものを結構見かけます。enzyme はReact 公式でも押しているっぽいので、積極的に使っていきたいです。

Note:
Airbnb has released a testing utility called Enzyme, which makes it easy to assert, manipulate, and traverse your React Components’ output. If you’re deciding on a unit testing library, it’s worth checking out: http://airbnb.io/enzyme/

日本語の紹介記事では@syossan27さんの記事が参考になりました。

React のテストを Enzyme で書いてみよう - Qiita

上記の記事では、コンポーネントの shallow レンダリングでのテストが実施されています。shallow レンダリングでは、全てのライフサイクルイベントが呼ばれないので、それらが必要な場合はフルレンダリングした状態でテストを実施する必要があります。


という訳で、表題にある通り、enzyme+mocha+power-assert で React コンポーネントのフルレンダリングテストを実施するまでを書いていきたいと思います。

前提

  • browserifywebpack等のビルド環境は整ってる
  • 今回対象したのはbrowserify+babelify
  • 使用する React のバージョン
    • react v15.2.0
    • react-dom v15.2.0

ビルド周りは特に関係無いと思いますが念のため。既に対象となるコンポーネントがコーディング済み、という前提で進めたいと思います。

テスト対象のコンポーネント

対象とするのは、通知などに使われる Toast コンポーネントを想定したいと思います。仮コンポーネントの為、雑な作りとなっている点は大目に見てやってください。

import React, { Component, PropTypes } from 'react';

export default class Toast extends Component {
  static propTypes = {
    children: PropTypes.node,
    onRequestClose: PropTypes.func,
  };

  constructor(props) {
    super(props);
    this.handleClose = this.handleClose.bind(this);
  }

  componentDidMount() {
    this.timer = setTimeout(this.handleClose, 3000);
  }

  componentWillUnmount() {
    clearTimeout(this.timer);
    this.timer = null;
  }

  handleClose() {
    const { onRequestClose } = this.props;

    if (typeof onRequestClose === 'function') {
      onRequestClose();
    }
  }

  render() {
    return (
      <div className="toast">
        <button className="toast__close" onClick={this.handleClose}>
          Close!
        </button>
        <div className="toast__body">{this.props.children}</div>
      </div>
    );
  }
}

以下の様な動作を期待します。

  • マウント後、3 秒後にonRequestCloseコールバックを呼び出す
  • button要素のクリックで同じくonRequestCloseコールバックを呼び出す

ファイル/ディレクトリ構成

ここまでで以下の様なディレクトリ構成となっています。

.
├── dist          #bundleファイル
│   └── index.js
├── package.json
└── src           #各コンポーネントファイル等
    ├── Toast.js  #テスト対象のToastコンポーネント
    └── index.js  #エントリーファイル (本記事では未使用)

今回使用したファイルは以下のリポジトリにあるので、記事に書かれていない箇所で不明な点があればご確認ください。

wadackel/enzyme-sample
https://github.com/wadackel/enzyme-sample

テストに必要なパッケージのインストール

必要なパッケージをインストールしていきます。

$ npm i -D mocha enzyme react-addons-test-utils jsdom babel-register power-assert

それぞれ以下のバージョンがインストールされました。

{
  "devDependencies": {
    "babel-register": "^6.9.0",
    "enzyme": "^2.3.0",
    "jsdom": "^9.4.0",
    "mocha": "^2.5.3",
    "power-assert": "^1.4.1",
    "react-addons-test-utils": "^15.2.0"
  }
}

react-addons-test-utils は直接使用しませんが、インストールしておかないと enzyme 実行時に怒られます。

あと、onRequestCloseが呼ばれたかどうか、3 秒後に ◯◯ する、などの処理を簡単にテストしたいのでsinonもインストールしておきます。これは表題に直接関係無いのでお好みで。

$ npm i -D sinon

テストのセットアップ

testディレクトリを作成後、jsdom の設定ファイルとテストファイルを追加します。

$ mkdir test
$ touch test/.setup.js test/Toast.spec.js

ここまでで以下のファイル構成です。

.
├── dist
│   └── index.js
├── package.json
├── src
│   ├── Toast.js
│   └── index.js
└── test
    ├── .setup.js
    └── Toast.spec.js

npm scripts へ test を追加

$ npm testを叩いたら、テストが実行されるように設定します。

"scripts": {
  "test": "mocha test/**/*.spec.js -r test/.setup.js --compilers js:babel-register"
}

ポイントは以下。

  • 後述する jsdom の設定ファイル.setup.jsを読み込み
  • コンパイラにbabel-registerを設定

jsdom の設定

フルレンダリングを使ったテストでは、shallow レンダリングとは異なりdocumentオブジェクトに対してグローバルにアクセス出来る状態が必要です。enzyme のガイドを参考に jsdom の設定を行います。

enzyme/jsdom.md at master · airbnb/enzyme
https://github.com/airbnb/enzyme/blob/master/docs/guides/jsdom.md

require('babel-register')();

import { jsdom } from 'jsdom';

const exposedProperties = ['window', 'navigator', 'document'];

global.document = jsdom('');
global.window = document.defaultView;
Object.keys(document.defaultView).forEach((property) => {
  if (typeof global[property] === 'undefined') {
    exposedProperties.push(property);
    global[property] = document.defaultView[property];
  }
});

global.navigator = {
  userAgent: 'node.js',
};

windowdocumentなど、ブラウザ固有の値をグローバル変数へ登録しているみたいです。

コンポーネントのテストコードを書く

準備が整ったので、Toastコンポーネントのテストを書いてみます。

import assert from 'power-assert';
import sinon from 'sinon';
import React from 'react';
import { mount } from 'enzyme';
import Toast from '../src/Toast';

describe('<Toast />', () => {
  it('Should call onRequestClose callback on close click', () => {
    const props = { onRequestClose: sinon.spy() };
    const wrapper = mount(<Toast {...props} />);

    wrapper.find('.toast__close').simulate('click');
    assert(props.onRequestClose.called === true);
  });

  it('Should call onRequestClose callback on 3 seconds after the mount', () => {
    const clock = sinon.useFakeTimers();
    const props = { onRequestClose: sinon.spy() };
    const wrapper = mount(<Toast {...props} />);

    clock.tick(1000);
    assert(props.onRequestClose.called === false);

    clock.tick(2000);
    assert(props.onRequestClose.called === true);
  });
});

onRequestCloseが呼ばれるかどうか、といった最低限の内容ですがこれで実際にテストを走らせてみます。

$ npm test

  <Toast />
    ✓ Should call onRequestClose callback on close click (43ms)
    ✓ Should call onRequestClose callback on 3 seconds after the mount


  2 passing (84ms)

componentDidMount内で設定したsetTimeoutが正常に動き、マウント後 3 秒経過時にonRequestCloseが呼び出されていることが確認できました。


週末に公開したreact-md-spinnerでテストを書く時に、enzyme どうやって使おうっていうところを軽く調べた内容について纏めました。enzyme 使ってみよう、という方の参考になれば嬉しいです。

React に限らず mocha+power-assert を使ったテストが自分の中でよく使うスタックなので、enzyme はそれらと簡単に統合出来て、割と直感的な API でテストが書き進められるので良い感じですね。

追記: 2016.07.15

azu さんのツイートで言及いただいていたので、該当箇所を修正しました。恥ずかしながら--requireオプションを使ったことが無かったので参考になりました。ありがとうございます。

一応変更箇所の diff を貼っておきます。

package.json
-    "test": "mocha test/.setup.js test/**/*.spec.js --compilers js:babel-register"
+    "test": "mocha test/**/*.spec.js -r test/.setup --compilers js:babel-register"