30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

JestでReactのテストをする(2) クリックイベントのテスト

前回の記事はこちら。

前回で、JestとReactの環境構築が終わった。
以降は、個人的に特に習得したいと思っている、イベントとDOMのテストに取り組む。
今回は、イベントのテストについて。

クリックイベントの挙動をテストする

Reactでの開発に限らず、ウェブアプリケーションにおいては、ユーザーによるクリックをトリガーにしてイベントを起こす、というのが一般的だと思われる。
クリックによって何かが送信されたり、表示が切り替わったり。
そのため、クリックイベントの挙動を確認できないと、テストとしては不十分なものになってしまう。

Jestでは、enzymeというツールと組み合わせることで、Reactのコンポーネントにセットしたイベントを簡単にテストできる。

airbnb/enzyme: JavaScript Testing utilities for React

まずは、テストの対象となるReactのコードを書く。
これを、click.jsとする。

// click.js
import React from 'react';
import ReactDOM from 'react-dom';

class App extends React.Component{
  constructor(props){
    super(props);
  };
  render(){
    const {event} = this.props;
    return(
      <div>
        <span onClick={event}>Click!</span>
      </div>
    );
  };
};

function sampleFunc(){
  alert('clickされました。');
};

ReactDOM.render(<App event={sampleFunc} />, document.getElementById('mount'));

このコードをビルドしてhtmlファイルで読み込むと、画面にはClick!と表示され、そこをクリックするとダイアログボックスが表示される。

Appコンポーネントを使う際にevent属性に渡した関数(上記ではsampleFunc())が、span要素のクリックイベントとして設定される。という仕組み。

これをテストしていく。

元となるコードを修正する

早速テストを書いていく。と思っていたのだが、エラーが発生し、修正を余儀なくされた。

元となるコードにReactDOM.render()があるとエラーになることが分かった。
このメソッドが悪いというより、ブラウザ環境以外では引数のdocument.getElementById('mount')が実行できなくてエラーになる、ということだと思う。

ということで、元ファイルを書き換えた。

// click.js
import React from 'react';

class App extends React.Component{
  constructor(props){
    super(props);
  };
  render(){
    const {event} = this.props;
    return(
      <div>
        <span onClick={event}>Click!</span>
      </div>
    );
  };
};
function sampleFunc(){
  alert('clickされました。');
};

export { App, sampleFunc };
// click-render.js
import React from 'react';
import ReactDOM from 'react-dom';
import {App, sampleFunc} from './click.js';

ReactDOM.render(<App event={sampleFunc} />, document.getElementById('mount'));

このように2つのファイルに分割し、click-render.jsをビルドしたものをhtmlファイルで読み込むようにした。
そうすることで、引き続きブラウザ上で問題なく動作し、かつ、click.jsの内容をテストできるようになった。

必要なnpmパッケージをインストールする

先述のようにenzymeというツールを使うので、インストールする。
同時に、react-addons-test-utilsもインストールしておく。

npm install -S enzyme react-addons-test-utils

react-addons-test-utilsはテストで直接使うことはないが、これをインストールしておかないと、テスト実行時に以下のエラーが出る。
enzymeを使わないテストなら問題ないようだが、使う際には必要となる。

react-addons-test-utils is an implicit dependency in order to support react@0.13-14. Please add the appropriate version to your devDependencies.

テストを書く

やっと準備が整ったので、テストを書く。

// click.test.js
import React from 'react';
import {shallow} from 'enzyme';
import {App} from '../click.js';

describe('clickイベントのテスト', ()=>{
  test('Appコンポーネントにイベントを渡す', ()=>{
    const testMock = jest.fn();
    const subject = shallow(<App event={testMock} />);
    subject.find('span').simulate('click');
    expect(testMock).toHaveBeenCalled();
  });
});

このテストを実行すると、無事にパスする。

jest.fn()

これは、関数のモックを作成している。
モック、というのはテストでよく使われる概念のようだが、よく分かっていない。
テスト用のダミーのデータ、というような意味だと思う。

今回のテストは、関数そのものの挙動ではなく、クリックで関数が呼ばれるかどうかを調べるのが、目的である。
そのため、関数そのものは何でもいい。
そういったときに、モックを使う。

今回の例ではtestMockという関数のモックを、Appコンポーネントevent属性に渡している。
つまり、span要素をクリックした際にtestMockが実行されれば、テストは成功と言える。

toHaveBeenCalled()

関数が呼ばれたかどうかは、toHaveBeenCalled()を使う。

expect(調べたい関数).toHaveBeenCalled();

このようにすると、調べたい関数が呼び出された場合のみ、パスする。呼び出されなかった場合、テストはパスしない。

shallow()

関数のモックを作り、それが呼び出されたかどうかを調べる仕組みは出来た。
後は、span要素に対してクリックイベントを発生させるだけである。

そのために、enzymeからインポートしたshallowという関数を使う。

この関数を実行すると、引数として渡したReactのコンポーネントレンダリングしたことになるようだ。

そしてその戻り値として、オブジェクトが返される。
このオブジェクトは様々なメソッドを使えるようだが、ここで重要なのは、find()simulate()の2つである。

shallowの戻り値.find(対象となる要素).simulate(イベント名);

このように書くことで、対象となる要素に対して、イベントを起こすことが出来る。

そのため、下記のコードは、Appコンポーネントspan要素に対してクリックイベントを起こす、という意味になる。

const subject = shallow(<App event={testMock} />);
subject.find('span').simulate('click');

要素の特定は、#.を使ってidclassNameに対して行うことも可能。

find()の注意点

find()を使う際は、対象の要素が一つに絞れないといけない。対象となる要素が複数あると、エラーになる。

以下は、span要素が複数あるため、エラーになる。

// コンポーネント
<div>
  <span onClick={event}>aaaa</span>
  <span onClick={event}>aaaa</span>
  <span onClick={event}>aaaa</span>
</div>
// テスト
subject.find('span').simulate('click');
expect(testMock).toHaveBeenCalled();
// エラーメッセージ
This method is only meant to be run on single node. 3 found instead.

以下は、要素を一つに絞れるため、問題ない。

<div>
  <span onClick={event}>aaaa</span>
  <span className="target" onClick={event}>aaaa</span>
  <span onClick={event}>aaaa</span>
</div>
subject.find('.target').simulate('click');
expect(testMock).toHaveBeenCalled();

shallow()とmount()

ここまで書いてきたshallow()は、mount()と置き換えても問題なく動作する。mount()でも、find()sumilate()は使える。

両者の違いは、子コンポーネントを展開するかどうか。他にも違いはあるだろうが、今の自分にとって重要なのはこの点である。
mount()は子コンポーネントを展開するが、shallow()は展開しない。

その違いを示したのが、下記のテスト。

// click2.test.js
import React from 'react'
import { shallow, mount } from 'enzyme'

class Sample extends React.Component {
  constructor(props) {
    super(props)
  };
  render() {
    const {event} = this.props;
    return (
      <div>
        <SampleChild event={event} />
      </div>
    )
  }
}
class SampleChild extends React.Component{
  constructor(props) {
    super(props)
  };
  render(){
    const {event} = this.props;
    return(
      <span onClick={event} >aaa</span>
    );
  };
};

describe('shallowとmountの違い', () => {
  test('shallowの場合', () => {
    const testMock = jest.fn();
    const subject = shallow(<Sample event={testMock} />);
    subject.find('span').simulate('click');
    expect(testMock).toHaveBeenCalled();
  });
  test('mountの場合', () => {
    const testMock = jest.fn();
    const subject = mount(<Sample event={testMock} />);
    subject.find('span').simulate('click');
    expect(testMock).toHaveBeenCalled();
  });
})
  shallowとmountの違い
    ✕ shallowの場合 (12ms)
    ✓ mountの場合 (19ms)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 passed, 2 total
Snapshots:   0 total
Time:        1.782s, estimated 2s
Ran all test suites matching "__tests__/click2.test.js".

SampleコンポーネントSampleChildコンポーネントを包含しているという構造。

このような状態でSampleに対してshallow()を実行すると、SampleChildは無視される。
そのため、SampleChildが持っているspan要素は無視され、テストは通らない。

一方、mount()では、SmapleChildも展開されるため、テストはパスする。


これでイベントのテストは出来るようになった。
次回は、DOMのテストに取り組む。

参考資料

Facebook製のJavaScriptテストツール「Jest」の逆引き使用例 - Qiita