DMM.comラボエンジニアブログ

DMM.comラボのエンジニアブログです。DMM.comを支える技術について書いています。

EnzymeでReactコンポーネントのテストを書こう

こんにちは、プラットフォーム開発部の新卒エンジニアの松下です。
普段は会員基盤フロントエンドチームでログインやアカウント登録などの会員基盤システムの開発をしています。

早いものであと1ヶ月ちょっとで1年目が終了。春には次の新卒が入ってくるとのことで、うかうかしていられないなと思う今日このごろです。

さて本日はEnzymeを使ってReactのテストを書く方法を紹介したいと思います。 f:id:dmmlabotech:20170303125726p:plain Enzymeは宿泊予約サイトのAirbnbが開発しているReactコンポーネントのテストツールです。

チームでは以前よりテスティングフレームワーク「Mocha」とアサーションライブラリ「Chai」でNode.jsアプリケーションの単体テストや結合テストを行ってきました。しかし、これらのツールだけではReactのテストを行うことができず、今回Enzymeの導入を検討することにしました。

目次

Enzymeのセットアップ

業務でも使用しているMochaとChaiを用いて、Enzymeを使います。

$ npm install mocha chai enzyme jsdom sinon react-addons-test-utils --save-dev

まずはテスト実施に必要なパッケージをインストールします。各パッケージの詳細は以下の通りです。

  • Mocha - テスティングフレームワーク
  • Chai - アサーションライブラリ
  • Enzyme - Reactのテスティングツール
  • jsdom - Node.js環境でDOMの操作を可能にするパッケージ
  • Sinon.JS - スパイやスタブ、モックなどテストダブルを提供
  • react-addons-test-utils - Reactが提供するテストユーティリティ(Enzymeが使っているのでいれないと怒られる)
$ npm install babel babel-core babel-preset-es2015 babel-preset-react --save-dev

ES6やJSXで記述されたコードをMochaでテストするためにはテスト実行前にトランスパイルする必要があります。そのために必要なパッケージもあわせてインストールします。

{
  ...
  "babel": {
    "presets": [
      "es2015",
      "react"
    ]
  }
  ...
}

次にトランスパイル実行時の処理のオプションを指定します。package.json"babel"か、.babelrcファイルに上記コードを追加します。

テスト対象のコードがES6とReactを用いているのでes2015reactをプリセットに指定しています。

/* setup.js */

var jsdom = require('jsdom').jsdom;

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

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

Enzymeのテストはコンポーネントの一部をレンダリングするシャローレンダリングとコンポーネント全体をレンダリングするフルDOMレンダリングを使い分けて行います。後者を行うときに、setup.jsが必要なので準備します。

enzyme/jsdom.md at master · airbnb/enzyme

テスト対象のReactコンポーネントを準備

簡単なフォームを用意しました。こちらをテストしていきます。

フォームの動作は以下の通りです。

f:id:dmmlabotech:20170220123239g:plain

  • 正常系
    • メールアドレスとパスワードを入力して、ログインボタンを押すと「ログインしました」とアラート表示

f:id:dmmlabotech:20170220123311g:plain

  • 異常系
    • メールアドレス、パスワードのいずれかの入力がないとき、エラーメッセージを表示
      • メールアドレス未入力 - 「メールアドレスが入力されていません」と表示
      • パスワード未入力 - 「パスワードが入力されていません」と表示
      • メールアドレス・パスワード未入力 - 「メールアドレス・パスワードが入力されていません」

ディレクトリ構成

create-react-appで生成されたReactアプリケーションの雛形を利用しています。

.
├── README.md
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   └── logo.svg
├── src
│   ├── App.css
│   ├── App.js
│   ├── EmailInput.js(メールアドレステキストボックス)
│   ├── LoginForm.js(ログインフォーム)
│   ├── PasswordInput.js(パスワードテキストボックス)
│   ├── index.css
│   └── index.js
└── test
    ├── LoginForm.spec.js(テストが記述されているファイル)
    └── setup.js

コード

import React, { Component } from 'react';
import EmailInput from './EmailInput';
import PasswordInput from './PasswordInput';

class LoginForm extends Component {
  constructor(props) {
    super(props);
    this.state = {
      email: '',
      password: '',
      message: '',
    };
    this.handleChange = this.handleChange.bind(this);
    this.handleSubmit = this.handleSubmit.bind(this);
    this.validate = this.validate.bind(this);
    this.setErrorMessage = this.setErrorMessage.bind(this);
  }

  // 入力されたメールアドレス・パスワードをStateにセット
  handleChange(event) {
    const name = event.target.name;
    const value = event.target.value;
    this.setState({
      [name]: value
    });
  }

  handleSubmit(event) {
    // フォームが送信されないようにイベントを止めている
    event.preventDefault();

    if(this.validate()) {
      alert('ログインしました');
      return true;
    }

    return false;
  }

  setErrorMessage(name) {
    const errorMessage = {
      email: 'メールアドレスが入力されていません',
      password: 'パスワードが入力されていません',
      common: 'メールアドレス・パスワードが入力されていません'
    };

    this.setState({
      message: errorMessage[name],
    });
  }

  validate(event) {

    let canSubmit = true;

    // refが付いた項目に関してバリデーションを行う
    for(const name in this.refs) {
      if(this.refs[name].refs[name].value === '') {
        if(!canSubmit) {
          this.setErrorMessage('common');
          canSubmit = false;
        } else {
          this.setErrorMessage(name);
          canSubmit = false;
        }
      }
    }

    return canSubmit;
  }

  render() {
    return (
      <form action="/" method="post" onSubmit={this.handleSubmit}>
        <EmailInput
          ref="email"
          value={this.state.email}
          onChange={this.handleChange}
        />
        <PasswordInput
          ref="password"
          value={this.state.password}
          onChange={this.handleChange}
        />
        <div className="App-info">
          {this.state.message}
        </div>
        <div>
          <input type="submit" value="ログイン" />
        </div>
      </form>
    );
  }
}

export default LoginForm;

フォームのコンポーネント。Stateの変更やSubmitイベントなどフォームをコントロールしています。

import React, { Component } from 'react';

class EmailInput extends Component {
  render() {
    return (
      <div>
        <input
          type="text"
          name="email"
          ref="email"
          placeholder="メールアドレス"
          value={this.props.value}
          onChange={this.props.onChange}
        />
      </div>
    );
  }
}

export default EmailInput;

メールアドレスが入力されるテキストボックスのコンポーネント。入力イベントのハンドリングと親コンポーネントから渡された値をvalueにセットしています。

import React, { Component } from 'react';

class PasswordInput extends Component {
  render() {
    return (
      <div>
        <input
          type="password"
          name="password"
          ref="password"
          placeholder="パスワード"
          value={this.props.value}
          onChange={this.props.onChange}
        />
      </div>
    );
  }
}

export default PasswordInput;

パスワードが入力されるテキストボックス。挙動はメールアドレスのものと同じです。

テストを書く

ここまでやや前置きが長くなってしまいましたが、テストを書いていきましょう。

まずはメールアドレスのテキストボックスが正しく動作するかをテストします。

import React from 'react';
import chai, { expect } from 'chai'
import chaiEnzyme from 'chai-enzyme'
import { mount, shallow } from 'enzyme';
import { spy } from 'sinon';
import LoginForm from '../src/LoginForm';
import EmailInput from '../src/EmailInput';
import PasswordInput from '../src/PasswordInput';

// アサーションにchai-enzymeを使用するように設定
chai.use(chaiEnzyme())

// describeにはどのコンポーネントに対してのテストを書いてるかを指定
describe('EmailInputのテスト', () => {

  // itにはテストの内容を書く
  it('propが渡されたときにvalueにセットされること', () => {
    const wrapper = shallow(<EmailInput />);
    // ここでpropsをセットする
    wrapper.setProps({ 'value': 'foo@example.com' });

    // inputタグのvalueにセットされているかを確認
    expect(wrapper.find('input')).to.have.value('foo@example.com');
  });

  it('メールアドレスが入力されたときにonChangeイベントが発火すること', () => {
    // onChangeメソッドが呼ばれたときの入出力(引数の値や戻り値、呼ばれた回数など)を監視する
    const onChange = spy();
    // テスト対象のコンポーネントのみをレンダリング
    const wrapper = shallow(<EmailInput onChange={onChange} />);

    // 入力イベントを擬似的に再現
    wrapper.find('input').simulate(
      'change', {
        target: {
          value: 'x'
        }
      }
    );

    // onChangeメソッドが1回呼ばれているかを確認
    expect(onChange.calledOnce).to.equal(true);
  });
});

メールアドレステキストボックスのテストの概要は次の通り。

  • 親コンポーネントから渡されるpropsvalueにセットできるか
    • setPropsメソッドで親からpropsを与えている
    • valueがセットされているか確認
  • テキストボックスに文字が入力されたときにonChangeイベントが動作するか
    • simulateメソッドでダミーイベントを発生させている
    • spyを使用して、onChangeメソッドが呼ばれる回数を監視

各テスト(itの内部)では次の処理をしています。

  1. コンポーネントをレンダリング(シャロー or フルDOM)
  2. コンポーネントをテスト内容に応じた振る舞いに変更(例:バリデーションテスト => バリデーションが動作するように)
  3. アサートでコンポーネントの振る舞いが正しいかを確認

1番目のテスト「propが渡されたときにvalueにセットされること」を例に処理を詳しくみていきましょう。

1. コンポーネントをレンダリング

const wrapper = mount(コンポーネント);

コンポーネントのレンダリングには2種類の方法があり、shallowmountメソッドでレンダリング方法を指定します。

  • shallowメソッド - コンポーネントの一部をレンダリング
  • mountメソッド - コンポーネント全体をレンダリング

debugメソッドを使用して、レンダリングされた要素を見てみます。

// デバッグ
console.log(wrapper.debug());

// shallowを使用
<form action="/" method="post" onSubmit={[Function]}>
  <EmailInput value="" onChange={[Function]} />
  <PasswordInput value="" onChange={[Function]} />
  <div className="App-info" />
  <div>
    <input type="submit" value="ログイン" />
  </div>
</form>

shallowを使った場合はLoginFormコンポーネントに含まれているEmailInputとPasswordInputは展開されていないことが確認できます。

// mountを使用
<LoginForm>
  <form action="/" method="post" onSubmit={[Function]}>
    <EmailInput value="" onChange={[Function]}>
      <div>
        <input type="text" name="email" placeholder="メールアドレス" value="" onChange={[Function]} readOnly={true} />
      </div>
    </EmailInput>
    <PasswordInput value="" onChange={[Function]}>
      <div>
        <input type="password" name="password" placeholder="パスワード" value="" onChange={[Function]} readOnly={true} />
      </div>
    </PasswordInput>
    <div className="App-info" />
    <div>
      <input type="submit" value="ログイン" />
    </div>
  </form>
</LoginForm>

一方、mountを使った場合はすべてレンダリングされていることがわかると思います。これらの違いを理解し、テストによって使い分ける必要があります。

2. コンポーネントをテスト内容に応じた振る舞いに変更

コンポーネントの振る舞いの変更ではEnzymeが提供しているメソッドを使用します。

wrapper.setProps({ 'value': 'foo@example.com' });

今回はpropsをセットするsetPropsを用いて、テスト内容に合うようにコンポーネントを変更しています。

詳細はAPIリファレンスをご覧ください。

API Reference | Enzyme

3. アサートでコンポーネントの振る舞いが正しいかを確認

chai-enzymeというChaiのアサーションをEnzyme向けに使いやすくしたパッケージを使用しています。

expect(wrapper.find('input')).to.have.value('foo@example.com');

input要素のvalue属性にpropsで渡された値がセットされていることが確認できたらテストは成功です。


テストの一連の流れが掴めたところで、次にログインフォームのテストを書いてみましょう。ここではユーザーが実際にフォームに値を入力して、Submitするまでをテストします。

describe('LoginFormのテスト', () => {
  let onSubmit;

  beforeEach(() => {
    onSubmit = spy(LoginForm.prototype, 'handleSubmit');
  });

  afterEach(() => {
    onSubmit.restore();
  });

  it('EmailInputとPasswordInputが含まれること', () => {
    const wrapper = shallow(<LoginForm />);

    expect(wrapper).to.have.descendants(EmailInput);
    expect(wrapper).to.have.descendants(PasswordInput);
  });

  it('メールアドレスとパスワードが入力されたとき、handleSubmitが正しい戻り値を返すこと', () => {
    const wrapper = mount(<LoginForm />);
    // メールアドレスとパスワードをセット
    wrapper.setState({ email: 'bar@example.com', password: 'hoge' });
    // フォームをsubmit
    wrapper.simulate('submit');

    // handleSubmitの戻り値がtrueであることを確認
    expect(onSubmit.returnValues[0]).to.equal(true);
  });

  it('メールアドレスが未入力のとき、正しいエラーメッセージが表示されること', () => {
    const wrapper = mount(<LoginForm />);
    // パスワードのみセット
    wrapper.setState({ email: '', password: 'hoge' });
    // フォームをsubmit
    wrapper.simulate('submit');

    // エラーメッセージが正しいかを確認
    expect(wrapper).to.have.text('メールアドレスが入力されていません');
  });

  it('パスワードが未入力のとき、正しいエラーメッセージが表示されること', () => {
    const wrapper = mount(<LoginForm />);
    // メールアドレスのみセット
    wrapper.setState({ email: 'foo@example.com', password: '' });
    // フォームをsubmit
    wrapper.simulate('submit');

    // エラーメッセージが正しいかを確認
    expect(wrapper).to.have.text('パスワードが入力されていません');
  })

  it('メールアドレスとパスワードが未入力のとき、正しいエラーメッセージが表示されること', () => {
    const wrapper = mount(<LoginForm />);
    // フォームをsubmit
    wrapper.simulate('submit');

    // エラーメッセージが正しいかを確認
    expect(wrapper).to.have.text('メールアドレス・パスワードが入力されていません');
  });
});

ログインフォームのテストの概要は次の通り。

  • EmailInputとPasswordInputが含まれること
    • descendantsでLoginFormコンポーネントの子コンポーネントとして存在しているか確認
  • メールアドレスとパスワードが入力されたとき、handleSubmitが正しい戻り値を返すこと
    • フォームが送信可能かどうかでhandleSubmitメソッドの戻り値(Boolean)を確認
  • バリデーションチェック系
    • setStateメソッドを使用して、実際にメールアドレス・パスワードが入力された状態を作っている
    • simulateメソッドでダミーイベントを発生させてフォームをSubmit
    • エラーメッセージが含まれ、内容が正しいかを確認

ここまできたら、最後にテストを動かしてみましょう。

テストを実行する

package.jsonscriptsにテストを実行するコマンドを追加しましょう。

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

testディレクトリ配下にある末尾にspec.jsと名前がついているファイルがテストとして実行されます。

--requireオプションでsetup.jsの読み込み、--compilersbabel-registerを指定することでテスト前にコードをトランスパイルします。

$ npm run test

コマンドラインで上記コマンドを実行すると、テストがスタートします。

f:id:dmmlabotech:20170228165632p:plain

このようにテスト結果が表示されています。全部パスしていますね。

さいごに

MochaとChaiを使ってテストが書けるので、これまでの業務で書いていたものの延長線上でテストが書けるのが便利だと感じました。早速、チームに広めて、Reactコードの品質向上に努めていきたいと思います。

本記事で使ったサンプルコードは以下に置いておきます。

github.com