React Test Renderer
のcreate
メソッドは、オプションでcreateNodeMock
という機能を使うことができる。
ref
属性を使っているコンポーネントのテストに便利なのだが、初見では理解しづらい機能だったので、整理しておく。
React Test Renderer
についてはこちらを参照。
numb86-tech.hatenablog.com
ref
属性の基本については、こちらを参照。
numb86-tech.hatenablog.com
この記事の動作確認には React のv16.10.2
を使用しており、テスティングフレームワークには Jest を、アサーションライブラリには power-assert を、それぞれ使っている。
HTML 要素に設定した ref 属性が機能しない
テスト対象のコンポーネントとして、以下のSample
コンポーネントを用意した。
const Sample = () => { const [state, setState] = useState(''); const ref = useRef(); useEffect(() => { setState(ref.current.textContent); }, []); return ( <div> <span data-test="original" ref={ref}> text1 </span> <span data-test="copy">{state}</span> </div> ); };
ひとつ目のspan
要素のref
属性にref
を渡しているため、マウントされた時点でref.current
にはこのspan
要素が格納されている。
そのため、useEffect
実行時点でref.current.textContent
はtext1
という文字列を指すようになっており、state
にもこの文字列が入る。
その結果、ふたつ目のspan
要素のtextContent
も、text1
になる。
このコンポーネントのテストコードが、以下。
describe('Sample', () => { it('original のテキストと copy のテキストが一致する', () => { let testRenderer; act(() => { testRenderer = create(<Sample />); }); const testInstance = testRenderer.root; assert.strictEqual( testInstance.findByProps({'data-test': 'copy'}).props.children, testInstance.findByProps({'data-test': 'original'}).props.children ); }); });
ロジックは何も間違っていない。
だがこのテストは、以下のエラーを出して失敗してしまう。
TypeError: Cannot read property 'textContent' of null
つまり、ref.current
の値がnull
になってしまっている。
useRef
に引数を渡さなかった場合のref.current
の初期値はundefined
なので、その後、useEffect
が実行されるまでの間に、span
要素ではなくnull
が格納されていることになる。
これは恐らく、React Test Renderer
は React コンポーネントを DOM ではなく JavaScript オブジェクトに変換するので、span
要素の DOM が存在せず、それによってnull
が格納されたのではないかと思う。
ともかく、ref
属性を使ったコンポーネントはReact Test Renderer
では上手く動かない、ということが分かった。
ref のモックを作る
ref
に限らないが、プロダクション環境での動作とテスト環境での動作が異なってしまう場合、モックを使って対応することが多い。
テスト環境では正しく動かない、あるいは動かすのが難しい部分をモックに置き換えて、他の部分が正しく動作するかをテストする。
今回のSample
においては、ref
のモックを作り、マウント時にref.current.textContent
の内容が DOM に反映されているかどうかをテストすることにした。
そのため、ref
のモックは、以下のようなオブジェクトになる。
{ current: /* ひとつ目の span 要素 */ }
これを用意して、それをSample
内で使っているRef
オブジェクトと置き換えることが出来れば、上手くテストを書けるはず。
そしてcreateNodeMock
を使うことで、ref
のモックを作ることができる。
createNodeMock の使い方
createNodeMock
というメソッドを持ったオブジェクトをcreate
メソッドの第二引数に渡す、がcreateNodeMock
の使い方なのだが、コードを見たほうが早い。
create(<Sample />, { createNodeMock: element => element, });
createNodeMock
メソッドの引数には、ref
属性を設定した要素が入る。
そして、このメソッドの返り値が、ref
属性に渡したRef
オブジェクトのcurrent
プロパティに設定される。
そのため、useEffect
時点でのref
の中身は、このようになる。
{ current: { type: 'span', props: { 'data-test': 'original', children: 'text1' } } }
だがこのままでは、プロダクション環境でのref
の構造とは異なっておりtextContent
を取得できないため、createNodeMock
の返り値を変える。
create(<Sample />, { createNodeMock: element => ({ ...element, textContent: element.props.children, }), });
これでtextContent
を取得できるようになる。
実際のコードは以下のようになる。
import React, {useState, useRef, useEffect} from 'react'; import {create, act} from 'react-test-renderer'; import assert from 'assert'; const Sample = () => { const [state, setState] = useState(''); const ref = useRef(); useEffect(() => { setState(ref.current.textContent); }, []); return ( <div> <span data-test="original" ref={ref}> text1 </span> <span data-test="copy">{state}</span> </div> ); }; describe('Sample', () => { it('original のテキストと copy のテキストが一致する', () => { let testRenderer; act(() => { testRenderer = create(<Sample />, { createNodeMock: element => ({ ...element, textContent: element.props.children, }), }); }); const testInstance = testRenderer.root; assert.strictEqual( testInstance.findByProps({'data-test': 'copy'}).props.children, testInstance.findByProps({'data-test': 'original'}).props.children ); }); });
これで無事にSample
のテストを書くことができた。
callback ref
ref
属性には、Ref
オブジェクトではなく関数を渡すこともできる。
その場合、Ref
オブジェクトを渡したときにはref.current
に格納されていた値が関数の引数として渡されるが、createNodeMock
はこのパターンでも問題なく動く。
つまり、createNodeMock
メソッドの返り値が、ref
属性に設定した関数の引数になる。
そのため、Sample
を以下のように書き換えても、先程のテストはパスする。
const Sample = () => { const [state, setState] = useState(''); const callbackRef = ref => { if (ref !== null) { setState(ref.textContent); } }; return ( <div> <span data-test="original" ref={callbackRef}> text1 </span> <span data-test="copy">{state}</span> </div> ); };
ref 属性を使った要素が複数ある場合
複数の要素でref
属性を使っていた場合、それぞれに対してcreateNodeMock
メソッドが実行される。
そのため、それぞれのRef
オブジェクト、もしくはコールバック関数に、対応した値が渡される。
import React, {useState, useRef, useEffect} from 'react'; import {create, act} from 'react-test-renderer'; import assert from 'assert'; const Sample = () => { const [state, setState] = useState(''); const leftRef = useRef(); const rightRef = useRef(); useEffect(() => { setState(leftRef.current.textContent + rightRef.current.textContent); }, []); return ( <div> <span data-test="left" ref={leftRef}> foo </span> + <span data-test="right" ref={rightRef}> bar </span> =<span data-test="result">{state}</span> </div> ); }; describe('Sample', () => { it('left + right = result', () => { let testRenderer; act(() => { testRenderer = create(<Sample />, { createNodeMock: element => ({ ...element, textContent: element.props.children, }), }); }); const testInstance = testRenderer.root; const leftText = testInstance.findByProps({'data-test': 'left'}).props .children; const rightText = testInstance.findByProps({'data-test': 'right'}).props .children; const resultText = testInstance.findByProps({'data-test': 'result'}).props .children; assert.strictEqual(resultText, `${leftText}${rightText}`); }); });