読者です 読者をやめる 読者になる 読者になる

gulp-jasmineでES6で書いたReactコンポーネントとReactステートレスコンポネントさらにはExpressルーターを同時にテストできるようにする

一通りハマって模索した結果、それぞれしっかり原因判明やりきって解決するまでの質ではないのですが、ES6 で書いたReactコンポーネントとステートレスコンポーネントをテストするとともに、ES6で書いたExpressルーターもまとめてテストできるプロジェクト設定・構成が一つ用意できました。

まず前提。

  • ES6で書いて、Babelでトランスパイルしている
    • jestで作ったテスト環境では、SuperTestを用いたExpressルーターテストがどうにも動かなかった
    • jest.autoMockOff()をかけても、あちらこちらでモックになっちまう。
    • 先にBabelトランスパイルしてから、出力ファイルでjest実行してみてもなぜか改善せず
    • jestは難しいことを簡単にできて素敵に思っていたけど、意外に簡単と思ってたところでエラーが出始め。解決がどうにも難しくてjestにギブアップしてしまった
  • Reactステートレスコンポーネントはテストが動かない。
    • 普通のReactコンポーネントはテスト可能
    • 後で判明するけどこれはjestのせいじゃなかった

ぐるぐる回ってみて、ちょっとjestでは層が深くて追いきれなくなってきたのでJasmine2でくみなおしました。もちろんいきなり結論には辿りつけずにいろいろ入れたり外したりしましたよ。。。以下は、シンプル化したpackage.jsonです。Reactテストには、gulp-jasmineとreact-addons-test-utilsとjsdomが関わります。Expressテストの方ではsupertest。ビルド及びテストの前処理は、gulp-babelとbabel-preset-*。

{
  "name": "sample",
  "version": "0.0.1",
  "private": true,
  "dependencies": {
    "express": "^4.13.4",
    "react": "^0.14.7",
    "react-dom": "^0.14.7"
  },
  "devDependencies": {
    "babel-preset-es2015": "^6.5.0",
    "babel-preset-react": "^6.5.0",
    "gulp": "^3.9.1",
    "gulp-babel": "^6.1.2",
    "gulp-jasmine": "^2.2.1",
    "jsdom": "^8.0.2",
    "react-addons-test-utils": "^0.14.7",
    "supertest": "^1.2.0"
  },
  "babel": {
    "presets": [
      "es2015",
      "react"
    ]
  },
  "scripts": {
    "build": "gulp build",
    "start": "cd dest; node app.js",
    "test": "gulp test"
  }
}

gulpでトランスパイルもしくはテスト実行という流れで、今後、もうチョイと便利に改良していく予定。gulpfile.jsだけはES5で書いてます。

// gulpfile.js
'use strict';
var gulp = require('gulp');
var babel = require('gulp-babel');
var jasmine = require('gulp-jasmine');
var reporter = require('jasmine-terminal-reporter');
var jsdom = require('jsdom').jsdom;
require('babel-register'); // Jasmineで事前トランスパイルなしに動かすために必要

gulp.task('build', function() {
    gulp.src(['src/**/*.js', '!src/**/__tests__/*.js']) //テストは出力しない
        .pipe(babel())
        .pipe(gulp.dest('dest'));
});

gulp.task('test', function () {
    global.document = jsdom(''); // 空白文字列でOK。好きなHTMLを書いても支障なし。
    global.window = global.document.defaultView; // defaultViewにするのは最近の仕様変更。
    for (var key in global.window) {
        if (global.window.hasOwnProperty(key) && !global[key]) {
            global[key] = global.window[key]; //念のため上書きなしで参照をコピーしておく
        }
    }
    gulp.src('src/**/__tests__/*.js')  //テストの配置方法は直前まで試していたjestの名残りです。
        .pipe(jasmine({reporter: new reporter()}));
});

トランスパイルの方はいいとして、テストの方について。Reactのテストはやり方によってはDOMが要らないやり方もあるみたいですが、jestを通じて知ったTestUtilsのrenderIntoDocumentを使う直感的なビヘイビアテストをするために、JsDOMでヘッドレスなDOM環境を作ります。その際、テストのルートたるgulpタスクで作っちゃった。コード中で「typeof window === 'undefined'」とサーバ環境判定していたりするとダメですが、これだけは自分で書かないと決めりゃいいかな。jestはざっくりとその辺はうまくやってるっぽい(jest/JSDOMEnvironment.js at master · facebook/jest · GitHub と jest/NodeEnvironment.js at master · facebook/jest · GitHub)。

ちなみに、React Test Utilsのドキュメントの該当箇所「NOTE」を読んでも全くわからなかったため相当費やしましたよ。。。解決のためにReactだけでなくjestやJsDOMの見当違いなソース箇所ばかり読んでた(Test Utilities | React)。

node環境のグローバルスコープの名前であるglobalのプロパティとしてdocumentを作り、windowを付け足します。その後にfor...inループではwindowからglobalへ上書きしないようにしながら参照をコピーしました。本当はnavigatorだけ手当てすれば良いみたいですけど念のためです。次いでgulp-jasmineでsrcフォルダのテストを一切実行します。jestの名残でテストのフォルダは各所の__tests__フォルダとなってます。

buildタスク、testタスクともにBabelのプリセット設定はpackage.jsonを使ってくれてますのでES6とJSXどちらも対応しています。

'use strict';
import React from 'react';

class CheckboxWithLabel extends React.Component {
    constructor(props) {
        super(props);
        this.state = {isChecked: false};
    }

    handleChange() {
        this.setState({isChecked: !this.state.isChecked});
    }

    render() {
        const sender = this;
        return (
            <label>
                <input
                    type="checkbox"
                    checked={this.state.isChecked}
                    onChange={() => sender.handleChange()}
                />
                {this.state.isChecked ? this.props.labelOn : this.props.labelOff}
            </label>
        );
    }
}

CheckboxWithLabel.propTypes = {
    labelOn: React.PropTypes.string.isRequired,
    labelOff: React.PropTypes.string.isRequired
};

export default CheckboxWithLabel;

上記のテスト対象はjestでのチュートリアルのものです。ESLintに怒られたのでちょっと整えました。

'use strict';
import React from 'react';
import ReactDOM from 'react-dom';
import TestUtils from 'react-addons-test-utils';
import Express from 'express';
import SuperTest from 'supertest';
import CheckboxWithLabel from '../CheckboxWithLabel';
import {ResponsiveCol} from '../BootstrapResponsive';
import WorkableRouter from '../WorkableRouter';

describe('React Components and Express' , () => {
    it('changes the text after click', () => {
        var checkbox = TestUtils.renderIntoDocument(
            <CheckboxWithLabel labelOn="On" labelOff="Off" />
        );
        var checkboxNode = ReactDOM.findDOMNode(checkbox);
        expect(checkboxNode.textContent).toEqual('Off');
        TestUtils.Simulate.change(
            TestUtils.findRenderedDOMComponentWithTag(checkbox, 'input')
        );
        expect(checkboxNode.textContent).toEqual('On');
    });

    // 下はReactステートレスコンポーネントのために動かない。
    it('hidden setting', () => {
        const col = TestUtils.renderIntoDocument(
            <ResponsiveCol xs={{hidden: true}} sm={{hidden: true}} md={{hidden: false}}>
                <div></div>
            </ResponsiveCol>
        );
        const colNode = ReactDOM.findDOMNode(col);
        expect(colNode.getAttribute('class').trim()).toEqual('hidden-xs hidden-sm');
    });

    it('get jobs', (done) => {
        const app = Express();
        app.use(WorkableRouter);
        SuperTest(app)
            .get('/jobs')
            .expect(200, done);
    });

});

もちろんテストもjestから。これと2/10の記事に書いたReactステートレスコンポーネントのテストも並べておきます。さらにSuperTestでルータの非同期テストも。

F..

events.js:154
      throw er; // Unhandled 'error' event
      ^
Failures: 
Error: Tests failed
1) React Components and Express hidden setting
1.1) TypeError: Cannot read property 'getAttribute' of null

3 specs, 1 failure
Finished in 0.4 seconds

結果は、ES6-classのReactコンポーネントはOK、SuperTestはOK。でもReactステートレスコンポーネントはNG。TestUtils.renderIntoDocumentでnullを返してます。しばしトランスパイルされた結果を眺めていて気がつきました。そこで下記の工夫。

    it('hidden setting', () => {
        const Component = ResponsiveCol({xs: {hidden: true}, sm: {hidden: true}, md: {hidden: false}, children: ''});
        const col = TestUtils.renderIntoDocument(Component);
        const colNode = ReactDOM.findDOMNode(col);
        expect(colNode.getAttribute('class').trim()).toEqual('hidden-xs hidden-sm');
    });

3種とも通りました!ステートレスコンポーネントはこう書けばいいのね。ステートレスコンポーネントは引数にプロパティを渡す単なる関数で、戻りはReact ElementになるのでそのままrenderIntoDocumentに渡してあげればよかった!まあrenderIntoDocumentの実装が追いついていないってことで確定だと思います。

...

3 specs, 0 failures
Finished in 0.3 seconds

そろそろgulpでちゃんとしたワークフローを組むべきかな。gulpのgithubにレシピ集があった( gulp/docs/recipes at master · gulpjs/gulp · GitHub)。いろいろ参考になる。