Jest+EnzymeでReactコンポーネントをテストする

JestはそもそもReactアプリをテストするためにあるのだと思うのですよ。作り手が同じFacebookだし、ドキュメントにもそういうことを言ってます。そこでJestでReactをテストしようとドキュメントにあたるとAirbnbの作るEnzymeを使うことを推奨されていました。FacebookAirbnbの遭遇です。なんか面白い。

// package.json
  "jest": {
    "setupFiles": [
      "./enzyme.setup.js"
    ],
    "transform": {
      "\\.(js|jsx)$": "babel-jest"
    }
  },
  "devDependencies": {
    // 関係あるところ以外は省略...
    "enzyme": "^2.9.1",
    "jest": "^20.0.4",
    "jsdom": "^11.2.0",
    "react-test-renderer": "^15.6.1",
  }

enzyme、jsdom、react-test-rendererを新たにインストールします。私はReactが最新版なのでreact-test-renderer。そうでなければインストールドキュメントに従って違うものに変える。jest設定に"setupFiles"エントリを足します。

// enzyme.setup.js
/* eslint-disable import/no-extraneous-dependencies */
const { JSDOM } = require('jsdom');
/* eslint-enable */

// material-uiのコンポーネントを描画するために必要
require('react-tap-event-plugin')();

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')
        .map(prop => Object.getOwnPropertyDescriptor(src, prop));
    Object.defineProperties(target, props);
}

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

EnzymeのためにJsDOMをglobalにアタッチしてヘッドレスに描画できるようにします。これはJasumineでもやりました。伝統的手法ですね。react-tap-event-pluginもここで起こしておきます。

まずはテスト対象のReactコンポーネントのソース。

// NavigationComponent.jsx
import React from 'react';
import PropTypes from 'prop-types';
import { Toolbar, ToolbarGroup, ToolbarTitle, Drawer, IconButton, MuiThemeProvider } from 'material-ui';
import NavigationMenu from 'material-ui/svg-icons/navigation/menu';

export default class extends React.Component {
    static propTypes = {
        fetchBooks: PropTypes.func.isRequired,
        title: PropTypes.string,
        vol: PropTypes.string,
        page: PropTypes.number,
        menuItems: PropTypes.element.isRequired,
        children: PropTypes.element.isRequired,
    };

    static defaultProps = {
        title: '',
        vol: '',
        page: 0,
    };

    constructor(props) {
        super(props);
        this.state = { open: false };
    }

    componentWillMount() {
        this.props.fetchBooks();
    }

    render() {
        return (
            <MuiThemeProvider>
                <div>
                    <Toolbar noGutter>
                        <ToolbarGroup>
                            <IconButton onTouchTap={() => this.setState({ open: true })}>
                                <NavigationMenu />
                            </IconButton>
                            <ToolbarTitle text={`${this.props.title} - ${this.props.vol} (${this.props.page})`} />
                        </ToolbarGroup>
                    </Toolbar>
                    <Drawer
                        open={this.state.open}
                        docked={false}
                        onRequestChange={open => this.setState({ open })}
                    >
                        {this.props.menuItems}
                    </Drawer>
                    {this.props.children}
                </div>
            </MuiThemeProvider>
        );
    }
}

次いでテストのコード。

import React from 'react';
import { mount } from 'enzyme';

import Navigation from '../NavigationComponent';

describe('NavigationComponent', () => {
    test('<Navigation>', () => {
        const fetchBooks = jest.fn();
        const navigation = mount(
            <Navigation
                title="aaa"
                vol="bbb"
                page={2}
                fetchBooks={fetchBooks}
                menuItems={<div>menu</div>}
            >
                <div>contents</div>
            </Navigation>);
        // componentWillMountでfetchBooksを実行しているか?
        expect(fetchBooks.mock.calls.length).toBe(1);
        // キャプション表示のテスト
        expect(navigation.find('ToolbarTitle').prop('text')).toBe('aaa - bbb (2)');
        // ドロワーの開閉テスト
        expect(navigation.state('open')).toBeFalsy();
        expect(navigation.find('Drawer').prop('open')).toBeFalsy();
        // CSSセレクターモドキはReactコンポーネントをチェーンしたこんな書き方もできる。
        navigation.find('Toolbar ToolbarGroup IconButton').simulate('touchTap');
        expect(navigation.state('open')).toBeTruthy();
        expect(navigation.find('Drawer').prop('open')).toBeTruthy();
    });
});

Enzymeにはmountを用いてフルにDOMレンダリングしてます。レンダリング方法は他にshallowレンダリングとstaticレンダリングがありますが、親子で関係しあうコンポーネントのテストを行いたかったので、shallowでは一通り書いて見てダメだった。mountによるフルDOMレンダリングではshallowでできることができた上でそれ以上のことができるので、一つやり方を習熟するのを求めるのであればmountでしょう、きっと。

jest.fn()で作ったモックをcomponentWillMountでちゃんと呼ばれているかを確かめたり、イベントを発火させてステートの変化をテストしたりできます。

構造としてはこのようにReactコンポーネント-その描画内容、というようにツリーが作られますので、findで探す時に、ToolbarTitleとかIconButtonで探せます。全部描画してるのでdivなどHTMLタグでも探せ、検索結果で複数返って来たときは.first()や.last()、.at(index)などで探します。findで引数に渡すのはCSSセレクターモドキなのでかなり柔軟に探せるけど、CSSセレクターを完璧に実装しているわけじゃないみたいで、思ったように引っ掛けない時がありました。慣れるまではここだけちょっと難しい。ちなみにshallowレンダリングするとまるっきり構造の違うものが作られます。

結構簡単にReactコンポーネントのテストが書けます。しかし、テスト対象の完成ソースを見ながらならテスト書けるけど、テストファーストでの初回ラウンドでバッチリ書くのは難しいかな。