React コンポーネントのユニットテストを書くときは、React Testing Library
やEnzyme
などのサードパーティライブラリを使うことが多いはず。
だがそれらを使わなくても、公式が提供している機能だけでも十分にユニットテストを書くことが出来る。
この記事では、ユニットテストのために公式が提供している機能を紹介していく。
サードパーティライブラリを使わないほうがいい、という話ではない。公式ドキュメントでも、サードパーティライブラリの使用が推奨されている。
だがそれらのライブラリも、内部では公式が提供している機能を使っている。トラブルシューティングや凝ったことをするために、理解しておくに越したことはない。
この記事の内容は React のv16.10.2
で動作確認している。
また、テスティングフレームワークには Jest を、アサーションライブラリには power-assert を使っている。
レンダラ
React DOM Renderer
(react-dom
)、もしくはReact Test Renderer
(react-test-renderer
)というパッケージを使ってテストを書くが、どちらも公式がサポートしている「レンダラ」である。
レンダラは、React のコンポーネントツリーをそれぞれの環境に適したものに変換する。
例えばReact DOM Renderer
は、DOM に変換する。それによって、React で記述した内容を DOM としてブラウザに表示させることが可能になっている。
レンダラを使って React コンポーネントをテストしやすい形に変換することで、テストが可能になる。
参考:
レンダラ – React
React DOM Renderer
React DOM Renderer
を使うと、コンポーネントツリーを DOM に変換できる。
そして、Jest を使った環境ではグローバルなdocument
オブジェクトが存在するので、そこに DOM をマウントさせることができる。
そのため、テスト対象のコンポーネントを DOM としてレンダーして、それに対してquerySelector
などによって DOM ツリーを走査してアサーションを行う、ということが可能になる。
以下の例ではSample
に対してテストを行っている。
import React from 'react'; import ReactDOM from 'react-dom'; import assert from 'assert'; // テスト対象のコンポーネント const Sample = ({value}) => { return ( <div> foo <span data-test="span-text">{value}</span> </div> ); }; describe('Sample', () => { let container = null; // 各テスト(この例では`it`ブロック)の開始前に都度、document に div 要素を追加する beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); // 各テスト(この例では`it`ブロック)の終了後に都度、React コンポーネントを削除し、document 直下の div 要素も削除する afterEach(() => { ReactDOM.unmountComponentAtNode(container); container.remove(); container = null; }); it('props.value が span 要素のテキストになる', () => { // document 直下の div 要素に Sample をマウントする ReactDOM.render(<Sample value={123} />, container); // すぐに後述するが、本来このコードは act でラップすべき // 通常の DOM 操作と同じ要領で、任意の DOM 要素を取得できる const elem = container.querySelector('span[data-test="span-text"]'); // 取得した DOM 要素に対してアサーションを行う assert.strictEqual(elem.textContent, '123'); }); });
React DOM Renderer
には「テストユーティリティ」というテストのための機能も含まれており、以下の形でインポートできる。
import ReactTestUtils from 'react-dom/test-utils';
テストユーティリティのなかで最も重要な機能が、act
である。
act
act
はv16.8
から導入された機能で、これを使うことで、テスト環境でのコンポーネントの動作を、ブラウザ環境での動作と一致させることができる。
これにより、ユーザーの操作と近い形でテストを実行できる。
コンポーネントのレンダリングや更新が発生するコードをact
でラップすると、そのレンダリングや更新による DOM への反映が全て行われたということが、保証される。
言い換えると、act
でラップされていない場合、DOM への反映が終わらないままアサーションが実行されてしまう可能性がある。
useEffect
を使ったコンポーネントで検証すると、分かりやすい。
下記のSample
は、マウント時にstate
がインクリメントされ、その値は1
になる。
import React, {useState, useEffect} from 'react'; import ReactDOM from 'react-dom'; import assert from 'assert'; const Sample = () => { const [state, setState] = useState(0); useEffect(() => { setState(s => s + 1); }, []); return <div data-test="state">{state}</div>; }; describe('Sample', () => { let container = null; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { ReactDOM.unmountComponentAtNode(container); container.remove(); container = null; }); it('マウント時の state は 1', () => { ReactDOM.render(<Sample />, container); const elem = container.querySelector('div[data-test="state"]'); assert.strictEqual(elem.textContent, '1'); }); });
だがこのテストは失敗する。useEffect
による更新が反映されておらず、表示されている DOM は<div data-test="state">0</div>
だからだ。
Expected value to deeply equal to: "1" Received: "0"
ReactDOM.render
をact
でラップすると、この問題を解決できる。正確にはact
に無名関数を渡し、その関数のなかでReactDOM.render
を実行する。
@@ -1,5 +1,6 @@ import React, {useState, useEffect} from 'react'; import ReactDOM from 'react-dom'; +import {act} from 'react-dom/test-utils'; import assert from 'assert'; const Sample = () => { @@ -25,7 +26,9 @@ }); it('マウント時の state は 1', () => { - ReactDOM.render(<Sample />, container); + act(() => { + ReactDOM.render(<Sample />, container); + }); const elem = container.querySelector('div[data-test="state"]');
こうすると DOM への反映が全て終わってから以降のコードが実行されるので、テストがパスするようになる。
だがuseEffect
のなかで非同期処理を行うと、またテストに失敗するようになる。
import React, {useState, useEffect} from 'react'; import ReactDOM from 'react-dom'; import {act} from 'react-dom/test-utils'; import assert from 'assert'; const Sample = () => { const [state, setState] = useState(0); useEffect(() => { Promise.resolve(null).then(() => { setState(s => s + 1); }); }, []); return <div data-test="state">{state}</div>; }; describe('Sample', () => { let container = null; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { ReactDOM.unmountComponentAtNode(container); container.remove(); container = null; }); it('マウント時の state は 1', () => { act(() => { ReactDOM.render(<Sample />, container); }); const elem = container.querySelector('div[data-test="state"]'); assert.strictEqual(elem.textContent, '1'); }); });
Expected value to deeply equal to: "1" Received: "0" (中略) When testing, code that causes React state updates should be wrapped into act(...):
act
でもasync
を使うことで、これを解決できる。
container = null; }); - it('マウント時の state は 1', () => { - act(() => { + it('マウント時の state は 1', async () => { + await act(async () => { ReactDOM.render(<Sample />, container); });
act
がasync
に対応したのはv16.9
からなので、注意。
act
についての解説は、公式ブログや型定義ファイルのコメントに書かれてある。
イベント
React DOM Renderer
を使って DOM をレンダーしているので、それに対してイベントを発生させることも出来る。
イベントの発生によってコンポーネントの更新も行われる場合(ほとんどのケースがそうだと思う)、act
でラップする。
import React, {useState} from 'react'; import ReactDOM from 'react-dom'; import {act} from 'react-dom/test-utils'; import assert from 'assert'; const Sample = () => { const [state, setState] = useState(0); const onClick = () => { setState(s => s + 1); }; return ( <div> <span data-test="state">{state}</span> <button type="button" onClick={onClick}> click </button> </div> ); }; describe('Sample', () => { let container = null; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => { ReactDOM.unmountComponentAtNode(container); container.remove(); container = null; }); it('ボタンをクリックすると state が 1 増える', () => { act(() => { ReactDOM.render(<Sample />, container); }); const span = container.querySelector('span[data-test="state"]'); const button = container.querySelector('button'); assert.strictEqual(span.textContent, '0'); act(() => { button.dispatchEvent(new MouseEvent('click', {bubbles: true})); }); assert.strictEqual(span.textContent, '1'); }); });
テストユーティリティにはSimulate
という機能もあり、これを使うことでもイベントを実行できる。
しかし公式ドキュメントには
DOM 要素に対して本物の DOM イベントをディスパッチし、その結果に対してアサーションを行うことをお勧めします。
と書かれてあるので、基本的にはdispatchEvent
を使ったほうがいいのかもしれない。
Simulate
の使い所についても、以下のように説明してある。
DOM をシミュレートできない環境(例えば Node.js で React Native のコンポーネントをテストする場合など)では、イベントシミュレーションヘルパを使って要素とのインタラクションをシミュレーションできます。
React Test Renderer
React Test Renderer
はその名の通りテストのためのレンダラで、React コンポーネントを JavaScript オブジェクトとして出力する。そのため、DOM を必要としない。
このレンダラは、create
とact
というメソッドを持つ。
import TestRenderer from 'react-test-renderer'; // [ '_Scheduler', 'create', 'unstable_batchedUpdates', 'act' ] console.log(Object.keys(TestRenderer));
act
の使い方は、React DOM Renderer
のテストユーティリティのact
と同じ。
コンポーネントのレンダリングや更新が行われるコードを、act
でラップする。
React Test Renderer
にはいくつかの固有の概念が存在し、それを理解していないと混乱するのでまずそれを整理する。
といっても、概念そのものはTestRenderer instance
とTestInstance
の2つしかなく、その関係性が重要になる。
先程少し触れたcreate
は、React コンポーネントを受け取り、それに応じたTestRenderer instance
を返す。create
によるコンポーネントの出力が正しく行われるよう、act
でラップする。
React Test Renderer
でのテストは、以下の形でTestRenderer instance
を取得するところから始まる。
let testRenderer; act(() => { testRenderer = create(/* テスト対象のコンポーネント */); });
TestRenderer instance
はroot
というプロパティを持っており、ここに入っているのがTestInstance
。
const testInstance = testRenderer.root;
まとめると、以下のようになる。
import React from 'react'; import {create, act} from 'react-test-renderer'; import assert from 'assert'; const Sample = () => { return <div>foo</div>; }; describe('Sample', () => { it('', () => { let testRenderer; act(() => { // create は TestRenderer instance を返す testRenderer = create(<Sample />); }); // TestRenderer instance は root プロパティを持つ assert.strictEqual(Object.keys(testRenderer).includes('root'), true); // root プロパティは TestInstance を指す const testInstance = testRenderer.root; assert.strictEqual( Object.getPrototypeOf(testInstance).constructor.name, 'ReactTestInstance' ); }); });
TestRenderer instance
とTestInstance
が持っているメソッドやプロパティを使って、テストを書いていく。
TestRenderer instance
いくつかのメソッドがあり、ここではtoJSON
、update
、unmount
を紹介する。
toJSON
。
コンポーネントのツリー構造を表現した JavaScript オブジェクトを返す。
その際、ユーザーが定義したコンポーネントは全て展開される。以下の例だとChild
コンポーネントは<Child />
ではなく<span>bar</span>
として表現される。
import React from 'react'; import {create, act} from 'react-test-renderer'; import assert from 'assert'; const Sample = () => { return ( <div id="sample" className="my-class"> foo <Child /> </div> ); }; const Child = () => { return <span>bar</span>; }; describe('Sample', () => { it('toJSON', () => { let testRenderer; act(() => { testRenderer = create(<Sample />); }); // 要素は type, props, children を持つ const {type, props, children} = testRenderer.toJSON(); assert.strictEqual(type, 'div'); assert.deepStrictEqual(props, {className: 'my-class', id: 'sample'}); assert.strictEqual(children[0], 'foo'); // 子要素も type, props, children を持つ // この構造が再帰的に繰り返される const { type: childType, props: childProps, children: childChildren, } = children[1]; assert.strictEqual(childType, 'span'); assert.deepStrictEqual(childProps, {}); assert.deepStrictEqual(childChildren, ['bar']); }); });
update
とunmount
。
update
は、コンポーネントツリーを受け取り、その内容でTestRenderer instance
を更新する。
その際、前回のコンポーネントツリーと要素やkey
属性が同じだった場合は、既存のコンポーネントを更新する。そうでない場合は、新しいコンポーネントをマウントし直す。
unmount
は、TestRenderer instance
として展開されていたコンポーネントツリーをアンマウントする。
下記のSample
はRef
オブジェクトを使ってマウントと更新を区別しているが、マウント、更新、アンマウントが適切に行われていることを確認できる。
import React, {useEffect, useRef} from 'react'; import {create, act} from 'react-test-renderer'; import assert from 'assert'; const Sample = ({value}) => { const prevValue = useRef(null); useEffect(() => { if (prevValue.current === null) { console.log('mounted'); } else { console.log('updated'); } prevValue.current = value; return () => { console.log('clean up'); }; }); return <div>{value}</div>; }; describe('Sample', () => { it('update と unmount', () => { let testRenderer; act(() => { // mounted testRenderer = create(<Sample value={0} />); }); assert.strictEqual(testRenderer.toJSON().children[0], '0'); // 再マウントではなく更新が行われる act(() => { // clean up // updated testRenderer.update(<Sample value={1} />); }); assert.strictEqual(testRenderer.toJSON().children[0], '1'); // clean up testRenderer.unmount(); assert.strictEqual(testRenderer.toJSON(), null); }); });
TestInstance
TestInstance
は、自身の属性を取得できるプロパティの他、他のTestInstance
を取得するためのメソッドを持つ。
TestInstance
内を検索して子のTestInstance
を取得することで、ルートのTestInstance
以外の要素も取得できる。
import React from 'react'; import {create, act} from 'react-test-renderer'; import assert from 'assert'; const Sample = ({value}) => { return ( <div id="sample"> <Child /> {value} <span id="a" className="foo"> aaa </span> <span id="b" className="foo"> bbb </span> <span id="c" className="bar"> ccc </span> </div> ); }; const Child = () => { return <span id="child">xxx</span>; }; describe('Sample', () => { it('testInstance', () => { let testRenderer; act(() => { testRenderer = create(<Sample value={123} />); }); const testInstanceOfRoot = testRenderer.root; assert.strictEqual(testInstanceOfRoot.type, Sample); assert.deepStrictEqual(testInstanceOfRoot.props, {value: 123}); assert.strictEqual(testInstanceOfRoot.children[0].props.id, 'sample'); const testInstanceOfChild = testInstanceOfRoot.findByType(Child); assert.strictEqual(testInstanceOfChild.type, Child); assert.strictEqual(testInstanceOfChild.children[0].type, 'span'); assert.deepStrictEqual(testInstanceOfChild.children[0].props, { id: 'child', children: 'xxx', }); assert.deepStrictEqual( testInstanceOfRoot.findByProps({className: 'bar'}).props, {children: 'ccc', className: 'bar', id: 'c'} ); const resultOfFindAllByProps = testInstanceOfRoot.findAllByProps({ className: 'foo', }); assert.strictEqual(resultOfFindAllByProps[0].props.children, 'aaa'); assert.strictEqual(resultOfFindAllByProps[1].props.children, 'bbb'); }); });
シャローレンダリング
React Test Renderer
にはシャローレンダリングという機能があり、以下の形でインポートできる。
import ShallowRenderer from 'react-test-renderer/shallow';
ShallowRenderer
のインスタンスを作り、そのインスタンスのrender
メソッドを使うことで、コンポーネントをレンダーする。レンダーした内容はインスタンスのgetRenderOutput
メソッドを使って取得する。
import React from 'react'; import ShallowRenderer from 'react-test-renderer/shallow'; import assert from 'assert'; const Sample = () => { return ( <div id="sample" className="bar"> <span>foo</span> </div> ); }; describe('Sample', () => { it('shallow', () => { const renderer = new ShallowRenderer(); renderer.render(<Sample />); const result = renderer.getRenderOutput(); assert.strictEqual(result.type, 'div'); assert.deepStrictEqual(result.props, { id: 'sample', className: 'bar', children: <span>foo</span>, }); }); });
シャローレンダリングの特徴は、子コンポーネントについてはレンダーを行わないこと。
それにより、子コンポーネントの振る舞いを気にせずにテストを書ける。
下記のChild
は、内部でfetch
を実行している。
このテスト環境にはfetch
が存在しないので、モックを用意するなどの対応をしないと、エラーになってしまう。
だがシャローレンダリングではChild
の中身は展開されないため、fetch
は実行されず、エラーは起きない。
import React from 'react'; import ShallowRenderer from 'react-test-renderer/shallow'; import assert from 'assert'; const Sample = ({url}) => { return ( <div> {`Url is ${url}.`} <Child url={url} /> </div> ); }; const Child = ({url}) => { fetch(url); return <div>foo</div>; }; describe('Sample', () => { it('shallow', () => { const url = 'https://example.com'; const renderer = new ShallowRenderer(); renderer.render(<Sample url={url} />); const result = renderer.getRenderOutput(); assert.strictEqual(result.type, 'div'); assert.deepStrictEqual(result.props, { children: [`Url is ${url}.`, <Child url={url} />], }); }); });