React 16.8 Hooks and ReactTestUtils.act
先週React 16.8がリリースされ、ようやくHooksが実装された。getSnapshotBeforeUpdate
とcomponentDidCatch
に対応するメソッドは実装されていないが、正式に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);
ドキュメント上とタブタイトルに表示された「You clicked 0 times」がボタンを押す度に1ずつ加算されます。
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で確認できます。