会社の勉強会でReactのユニットテスト(UT / 単体テスト)について軽く話してきました。Reactのユニットテストを語る際に、テスティングフレームワークと言えば「Jest」、テストユーティリティツールと言えば「enzyme」が真っ先に思いつくのではないでしょうか。話してきた内容としては、これらのツールの使い方や、Reactでのテストコードの書き方などです。当ブログで、話してきた内容を補足しつつまとめておきます。
目次
はじめに – Reactでのユニットテストのポイント
Reactで扱うコンポーネントのほとんどは、自身で状態を持たないfunctional componentです。コンポーネントが関数であり、引数で受け取ったデータに対しては毎回同じビューが返されるという性質を持っています。従って、ユニットテストを行う際に、まずはコンポーネントが正しいデータをレンダリングしているかをテストすることが大事です。
コンポーネントは、上記のような特徴を持っていることから、基本的には入力されるデータと出力されるデータだけを気にすれば良く、テストがそれほど複雑にならないのが特徴です。それからコンポーネントは、ただ単にデータを出力するだけのものではなく、インタラクティブな操作を含むものもあるので、必要に応じてインタラクションのテストも書くようにします。
ビジネスロジックについては主にReduxの責務となるので、Reactのユニットテストとはまた作法が異なります。今回の記事では、ビジネスロジック側のユニットテストについては省略します。
Reactでのユニットテストに必要なツール
それでは、まずは簡単にReactのユニットテストで必要なツール類を紹介していきます。
以下の2つのツールが定番です。この2つについては、この後詳しく特徴を紹介します。
その他に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でのユニットテストを助けてくれるテストユーティリティツールです。以下のような特徴があります。
- Reactのテストコードの記述を簡単にしてくれるテストユーティリティです。airbnb製。Reactの公式ドキュメントでも使用が推奨されていて、もはやReactのユニットテストでの定番と言えるツールになっています。
- テスト内でコンポーネントをレンダリングする際に、紐づいた子コンポーネントを無視してくれるshallow(浅い)レンダリングの機能があり、コンポーネント単体でテストが行えるようにしてくれます。もちろん子コンポーネントも含めてフルレンダリングしてテストを行うことも可能です。
- jsxのDOMへのアクセスを楽にしてくれるjQueryライクなセレクタが便利です。
その他詳細はenzymeの公式サイトをご覧ください。
サンプル
これからReactのユニットテストについて説明していきます。その際に使用するサンプルが以下となります。入力フォームにテキストを入力し、送信ボタンを押すと、「Hello」の後ろに入力したテキストが表示されるというシンプルなReactのアプリケーションとなります。
ソースはGitHubにもアップしておきました。こちらも合わせてご確認ください。
Jest + enzymeの準備
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() });
ユニットテスト用のファイルを準備
Jestでは、__tests__
フォルダ内にあるファイル、もしくはファイル名の拡張子が.spec.js
または、.test.js
となっているファイルに対してテストを実行するようになっています。
テスト用のファイルを作成したら、以下のようにimport
やrequire
を使って、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
基本的なテストコードの書き方
Jestとenzymeを使った基本的なReactのテストコードの書き方を紹介します。上で紹介したサンプルアプリに対して書いたテストコードの中から一部抜粋して紹介していきます。サンプルアプリのソースと照らし合わせて見てもらえればと思います。
1. 子コンポーネントの存在を確認する
こちらは、コンポーネントに紐付く子コンポーネントがちゃんと存在するか確認するためのテストコードです。
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
を使った個数での存在チェックを行なっています。
2. setStateで更新した値が反映されているか確認する
こちらは、this.setState
でthis.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
を使った個数での要素の存在チェックを行なっています。
3. 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
で引数と共にチェックを行なっています。
4. 子コンポーネントで受け取った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
の値を変更し、変更した値がちゃんと反映されているかチェックしています。
5. イベント発火時のコールバック関数の呼び出しを確認する
こちらは、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>
`;
スナップショットファイルを使ったユニットテスト
以後、テストを実行する度に、保存されたスナップショットとテスト実行時のスナップショットが比較されます。例えば、サンプルコードの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のユニットテストについて紹介してきましたが、create-react-appには最初からJestが組み込まれているので、余計な設定をすることなくユニットテストを行うことができるようになっていますし、さらにenzymeを使えばテストコードがシンプルに書けるようになります。私は仕事ではAngularでの開発を行なっていますが、Angularではテストコードを書くまでの準備の段階が非常に面倒で、依存関係の解決やモックの作成なんかをやっているとそれだけで100行を超えたりします。それに比べると、Reactのテストはだいぶシンプルに書けるようになっています。
特に、Jestのスナップショットテストは余計なテストコードを書く必要がなくなるので、積極的に使っていきたいですね。Reactに限らずViewは頻繁に変更されるので、要素の存在確認やテキストの表示のテストなどは、どんどんスナップショットテストに置き換えていきたいところです。
この記事を書くに当たって、久々にJestを使ってみましたが、Jestがだいぶ進化していたのに驚きました。非同期処理のテストが描きやすくなっていたり、モックが扱いやすくなっていたり、かなりJestは使えるんじゃないかと思っています。今回の記事では、基本的な使い方しか紹介しませんでしたが、もっと踏み込んだ内容の記事も書いていければと思っています。
最後に上でも紹介しましたが、今回の記事で扱ったサンプルを改めて紹介しておきます。参考にしてください。
コメント