[React] カスタム Hooks のユニットテスト

追記:2019-07-12
react-testing-library@testing-library/react に名前が変わりました。この記事で言及している範囲では現状は単に import 元のリネームだけで済むようです。
import { render, fireEvent, cleanup } from '@testing-library/react';

昨年の React Conf で 発表された Hooks が正式版になってしばらく経ちましたね。Hooks 使ってますか?

Class Component から Functional Component に舵を切っていくにあたって Hooks は欠かせない機能になります。今回は カスタム Hooks のユニットテストの書き方のメモです。

カスタム Hooks ってなに

React 自身が提供する Hooks を組み合わせてオリジナルの Hooks を作ることが出来ます。これを「カスタム Hook(s)」と呼びます。

例えばコンポーネントの中で useState をズラズラっと書いたりすると可読性が落ちますので、カスタム Hooks にまとめて読みやすくする、といった利点があります。また、関連する処理をまとめてカスタム Hooks に閉じ込めてしまってもいいでしょう。Hooks を使って少し複雑なコンポーネントを作ってみるとわかりますが、カスタム Hooks はなくてはならない存在です。

カスタム Hooks は実際はただの関数ですが、その関数の名前は use で始めるルールがあります。

Extracting a Custom Hook

A custom Hook is a JavaScript function whose name starts with ”use” and that may call other Hooks.

カスタム Hooks のユニットテスト

本題です。

複雑な処理をまとめたり、たくさんの state を束ねる Hook を書くとその Hook のユニットテストが欲しくなります。

しかし Hooks にはいくつかのルールがあり、そのうちの一つに「Hooks は React のコンポーネントの中 or カスタム Hooks の中からしか呼べない」というのがあります。もちろんカスタム Hooks も例外ではありません。

Only Call Hooks from React Functions

Don’t call Hooks from regular JavaScript functions.

となると、Jest のようなテストランナーから Hooks を呼ぶわけにはいきません。どうしましょう。

実は、React 公式が答えを出してくれています。

Testing Hooks

We recommend to wrap any code rendering and triggering updates to your components into act() calls.

ReactDOM の提供する act() 関数の中ではコンポーネントをマウントしたり、コンポーネントの state が変わるような操作をすることが出来ます。つまりカスタム Hooks のユニットテストは、カスタム Hooks を呼ぶ簡単なコンポーネントを作り、act() でマウントしてテストする 流れになります。

上記の記事では act() を直接使うのではなく react-testing-library というライブラリを使用することを推奨しています。そこで今回はカスタム Hooks のユニットテストを「ReactDOM の提供する act() を使った場合」と「react-testing-library を使った場合」の2パターン書いて見ることにします。

テスト対象の Hook とテスト用コンポーネント

ここでは以下の簡単なカスタム Hook を例とします。現在のカウント (count) と、それをインクリメントする関数 (increment) を提供する簡単な Hook です。

export const useCounter = (initialCount) => {
    const [count, setCount] = useState(initialCount);
    const increment = () => setCount(count + 1);

    return [count, increment];
};

そしてこれを使うテスト用コンポーネントを用意します。カウントを表示する要素、インクリメントするボタン要素の2つしかありません。

export const TestCounter = ({ initialCount = 0 }) => {
    const [count, increment] = useCounter(initialCount);
    return (
        <>
            <div>count: {count}</div>
            <button onClick={() => increment()}>increment</button>
        </>
    );
};

act() を使った例

act() を使って上記 Hook のユニットテストを書いてみます。ここではユニットテストのフレームワークとして Jest を使用します。

act() の中で render する

コンポーネントのマウントは act() の中で行わなければいけません。

import { render } from 'react-dom';
import { act } from 'react-dom/test-utils';

act(() => {
    render(<TestCounter />, container);
});

ここで render() は ReactDOM の提供する render() です。第二引数に container を指定していますが、これはコンポーネントをマウントする先の DOM ノードです。Jest の提供する beforeEachafterEach を使ってテストケースの実行前後に生成・破棄します。

let container;

beforeEach(() => {
    container = document.createElement('div');

    // 注1
    // document.body.appendChild(container);
});

afterEach(() => {
    // 注1
    // document.body.removeChild(container);

    container = null;
});

※注1・・・テスト対象の Hook が DOM を直接触る場合 (要素の高さ・幅を取得したり、イベントをディスパッチしたりする場合) container は DOM にアタッチされている必要があります。その場合は body の直下に container を追加してしまいましょう。

イベントのディスパッチは Simulate

また、テストケース内でイベントをディスパッチしたいときも act() の中で行う必要があります。Simulate は DOM ノードにイベントをディスパッチするユーティリティです。act() と同様に ReactDOM が提供します。

import { Simulate } from 'react-dom/test-utils';

act(() => {
    render(<TestCounter />, container);

    // 注2
    const button = container.querySelector('button');
    Simulate.click(button);
});

※注2・・・Simulate でイベントをディスパッチする場合は「注1」で書いたように container を DOM にアタッチする必要はありません。

アサーション

さて、マウントもできたのでいよいよアサーションを書きましょう。アサーションは act() の外に書いても大丈夫です。やってることはごく普通で、querySelector で要素を取ってきて textContent の中身を確認しています。

const div = container.querySelector('div');
expect(div.textContent).toEqual('count: 0');  // Jest は jsdom を使うので innerHTML ではなく textContent

全体

全体像は以下のようになります。

import React from 'react';
import { render } from 'react-dom';
import { act, Simulate } from 'react-dom/test-utils';
import { TestCounter } from './TestComponent';

let container;

beforeEach(() => {
    container = document.createElement('div');

    // 注1 上記参照
    // document.body.appendChild(container);
});

afterEach(() => {
    // 注1 上記参照
    // document.body.removeChild(container);

    container = null;
});

describe('TestCounter', () => {
    it('should show an initial count.', () => {
        const initialCount = 5;
        act(() => {
            render(<TestCounter initialCount={initialCount} />, container);
        });

        const div = container.querySelector('div');
        expect(div.textContent).toEqual('count: 5');
    });

    it('should increment when the button is pushed.', () => {
        act(() => {
            render(<TestCounter />, container);

            const button = container.querySelector('button');
            Simulate.click(button);
        });

        const div = container.querySelector('div');
        expect(div.textContent).toEqual('count: 1');
    });
});

react-testing-library を使った例

今度は react-testing-library を使って書いてみましょう。テスト対象の Hook とコンポーネントは上記と同じものです。

act() を書く必要はない

react-testing-library の提供する render()act() の中で呼ぶ必要はありません。

import { render } from 'react-testing-library';

const { getByText } = render(<TestCounter />);

こちらの render() は DOM 要素を取得するための様々な関数が詰まったオブジェクトを返します。どのような関数が利用できるかは react-testing-library のドキュメントを参照してください。

render Result

また、act() を使う例ではテストケース実行前に container 変数のセットアップをしていましたが、react-testing-library ではそういった事は必要ありません。ただしテストケース実行後の破棄だけは必要になります。

import { cleanup } from 'react-testing-library';

afterEach(cleanup);

イベントのディスパッチは fireEvent

act() を使う例ではイベントのディスパッチには Simulate を使っていましたが、こちらは fireEvent を使います。これも act() でラップする必要はありません。

const { getByText } = render(<TestCounter />);

const button = getByText('increment');
fireEvent.click(button);

アサーション

アサーションは act() を使う例と同様に DOM 要素を取ってきてそれに対して何らかのアサーションをする形になります。ここでは期待するテキストから div 要素を取ってきて、その div 要素が存在するかどうかを確かめています。他にも data-testId 属性から要素を取得できたりするようです。

const div = getByText('count: 1');
expect(div).toBeDefined();

全体

react-testing-library を使う場合の全体像は以下のようになります。act() を使う例に比べるといくらか短いですね。

import React from 'react';
import { render, fireEvent, cleanup } from 'react-testing-library';
import { TestCounter } from './TestComponent';

afterEach(cleanup);

describe('TestCounter', () => {
    it('should show an initial count.', () => {
        const initialCount = 5;
        const { getByText } = render(<TestCounter initialCount={initialCount} />);

        const div = getByText('count: 5');
        expect(div).toBeDefined();
    });

    it('should increment when the button is pushed.', () => {
        const { getByText } = render(<TestCounter />);

        const button = getByText('increment');
        fireEvent.click(button);

        const div = getByText('count: 1');
        expect(div).toBeDefined();
    });
});

まとめ

カスタム Hooks のユニットテストを書くことができました。React 標準の API だけでも十分簡単ですが、react-testing-library を使うともっと簡単にかけますね。

ここで紹介した例をまとめて以下のリポジトリに置きましたので見てみてください。useEffect を使ったもう少し複雑な Hooks のテストも書いてあります。

react-hooks-unit-test-example

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です