maesblog

Jest + enzymeで行うReactのUT(ユニットテスト )について

会社の勉強会でReactのUTについて軽く話してきました。ReactのUT(ユニットテスト )と言えば、Jestとenzymeが真っ先に思いつくかと思います。これらのツールを使ったReactのUTの書き方などを話してきた内容を補足しつつまとめておきます。

はじめに – ReactのUTのポイント

Reactで扱うコンポーネントのほとんどは、自身で状態を持たないfunctional componentです。コンポーネントが関数であり、引数で受け取ったデータに対しては毎回同じビューが返されるという性質を持っています。従って、UTを行うに祭して、まずはコンポーネントが正しいデータをレンダリングしているかをテストすることが大事です。

コンポーネントは、上記のような特徴を持っていることから、基本的には入力されるデータと出力されるデータだけを気にすれば良く、テストがそれほど複雑にならないのが特徴です。それからコンポーネントは、ただ単にデータを出力するだけのものではなく、インタラクティブな操作を含むものもあるので、必要に応じてインタラクションのテストも書くようにします。

ビジネスロジックについては主にReduxの責務となるので、ReactのUTとはまた作法が異なります。今回の記事でもビジネスロジックのUTについては省略します。

ReactのUTに必要なツール

それでは、まずは簡単にReactのUTで必要なツール類を紹介していきます。

以下の2つのツールが定番です。この2つについては、この後詳しく特徴を紹介します。

  • Jest(テスティングフレームワーク) ※もちろんJasmineなど他のフレームワークを使用しても可
  • enzyme(テストユーティリティ) 

その他にReactにenzymeを連携させるためのenzyme-adapter-react-16や、Jestのスナップショットを行うためのreact-test-renderer、Jestでenzyme-matchersを使用するためのjest-enzymeなどが状況により必要となります。

なお、create-react-appを使う場合は、デフォルトでJestが組み込まれており、特にBabelなどの設定を行う必要はありません。enzymeについては、別途インストールが必要です。

Jestについて

Jestはテスティングフレームワークです。以下のような特徴があります。

  • JestはJasmine 2をベースに作られていて、JasmineのMatcherがそのまま使えます。Facebook製でReactのイメージが強いですが、他のフレームワークなどでも普通に使えます。
  • Node.js上のjsdomで実行されるので、Domを扱うテストもブラウザが必要なく、コマンドベースで行えます。従って、Karmaのようなテストランナーも別でインストールする必要はありません。
  • Mockを自動で作成してくれる機能や、豊富なMockライブラリなどが用意されているなどMockが扱いやすくなっています。また、もともと「Mock By Default」をコンセプトにしていたため、読み込んだモジュールは全てMockに置き換えられる仕様でした(現在はデフォルトではオフになっています)。
  • テストダブル、スナップショットテストにも対応しており、Jestだけでさまざまなテストが行えます。
  • 嬉しいことに公式ドキュメントは日本語に対応しています。

その他詳細はJestの公式サイトをご覧ください。

enzymeについて

enzymeはReactのUTを助けてくれるテストユーティリティツールです。以下のような特徴があります。

  • ReactのUTの記述を簡単にしてくれるテストユーティリティです。airbnb製。Reactの公式ドキュメントでも使用が推奨されていて、もはやReactのUTに定番と言えるツールになっています。
  • テスト内でコンポーネントをレンダリングする際に、紐づいた子コンポーネントを無視してくれるshallow(浅い)レンダリングの機能があり、コンポーネント単体でテストが行えるようにしてくれます。もちろん子コンポーネントも含めてフルレンダリングしてテストを行うことも可能です。
  • jsxのDOMへのアクセスを楽にしてくれるjQueryライクなセレクタが便利です。

その他詳細はenzymeの公式サイトをご覧ください。

サンプル

これからReactのUTについて説明していきます。その際に使用するサンプルが以下となります。入力フォームにテキストを入力し、送信ボタンを押すと、「Hello」の後ろに入力したテキストが表示されるというシンプルなReactのアプリケーションとなります。

サンプルのソースコード

ソースはGitHubにもアップしておきました。こちらも合わせてご確認ください。

Jest + enzymeでのUTの準備

Reactの開発環境として、create-react-appを使用することを前提として説明していきます。create-react-appには、Jestがデフォルトで組み込まれているため、Jestに関しては特にインストールや設定を行う必要はありません。enzymeについては、create-react-appに組み込まれていないので、インストールや設定が必要です。

enzymeのインストール

以下のコマンドで、enzymeとenzyme-adapter-react-16をインストールします。enzyme-adapter-react-16は、Reactとenzymeを連携するためのアダプタです。Reactのバージョンに合わせたアダプタをインストールします。今回はReact v16系を使うので、enzyme-adapter-react-16をインストールしています。

$ npm i --save-dev enzyme enzyme-adapter-react-16

setupTests.jsファイルをsrcディレクトリ内に作成し、以下を記述します。

import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';

Enzyme.configure({ adapter: new Adapter() });
setupTests.js

UT用のファイルを準備

Jestでは、__tests__フォルダ内にあるファイル、もしくはファイル名の拡張子が.spec.jsまたは、.test.jsとなっているファイルに対してテストを実行するようになっています。

テスト用のファイルを作成したら、以下のようにimportrequireを使って、enzymeを読み込むようにします。

import { shallow, mount, render } from 'enzyme';

// enzymeから読み込んだshallowを使う例
const wrapper = shallow(<Foo />);
テスト用のファイル

テストの実行

以下のコマンドでテストが実行されます。Jestはテストを並列実行するため、とても高速です。

# npmで実行する場合
$ npm test

# yarnで実行する場合
& yarn test

コマンドにファイル名をつけて実行すると、そのファイルに限定してテストを実行することも可能です。

$ npm test App.test.js

なお、ユニットテストのカバレッジを確認するには、以下のコマンドを実行します。

$ npm test -- --coverage

基本的なUTの書き方

Jestとenzymeを使った基本的なReactのUTの書き方を紹介します。上で紹介したサンプルアプリに対して書いたUTの中から紹介していきます。サンプルアプリのソースと照らし合わせて見てもらえればと思います。

子コンポーネントの存在を確認する

こちらは、コンポーネントに紐付く子コンポーネントがちゃんと存在するか確認するためのテストコードです。

test('子コンポーネントが存在すること', () => {
  /** Appコンポーネントをshallowレンダリング */
  const wrapper = shallow(<App />);
  /** 各コンポーネントの数を取得し、1であればOK */
  expect(wrapper.find(Title).length).toBe(1);
  expect(wrapper.find(Input).length).toBe(1);
  expect(wrapper.find(Button).length).toBe(1);
});

子コンポーネントは、shallowレンダリングしたwrapper変数を使ってwrapper.find(子コンポーネント名)で取得可能です。ここでは、lengthを使った個数での存在チェックを行なっています。

setStateで更新した値が反映されているか確認する

こちらは、this.setStatethis.state.textの値を'XXX'に更新した時に、class名も'XXX'に変更されているか確認するためのテストコードです(今回のサンプルアプリでは、敢えてthis.state.textの値がAppコンポーネントのdiv要素のclass名になるようにしています)。

test('this.state.textを更新した時にclass名に反映されること', () => {
  /** Appコンポーネントをshallowレンダリング */
  const wrapper = shallow(<App />);
  /** setStateしてthis.state.textの値を'XXX'に更新 */
  wrapper.setState({
    text: 'XXX',
  });
  /** 'XXX'というclass名を持った要素があればOK */
  expect(wrapper.find('.XXX').length).toBe(1);
});

shallowレンダリングしたwrapper変数のsetState()メソッドを使うと、this.stateの値を更新できます。任意の値でthis.stateの値を更新し、lengthを使った個数での要素の存在チェックを行なっています。

Classコンポーネントに定義したメソッドの呼び出しを確認する

こちらは、Appコンポーネントに定義したhandleClickメソッドの呼び出しを確認するテストコードです。handleClickメソッドが呼び出されると、this.setStateを呼び出し、this.state.textの値をthis.state.inputValueの値に更新し、this.state.inputValueの値を''に更新するようになっています。

test('handleClickを呼び出すと、setStateが呼び出されること', () => {
  /** Appコンポーネントをshallowレンダリング */
  const wrapper = shallow(<App />);
  /** setStateをスパイ化 */
  const setStateSpy = jest.spyOn(App.prototype, 'setState');
  /** setStateしてthis.state.inputValueの値を'XXX'に更新 */
  wrapper.setState({
    inputValue: 'XXX',
  });
  /** handleClick()を呼び出す */
  wrapper.instance().handleClick();
  /** 適切な引数でspy化したsetStateが呼び出されていればOK */
  expect(setStateSpy).toHaveBeenCalledWith({
    text: 'XXX',
    inputValue: '',
  });
});

setStateは、jest.spyOnを使ってスパイ化しています。また、handleClickメソッドの呼び出しは、shallowレンダリングしたwrapper変数のinstance()メソッドを使って行なっています。スパイ化したsetStateSpyの呼び出しは、toHaveBeenCalledWithで引数と共にチェックを行なっています。

子コンポーネントで受け取ったpropsの値がレンダリングされているか確認する

こちらは、親コンポーネントからpropsを通して受け取った値がちゃんとレンダリングされているかを確認するためのテストコードです。

test('受け取ったpropsの値を表示すること', () => {
  /**
   * 'React'という値をtextに渡して、
   * Titleコンポーネントをshallowレンダリング
   */
  const wrapper = shallow(<Title text={'React'} />);
  /** レンダリングされたテキストが'Hello React'であればOK */ 
  expect(wrapper.text()).toBe('Hello React');
  /** props.textの値を'World'に変更 */
  wrapper.setProps({ text: 'World' });
  /** レンダリングされたテキストが'Hello World'であればOK */
  expect(wrapper.text()).toBe('Hello World');
});

shallowレンダリングしたwrapper変数のtext()メソッドを使って、Helloの後ろにpropsを通して受け取った値が表示されているかをチェックしています。また、wrapper変数のsetProps()メソッドを使ってprops.textの値を変更し、変更した値がちゃんと反映されているかチェックしています。

イベント発火時のコールバック関数の呼び出しを確認する

こちらは、input要素にテキストが入力された時に発生するchangeイベントのコールバック関数であるhandleChangeメソッドの呼び出しを確認するためのテストコードです。handleChangeメソッドが呼び出されると、親コンポーネントから受け取ったメソッドが呼ばれるかについても確認します。

test('changeイベント発火時にコールバック関数が呼び出されること', () => {
  /** mock関数としてhandleChangeSpyを作成 */
  const handleChangeSpy = jest.fn();
  /**
   * mock関数'handleChangeSpy'をhandleChangeに渡して、
   * Inputコンポーネントをshallowレンダリング
   */
  const wrapper = shallow(<Input handleChange={handleChangeSpy} />);

  /** ダミーなeventオブジェクトを作成 */
  const event = { target: { value: 'XXX' } };
  /** input要素に対してchangeイベントを発火させる */
  wrapper.find('input').simulate('change', event);
  /** mock関数'handleChangeSpy'が'XXX'という引数で呼び出されればOK */
  expect(handleChangeSpy).toHaveBeenCalledWith('XXX');
});

親コンポーネントから受け取るメソッドは、jest.fn()を使ってmock関数として作成します。changeイベントを発火させるには、shallowレンダリングしたwrapper変数のsimulate()メソッドを使います。その際にダミーのeventオブジェクトを作成して渡しています。mock関数として作成したhandleChangeSpyの呼び出しをtoHaveBeenCalledWithで引数と共にチェックを行なっています。

スナップショットテストを行う

最後にJestの便利な機能であるスナップショットテストについて紹介します。スナップショットテストは、レンダリングされたコンポーネントの状態をスナップショットとして保存し、テストを実行する度に前後のスナップショットを比較して、違いがないかをチェックするためのテストです。

スナップショットテストのテストコード

例えば、今回のサンプルのAppコンポーネントに対して、以下のようなスナップショットのテストを書いたとします。

test('<App />のスナップショット', () => {
  const tree = renderer
    .create(<App />)
    .toJSON();
  expect(tree).toMatchSnapshot();
});

スナップショットテストを行うには、別途react-test-rendererをインストールする必要があります。 上記のコードでは、react-test-rendererのrenderer.create(<App />).toJSON();によってスナップショットが作られます。

スナップショットファイル

テストを実行すると、以下のようなスナップショットの内容が記述されたスナップショットファイルが__snapshots__フォルダ内に作られます。

exports[`<App /> <App />のスナップショット 1`] = `
<div
  className=""
>
  <h1>
    Hello 
    
  </h1>
  <input
    onChange={[Function]}
    value=""
  />
  <button
    onClick={[Function]}
  >
    送信
  </button>
</div>
`;

スナップショットファイルを使ったUT

以後、テストを実行する度に、保存されたスナップショットとテスト実行時のスナップショットが比較されます。例えば、サンプルコードのTitleコンポーネント内の「Hello」の部分を以下のように「Hey」に変更したとします。

const Title = (props) => {
  return(
    <h1>Hey { props.text }</h1>
  );
}

するとテスト実行時に以下のようなエラーが発生します。つまり保存されているスナップショットとの違いがあるということを教えてくれます。

  ● <Title /> › <Title />のスナップショット

    expect(value).toMatchSnapshot()

    Received value does not match stored snapshot 1.

    - Snapshot
    + Received

     <h1>
    -  Hello
    +  Hey
       React
     </h1>

この違いが意図しないものであれば、コードの方を修正することになります。意図したものであれば、スナップショットを更新します。更新すると__snapshots__フォルダ内に作られているスナップショットが新しい内容に置き換えられます。次にテストを実行した際には、新しい内容となったスナップショットで比較が行われるようになります。

スナップショットの更新

スナップショットを更新するには、jest -uコマンドを実行するか、インタラクティブ・スナップショットモードを使って対話的に更新します。

インタラクティブ・スナップショットモードは、スナップショットテストが失敗した時に、ターミナルに以下が表示され、wを選択すると、起動します。

Watch Usage: Press w to show more.

ここでuを選択すると、スナップショットが更新されます。

Watch Usage
 › Press a to run all tests.
 › Press u to update failing snapshots.
 › Press p to filter by a filename regex pattern.
 › Press t to filter by a test name regex pattern.
 › Press q to quit watch mode.
 › Press Enter to trigger a test run.
インタラクティブ・スナップショットモード

スナップショットテストを行うための準備

上でも述べましたが、スナップショットテストを行うためには、react-test-rendererが必要です。react-test-rendererは、DOMやネイティブのモバイル環境に依存することなく、Reactコンポーネントを純粋なJavaScriptオブジェクトにレンダリングするために使われる実験的なReactレンダラーです。React DOMやReact Nativeのコンポーネントによってレンダリングされた「DOMツリー」のスナップショットをブラウザまたはjsdomを使用せずに取得できます。

react-test-rendererは以下のコマンドでインストールします。

$ npm i --save-dev react-test-renderer

スナップショットテストを行うテストファイルに読み込みます。

import renderer from 'react-test-renderer';

なお、enzymeオンリーでスナップショットテストを行いたい場合は、react-test-rendererの代わりにenzyme-to-jsonというパッケージを使ってもよいです。enzyme-to-jsonを使ったスナップショットテストの方法については以下の記事などを参考にしてください。

まとめ

Jestとenzymeを使ったReactのUTについて紹介してきましたが、create-react-appには最初からJestが組み込まれているので、余計な設定をすることなくUTを行うことができるようになっていますし、さらにenzymeを使えばテストコードがシンプルに書けるようになります。私は仕事ではAngularでの開発を行なっていますが、AngularのUTはテストコードを書くまでの準備の段階が非常に面倒で、依存関係の解決やモックの作成なんかをやっているとそれだけで100行を超えたりします。それに比べると、Reactのテストはだいぶシンプルに書けるようになっているのがわかるかと思います。

特に、Jestのスナップショットテストは余計なテストコードを書く必要がなくなるので、積極的に使っていきたいですね。Reactに限らずViewは頻繁に変更されるので、要素の存在確認やテキストの表示のテストなどは、どんどんスナップショットテストに置き換えていきたいところです。

この記事を書くに当たって、久々にJestでUTを書いてみましたが、Jestがだいぶ進化していたのに驚きました。非同期処理のテストが描きやすくなっていたり、モックが扱いやすくなっていたり、かなりJestは使えるんじゃないかと思っています。今回の記事では、基本的な使い方しか紹介しませんでしたが、もっと踏み込んだ内容の記事も書いていければと思っています。

最後に上でも紹介しましたが、今回の記事で扱ったサンプルを改めて紹介しておきます。参考にしてください。

コメント

  • 必須

コメント