[2019/12/21 追記]
数ヶ月テストを運用してみて、React Hooksにおけるユニットテストをどうすべきかを更新した記事を出したのでこっちも参考にしてみてください
👉 React Hooks Testing
React Hooks使ってますか?
ロジックをキレイにかけるし、最近ではReduxHooksも登場しconnectやHOCの地獄から抜け出しHooksなしでは生きていけないという人も多いと思います。
しかしテストが書きにくい(特にReduxHooks)など課題がありプロダクション環境に取り入れられていないなどの声も聞きます。
そんなHooksのユニットテストで私自身が迷ったカスタムHooksとReduxHooksを使っているコンポーネントのユニットテストをJest・enzymeを使って紹介します。
カスタムHook
カスタムHookとはHooks APIを組み合わせることで、独自のHooksを定義することができるものです。
今回はuseInput
というinput系の処理を良しなにしてくれるHooksを用意しました
import { useState } from 'react';
const useInput = initialValue => {
const [value, setValue] = useState(initialValue);
return { value, onChange: (e) => setValue(e.target.value) }
}
export default useInput;
import React from 'react';
import useInput from './useInput';
const Hoge = () => {
const attrs = useInput('');
return (
<div>
<p>input value: { attrs.value }</p>
<input type="text" { ...attrs } />
</div>
)
}
export default Hoge;
ユニットテスト
useInput
のUnitTestを考えていきます。
一見普通のJavaScript関数に見えますが、HooksはReactコンポーネントの内部でしか動作することができません。
なのでテストをするためにテスト用のコンポーネントでラップする方法をとります。
import React from 'react';
import { shallow } from 'enzyme';
import useInput from '../useInput';
const Wrapper = () => {
const attrs = useInput('hello');
return (
<div>
<span>{attrs.value}</span>
<input type="text" {...attrs} />
</div>
)
}
describe('useInput', () => {
it('initial value', () => {
const component = shallow(<Wrapper />);
expect(component.find('span').text()).toEqual('hello');
});
it('onChange', () => {
const component = shallow(<Wrapper />);
component.find('input').simulate('change', {target: {value: 'input new value'}});
expect(component.find('span').text()).toEqual('input new value');
});
});
こうすることでカスタムHookのユニットテストを実行することができます。
次はReduxHooksのユニットテストを紹介します。
Redux Hooks
ReduxHooksとはconnect()
を使わなくてもコンポーネントでdispatch,stateを利用することができます。
mapStateToProps
やmapDispatchToProps
という闇の魔術を使わなくてよくなりました
今回は簡単なカウンターアプリを用意しました。
const initialState = {
count: 0
}
export default function reducer(state = initialState, action) {
switch (action.type) {
case 'INCREMENT': {
return { count: state.count + 1 }
}
default:
return state
}
}
export default {
increment: () => {
return { type: 'INCREMENT' }
},
}
import React from 'react';
import { useDispatch, useSelector } from "react-redux";
import action from './action';
const counterSelector = (state) => ({
count: state.count,
});
const Count = () => {
const dispatch = useDispatch();
const { count } = useSelector(counterSelector);
return (
<div>
<p>{ count }</p>
<button onClick={() => dispatch(action.increment())}>+</button>
</div>
)
}
export default Count;
ユニットテスト
Count.jsx
のユニットテストを考えていきます。
connect()
を使っていた時はconnect()
してない部分をexportすることでユニットテストを容易にすることができました。(以下のような感じ)
import React from 'react';
import { connect } from 'react-redux';
// ↓ココ
export const Count = ({ count, increment }) => {
return (
<div>
<p>{ count }</p>
<button onClick={() => increment()}>+</button>
</div>
)
}
function mapStateToProps(state) {
return { count: state.count };
}
function mapDispatchToProps(dispatch) {
return {
increment: () => dispatch({
type: 'INCREMENT',
}),
}
};
export default connect(mapStateToProps, mapDispatchToProps)(Count);
ReduxHooksを使っているコンポーネントの場合はStoreとコンポーネントが密結合になってしまいStoreのモックを用意しないとエラーが出るようになってしまいました。
しかしStoreを用意するとユニットテストではなく、インテグレーションテストになってしまいます。
なのでReduxHooksを使用しているコンポーネントでユニットテストを行うためにContainerコンポーネントの役割のようなカスタムHookを定義し、ユニットテストを行う際はカスタムHookのスタブを用意することで対処します。
今回はスタブを用いるためにsinonを使います。
Sinonはスタブやモックなどの実装に役立つJavaScriptのライブラリです。
Jestでもモックは用意することができますが、今回はSinonの方が使いやすそうだったのでコチラにしました。
まず、カスタムHooksを使う方法に書き換えていきます。
import { useDispatch, useSelector } from "react-redux";
import action from './action';
export const useCounter = () => {
const dispatch = useDispatch();
const counterSelector = (state) => ({
count: state.count,
})
return {
...useSelector(counterSelector),
increment: () => dispatch(action.increment())
};
}
import React from 'react';
import { useCounter } from './store/hooks';
const Count = () => {
const { count, increment } = useCounter();
return (
<div>
<p>{ count }</p>
<button onClick={increment}>+</button>
</div>
)
}
export default Count;
次にスタブを使ったユニットテストを書いていきます。
ちなみにsinonではObjectしか扱えないっぽいです。
import React from 'react';
import { shallow } from 'enzyme';
import sinon from 'sinon';
import * as hooks from '../store/hooks';
import Count from '../Count';
describe('Countコンポーネント', () => {
const increment = jest.fn();
sinon.stub(hooks, 'useCounter').returns({
count: 1,
increment
});
it('Redux Hooks', () => {
const component = shallow(<Count />);
component.find('button').simulate('click');
expect(increment).toBeCalled();
})
});
これでReduxHookを使っているコンポーネントでもユニットテストをすることができるようになりました。
おわりに
GitHubにも今回のコードをあげておきました
https://github.com/isy/react-hooks-unit-testing
今回の例では結局Containerのようなものを用意してしまったので、もっといい方法があればぜひ教えてもらいたいです。
recompose
やClassコンポーネントに比べるとやっぱりキレイにかけるので、プロダクションでもHooksとテスト書いていきましょう!!
参考記事
https://dev.to/flexdinesh/react-hooks-test-custom-hooks-with-enzyme-40ib
https://qiita.com/r_1105/items/ddcb1ac56df13a0c77ca