EnzymeでReactコンポーネントのテストを書こう
こんにちは、プラットフォーム開発部の新卒エンジニアの松下です。
普段は会員基盤フロントエンドチームでログインやアカウント登録などの会員基盤システムの開発をしています。
早いものであと1ヶ月ちょっとで1年目が終了。春には次の新卒が入ってくるとのことで、うかうかしていられないなと思う今日このごろです。
さて本日はEnzymeを使ってReactのテストを書く方法を紹介したいと思います。 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を用いているのでes2015
とreact
をプリセットに指定しています。
/* 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コンポーネントを準備
簡単なフォームを用意しました。こちらをテストしていきます。
フォームの動作は以下の通りです。
- 正常系
- メールアドレスとパスワードを入力して、ログインボタンを押すと「ログインしました」とアラート表示
- 異常系
- メールアドレス、パスワードのいずれかの入力がないとき、エラーメッセージを表示
- メールアドレス未入力 - 「メールアドレスが入力されていません」と表示
- パスワード未入力 - 「パスワードが入力されていません」と表示
- メールアドレス・パスワード未入力 - 「メールアドレス・パスワードが入力されていません」
- メールアドレス、パスワードのいずれかの入力がないとき、エラーメッセージを表示
ディレクトリ構成
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); }); });
メールアドレステキストボックスのテストの概要は次の通り。
- 親コンポーネントから渡される
props
をvalue
にセットできるかsetProps
メソッドで親からprops
を与えている- valueがセットされているか確認
- テキストボックスに文字が入力されたときに
onChange
イベントが動作するかsimulate
メソッドでダミーイベントを発生させているspy
を使用して、onChange
メソッドが呼ばれる回数を監視
各テスト(itの内部)では次の処理をしています。
- コンポーネントをレンダリング(シャロー or フルDOM)
- コンポーネントをテスト内容に応じた振る舞いに変更(例:バリデーションテスト => バリデーションが動作するように)
- アサートでコンポーネントの振る舞いが正しいかを確認
1番目のテスト「propが渡されたときにvalueにセットされること」を例に処理を詳しくみていきましょう。
1. コンポーネントをレンダリング
const wrapper = mount(コンポーネント);
コンポーネントのレンダリングには2種類の方法があり、shallow
とmount
メソッドでレンダリング方法を指定します。
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リファレンスをご覧ください。
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.json
のscripts
にテストを実行するコマンドを追加しましょう。
"scripts": { // 追加 "test": "mocha --require test/setup.js --compilers js:babel-register test/**/*.spec.js" }
testディレクトリ配下にある末尾にspec.js
と名前がついているファイルがテストとして実行されます。
--require
オプションでsetup.jsの読み込み、--compilers
でbabel-register
を指定することでテスト前にコードをトランスパイルします。
$ npm run test
コマンドラインで上記コマンドを実行すると、テストがスタートします。
このようにテスト結果が表示されています。全部パスしていますね。
さいごに
MochaとChaiを使ってテストが書けるので、これまでの業務で書いていたものの延長線上でテストが書けるのが便利だと感じました。早速、チームに広めて、Reactコードの品質向上に努めていきたいと思います。
本記事で使ったサンプルコードは以下に置いておきます。