React Component Test: Enzyme+Mocha+Chai

Reactのコンポーネントテストは、Enzyme+MochaもしくはJestが広く使われています。

EnzymeはAirbnbが開発したReactコンポーネントユーティリティです。単体ではテストランナーやアサーションは実装していないため、他のテストランナー及びアサーションライブラリと組み合わせて利用します。一般的にMochaChaiとの組み合わせが使われます。MochaがテストランナーでChaiがアサーションライブラリです。Enzymeは元々Mochaとの併用を考え設計されていますが、Angularで利用されているKarmaや後述するJestなどとも組み合わせることができます。

JestはFacebookが開発しているテストツールです。テストランナーとアサーションライブラリが付属しているため、Enzymeと異なりオールインワンでテスト環境が用意されています。create-react-appにはJestが同梱しているため実質React標準のテストツールです。Jestにはスナップショットテストもあります。これは出力をチェックします。出力結果が初回時に保存されるスナップショットと異なる場合、それが意図的か否かでパスするか修正するかという使い方になります。Reactは結局のところHTMLの描画が目的であるため、スナップショットテストは多くのテストを軽量にしてくれる可能性を秘めています。

Reactコンポーネントをテストするにはいずれか、もしくは併用することになります。他にもReact上の非同期処理をテストするSinonなどのテストツールもありますが、今回は割合します。

どれを使用すればいいかというのは難しい話です。Jestはオールインワンで構成されている点が利点です。Mochaは成熟されたツールのため使用例が多く、またアサーションやテストライブラリを交換できる柔軟性が利点です。逆を取れば、構成が面倒とも考えられます。

結局は使う人の好みなのですが、本記事ではEnzyme+Mocha+Chaiという組み合わせを選択し、構成のセットアップと実際に簡単なテストに触れてみます。

目次

Mocha and Chai

Mochaはテストランナーです。一方Chaiはアサーションを担当します。

yarn add mocha chai --dev

これら2つはJavascriptのテストツールのため、コンポーネント部分を除けばこのままでも利用できます。しかし、Reactでテストを行うためにはもう1つ必要なものがあります

jsdom

ReactはブラウザにHTMLレンダリングを行うため、擬似的なブラウザ環境を用意する必要があります。ChromeやFirefoxは駄目なの?と思いますが、テストはブラウザ上で走るわけではないため、私たちが普段利用するブラウザは使用できません。広く使われているのは、jsdomです。

yarn add jsdom --dev

jsdomをインストールしたら、擬似的なブラウザ環境を構築するする必要があります。test/dom.jsというファイルを作成し、そこに以下を記述します。

test/dom.js

const { JSDOM } = require('jsdom');

const jsdom = new JSDOM('<!doctype html><html><body></body></html>');
const { window } = jsdom;

function copyProps(src, target) {
  const props = Object.getOwnPropertyNames(src)
    .filter(prop => typeof target[prop] === 'undefined')
    .reduce((result, prop) => ({
      ...result,
      [prop]: Object.getOwnPropertyDescriptor(src, prop),
    }), {});
  Object.defineProperties(target, props);
}

global.window = window;
global.document = window.document;
global.navigator = {
  userAgent: 'node.js',
};
copyProps(window, global);

このコードはAirbnbのチュートリアルに記載されているものです。簡単に解説すると、普段利用しているブラウザに組み込まれているwindowオブジェクト(正しくはWindowオブジェクトのWindowプロパティ)やnavigationオブジェクトを生成しています。

Enzymeのセットアップ

EnzymeはAirbnbが開発したコンポーネントテストライブラリです。開発環境に導入するには、Enzymeに加えReactのバージョンに合ったアダプターを導入する必要があるます。以下はReactのバージョンが16の場合を想定しています。もし他のバージョンを利用している場合は、ドキュメントを参照して対応するものを導入してください。

yarn add enzyme enzyme-adapter-react-16 --dev

次にテスト全体の設定ファイルを作成します。各テストファイルにEnzymeやChaiのテスト関数をインポートしてもよいのですが、テストファイルが増えてくると冗長になるため、よく利用するものは1つの設定ファイルに記述した後、グローバルな空間にインポートします。

test/test_helper.js

import { expect } from 'chai';
import { shallow, mount, configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

// アダプターの作成
configure({ adapter: new Adapter() });

// グローバル空間にアサーション用の関数をインポート
global.expect = expect;
global.mount = mount;
global.shallow = shallow;

最後に実行ファイルを定義します。

package.json

"scripts": {
    //(略)
    "test:unit": "mocha ./test/mochasetup.js --require babel-core/register --require ./test/test_helper.js --require ./test/dom.js 'src/**/*.spec.js'",
}

mochaに「*.spec.js」をテストファイルと見なし実行するようにしています。また、上記で作成した2つのファイルを--requireで読み込んでいます。以前利用されていた--compilereを使う方法は廃止になるそうです。

mochasetup.jsというファイルを指定しています。

test/mochasetup.js

require.extensions['.jpg'] = function(module, fileName) {
  return module._compile("module.exports = "+JSON.stringify(fileName), fileName)
}

Mochaのテスト時はローダーを指定していないため、画像を認識できません。そこでこのような設定ファイルを作成し、画像ファイルを取り扱えるようにしています。

一方、静的な画像リソースやCSSといったものがテスト上、邪魔になるケースもあります。Unexpected characterが表示される場合などです。そういった場合は、リソースを無視させます。null-loaderignore-stylesを利用する方法もありますが、以下のような設定を定義すれば、スルーができるようです。

require.extensions['.css'] = function () {
  return null;
};
require.extensions['.png'] = function () {
  return null;
};
require.extensions['.jpg'] = function () {
  return null;
}

Sample Application

テストを作成するには何かしらのアプリケーションが必要です。ここでは、テスト対象となる極めてシンプルなアプリケーションを作成します。

src/components/App.js

import React, { Component } from 'react';

import blackCat from '../../public/assets/blackcat.jpg';
import alleryCat from '../../public/assets/alleycat.jpg'

class App extends Component {
    constructor() {
        super();
        this.state = {
            flag: true,
        }
    }

    switchImg = () => this.setState((prevState) => ({
        flag: !prevState.flag
    }));

    render() {
        const { flag } = this.state;

        return (
            <div>
                <div>
                    <button type="button" onClick={this.switchImg}>
                        Change
                    </button>
                </div>
                <SwitchImage flag={flag} />
           </div> 
        );
    }
}

export const SwitchImage = ({ flag }) => 
    flag ? <img src={blackCat} alt="黒猫" />
    : <img src={alleryCat} alt="野良猫" />

export default App;

ボタンをクリックすると画像が切り替わるだけのコードです。

通常、ユニットテストは関数・メソッド単位のテストになりますが、今回メインコンポーネントから呼ばれるのはステートレスな子コンポーネント関数のみ定義しています(ステートレスなコンポーネントは単なる関数です)。

Enzymeを利用したユニットテスト

テスト環境が整ったので、簡単なテストに触れてみます。以降、いくつかテストを書いていますが、色々試すために統一感のないコードになっています。

初期化時のテスト

まずシンプルなshallow()を試してみます。この関数は先程assertion.jsで定義したEnzymeの関数です。shallow()は指定したコンポーネントのみレンダリングを行います。内部に含まれる子コンポーネントまでは描画されません。つまりコンポーネント単位でテストができます。

テストと言う程でもないですが、初期化部分に関するテストを書いてみます。

  1. Appコンポーネントの状態(state)オブジェクトが初期化されているか
  2. Appコンポーネントは内部にswitchImageコンポーネントを1つ持っているか
  3. SwitchImageコンポーネントはデフォルトの黒猫の画像(blackcat.jpg)を表示するか

下記は1つのテストスイートと3つのテストを記述しています。describeブロックがテストスイートを定義し、itはテストケースを定義します。

src/components/App.spec.js

import React from 'react';
import App, { SwitchImage } from './App';

const onImg = 'blackcat.jpg';
const offImg = 'alleycat.jpg';

describe('default App Component', () => {
    let defaultFlag = true;
    let defaultState = { flag: defaultFlag };
    let component = shallow(<App />);

    it('[1] has default state', () => {
        expect(component.state()).to.deep.equal(defaultState);
    });

    it('[2] renders the SwitchImage wrapper', () => {
        expect(component.find(SwitchImage)).to.have.length(1);
    });

    it('[3] img tag attributes', () => {
        const switchImage = shallow(<SwitchImage flag={defaultFlag} />);
        let path = switchImage.find('img').props().src;
        path = path.substring(path.lastIndexOf('/')+1);
        expect(path).to.equal(onImg);
    });
});

基本的にexpect()とアサーション関数使ってテストを行います。Reactコンポーネントの取り扱いは、先に説明したようにshallow()でラップします。例えばここではshallow(<App />)としています。

1つ目のテストのようにコンポーネントの状態オブジェクトはstate()で取得できます。オブジェクトの等価チェックはto.deep.equal()を使用します。尚、プリミティブ値はto.equal()です。内部のコンポーネントや要素を取得するには2つ目のテストのようにfind()を使います。個数をテストしたいのでto.have.length()を使用しています。3つ目のテストは、まずは子コンポーネントのflagを指定してshallow()に渡しています。そのレンダリングとなる<img>のsrc属性が初期画像のファイル名と等しいかをテストしています。

yarn test:unit
App Component
    ✓ has default state
    ✓ renders the SwitchImage wrapper
    ✓ img tag attributes

テストは成功(緑色)のチェックマーク、またはエラー(赤色)のメッセージのいずれかを返します。

状態オブジェクト変更後のテスト

次に状態オブジェクトを更新したときのテストを書いてみます。

  1. SwitchImageコンポーネントのflagがfalseになっているか
  2. flagがfalseのときに描画する画像が野良猫(allerycat.jpg)になっているか
describe('when state changes', () => {
    let changeFlag = false;
    let component = mount(<App />);

    component.setState({ flag: chnageFlag });
    let switchImage = component.find(SwitchImage);

    it('[1] updates SwitchImage state', () => {
        expect(switchImage.props().flag).to.equal(chnageFlag);
    });

    it('[2] src test', () => {
        let path = switchImage.find('img').props().src;
        path = path.substring(path.lastIndexOf('/')+1);
        expect(path).to.equal(offImg);
    });
});

やっていることは先と同じなので省略しますが、ここではshallow()ではなくmount()を使っています。mount()は子コンポーネントもレンダリングします。コンポーネント階層全体をレンダリングするため、結合テストで使われているようです。仮にshallo()で書き換えると、2つ目のテストは失敗します。

yarn unit:test 
when state changes
    ✓ updates SwitchImage state
    ✓ src test

今回使用していませんがrender()というものもあります。これはmount()に似ていて、すべての子コンポーネントをレンダリングします。ただし、Reactのライフサイクルを持ちません。このためmount()よりも負荷が小さいと言われています。

おわりに

実際はコンポーネントのユニットテストはあまり重視されないかもしれません。Reactコンポーネントは機能が完全に独立しているため、破損はそれほど起こりません。コンポーネント内で他のコンポーネントを利用しテストすれば堅牢性が高まるという考えだそうです。頭の片隅に入れておくといいかもしれません。

未熟なため参考にはならないかもしれませんが、雰囲気だけでも伝われば幸いです。