React 16.8 Hooks and ReactTestUtils.act

先週React 16.8がリリースされ、ようやくHooksが実装された。getSnapshotBeforeUpdatecomponentDidCatchに対応するメソッドは実装されていないが、正式にHooksベースでコードが組めるようになった。いくつかのサンプルも見られる。

また、16.8でReactTestUtils.act()というHooksのテストAPIが追加された。今回はこのメソッドに触れてみる。

Hooksを試す

React Hooksは@^16.8が必要。

yarn add react@^16.8.0 react-dom@^16.8.0

ReactTestUtils.act()を試すためにはテストされるコンポーネントが必要なので、今回は公式にあるCounterコンポーネントサンプルをHookに書き換えて使うことにする。また、ロジック部分はCustom Hookで切り分けした。

以下、ミニマム構成による最終的なディレクトリ構造。

ディレクトリ構造

Custom Hooksを利用するCountコンポーネント。

// src/components/main/Count.jsx
import React from 'react';
import PropTypes from 'prop-types';

import useCounter from '../hooks/Counter';

const Count = ({ count }) => {
  const counter = useCounter(count);
  return (
    <>
      <p className="times">
        {`You clicked ${counter.count} times`}
      </p>
      <button type="button" onClick={counter.onIncrement} className="btn">
        Click me
      </button>
    </>
  );
};

Count.propTypes = {
  count: PropTypes.node.isRequired,
};

export default Count;

Custom HooksとなるuseCounter。

// src/components/hooks/Counter.js
import { useState, useEffect } from 'react';

function useCounter(defaultCount) {
  const [count, setCount] = useState(defaultCount);
  useEffect(() => {
    document.title = `You clicked ${count} times`;
  });

  return {
    count,
    onIncrement: () => setCount(prevState => prevState + 1),
  };
}

export default useCounter;

AppでCountコンポーネントを使用します。

// src/components/App.jsx
import React from 'react';
import { hot } from 'react-hot-loader/root';
import Count from './components/main/Count';

const App = () => (
  <>
    <Count count={0} />
  </>
);

export default hot(App);

以下のように動作します。

ブラウザ動作

ReactTestUtils.act

act()はHooksを使ったコンポーネントのレンダリングと更新のテストが、ブラウザ上の動作と一致しているかを確認するもの。このメソッドはreact-dom/test-utilsに付属している。

React Hooksを使用したコードをact()を使用せずにテストすると、意図しない結果になります。以下はCount.jsをact()を使用せずにテストした例です。

⚠テストにはJest+Enzymeを使っています。予めセットアップが必要です。

// src/components/main/__tests__/Count-test.js
import React from 'react';
import { act } from 'react-dom/test-utils';
import { mount } from 'enzyme';
import Count from '../Count';

const div = global.document.createElement('div');
global.document.body.appendChild(div);
global.document.title = 'Default Title';

describe('Count Component', () => {
  it('can render and update a counter', () => {
    const wrapper = mount(<Count count={0} />, { attachTo: div });
    expect(div.childNodes).toHaveLength(2);
    expect(wrapper.find('p.times').text()).toEqual('You clicked 0 times');
    expect(document.title).toEqual('You clicked 0 times');
  });
});

これをテストすると、以下のような結果になります。

● Count Component › can render and update a counter
expect(received).toEqual(expected)

Difference:

- Expected
+ Received

- You clicked 0 times
+ Default Title

  13 |     expect(div.childNodes).toHaveLength(2);
  14 |     expect(wrapper.find('p.times').text()).toEqual('You clicked 0 times');
> 15 |     expect(document.title).toEqual('You clicked 0 times');
     |                            ^
  16 |   });
  17 | });

デフォルトで指定したdocument.titleが変更されていません。useCounter内のuseEffect()が処理できていないのが確認できます。そこで次にact()を使用してみます。

import React from 'react';
import { act } from 'react-dom/test-utils';
import { mount } from 'enzyme';
import Count from '../Count';

const div = global.document.createElement('div');
global.document.body.appendChild(div);
global.document.title = 'Default Title';

describe('Count Component', () => {
  it('can render and update a counter', () => {
    let wrapper;

    // Test first render and effect
    act(() => {
      wrapper = mount(<Count count={0} />, { attachTo: div });
    });
    expect(div.childNodes).toHaveLength(2);
    expect(wrapper.find('p.times').text()).toEqual('You clicked 0 times');
    expect(document.title).toEqual('You clicked 0 times');
  });
});

これは無事テストがパスできました。同様にイベント発火も書いてみます🔥

	...
	// Test second render and effect
    act(() => {
      wrapper.find('.btn').simulate('click');
    });
    expect(wrapper.find('p.times').text()).toEqual('You clicked 1 times');
    expect(document.title).toEqual('You clicked 1 times');
  });
});

仮にact()を使用しないと、useCounterのレンダリングが動作せずテストが落ちます。要約するとHookを使うレンダリングや更新があれば、act()でラップするということ。

今回テストしていないですが、Custom Hookのテストも書くことができます。

ESLint

Hooks向けにESLintのプラグインも公開されています。以下、公式そのまま。

yarn add eslint-plugin-react-hooks@next --dev

eslintの設定ファイル(.eslintrc.jsなど)に以下を追加。

{
  "plugins": [
    // ...
    "react-hooks"
  ],
  "rules": {
    // ...
    "react-hooks/rules-of-hooks": "error"
  }
}

Custom Hooksをアロー演算子で定義すると警告が表示されたが、再現性がなく不明。現状はお出しされたものをそのまま使ってて問題なさそう。

おわりに

Hooksはバグが多く残っているようで、16.8.0リリースから8日間で2回のマイナー更新が行われている。これを書いている時点の最新は16.8.2。変更点はChangeLogで確認できます。