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

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

サードパーティライブラリを使わずに React コンポーネントのユニットテストを書く

React コンポーネントのユニットテストを書くときは、React Testing LibraryEnzymeなどのサードパーティライブラリを使うことが多いはず。
だがそれらを使わなくても、公式が提供している機能だけでも十分にユニットテストを書くことが出来る。
この記事では、ユニットテストのために公式が提供している機能を紹介していく。

サードパーティライブラリを使わないほうがいい、という話ではない。公式ドキュメントでも、サードパーティライブラリの使用が推奨されている。
だがそれらのライブラリも、内部では公式が提供している機能を使っている。トラブルシューティングや凝ったことをするために、理解しておくに越したことはない。

この記事の内容は 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

actv16.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.renderactでラップすると、この問題を解決できる。正確には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);
     });

actasyncに対応したのは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 イベントをディスパッチし、その結果に対してアサーションを行うことをお勧めします。

イベント – React

と書かれてあるので、基本的にはdispatchEventを使ったほうがいいのかもしれない。

Simulateの使い所についても、以下のように説明してある。

DOM をシミュレートできない環境(例えば Node.js で React Native のコンポーネントをテストする場合など)では、イベントシミュレーションヘルパを使って要素とのインタラクションをシミュレーションできます。

描画画面のモック – React

React Test Renderer

React Test Rendererはその名の通りテストのためのレンダラで、React コンポーネントを JavaScript オブジェクトとして出力する。そのため、DOM を必要としない。

このレンダラは、createactというメソッドを持つ。

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 instanceTestInstanceの2つしかなく、その関係性が重要になる。

先程少し触れたcreateは、React コンポーネントを受け取り、それに応じたTestRenderer instanceを返す。createによるコンポーネントの出力が正しく行われるよう、actでラップする。
React Test Rendererでのテストは、以下の形でTestRenderer instanceを取得するところから始まる。

let testRenderer;
act(() => {
  testRenderer = create(/* テスト対象のコンポーネント */);
});

TestRenderer instancerootというプロパティを持っており、ここに入っているのが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 instanceTestInstanceが持っているメソッドやプロパティを使って、テストを書いていく。

TestRenderer instance

いくつかのメソッドがあり、ここではtoJSONupdateunmountを紹介する。

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']);
  });
});

updateunmount

updateは、コンポーネントツリーを受け取り、その内容でTestRenderer instanceを更新する。
その際、前回のコンポーネントツリーと要素やkey属性が同じだった場合は、既存のコンポーネントを更新する。そうでない場合は、新しいコンポーネントをマウントし直す。

unmountは、TestRenderer instanceとして展開されていたコンポーネントツリーをアンマウントする。

下記のSampleRefオブジェクトを使ってマウントと更新を区別しているが、マウント、更新、アンマウントが適切に行われていることを確認できる。

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} />],
    });
  });
});

参考資料

React のプレリリースチャンネルで実験的な機能にアクセスする

React は公式に3つのリリースチャンネルを用意しており、これを利用すれば安定版以外の React もインストールすることが出来る。

  • Latest
  • Next
  • Experimental
    • Next と同様にmasterブランチを追跡するが、Next では無効になっている実験的な機能も有効になっている。このチャンネルでは、リリース間で破壊的変更が高い頻度で行われる可能性がある。

このうち Latest 以外の2つのリリースが「プレリリースチャンネル」と呼ばれる。

React の開発は GitHub 上で行われており、変更は随時masterブランチに取り込まれていく。
だが、その内容が全て安定版に取り込まれるわけではない。
安定版のブランチは別にあり、そこにmasterブランチからチェリーピックして安定版に変更を取り込んでいく
そしてそれが Latest チャンネルからリリースされる。このプロセスによって、互換性が不本意に破壊されることを防ぎ、React の安定性が保たれるようになっている。

masterブランチに取り込まれた内容は、Next チャンネルからリリースされる。
そのため、Latest と Next の差分は、次回の Latest リリースに含まれる内容とほぼ同じになる。

Experimental もmasterブランチの内容が反映されているため、Next と同じコードをベースにしている。
違いは、Experimental ではフィーチャー・フラグがオンになっていること。これにより、実験的な機能が含まれたリリースになっている。
これらの機能は今後の開発で変更されたり削除されたりする可能性が十分にある。

Next や Experimental のようなプレリリースチャンネルが用意されていることで React コアチームは、コミュニティにそれらの機能を利用してもらい、フィードバックを得ることが出来るようになっている。

実際に使ってみる

全てのリリースが npm に公開されているので、簡単に利用できる。

リリースチャンネルを指定しなかった場合は Latest リリースがインストールされる。

$ npm i react react-dom

本日現在の最新安定版は16.12.0なので、それがインストールされた。react@latestreact-dom@latestのように@latestを付けて明示的に Latest リリースをインストールすることも出来る。

@nextを付けると Next リリースをインストールできる。

$ npm i react@next react-dom@next

インストールされたバージョンは0.0.0-b53ea6ca0
プレリリースのバージョンにはコンテンツのハッシュ値を使うため、このようになる。

最後に Experimental をインストールする。

$ npm i react@experimental react-dom@experimental

0.0.0-experimental-f42431abeがインストールされた。
このバージョンでは、まだ安定版には採り入れられていない Concurrent Mode の機能にアクセスできる。

以下のコードを実行すると、useTransitioncreateRootを利用できることを確認できる。

// import 文に対応するのが面倒で require を使っただけであり、他意はない
const React = require('react');
const ReactDOM = require('react-dom');

console.log(React.useTransition);
console.log(ReactDOM.createRoot);

このコードを Latest や Next の React で実行すると、useTransitioncreateRootundefinedになる。

参考資料