Hooks時代のユニットテスト

[2019/12/21 追記]
数ヶ月テストを運用してみて、React Hooksにおけるユニットテストをどうすべきかを更新した記事を出したのでこっちも参考にしてみてください
👉 **[React Hooks Testing](https://kumaaaaa.com/react-testing/)**

--------------

React Hooks使ってますか?

ロジックをキレイにかけるし、最近ではReduxHooksも登場しconnectやHOCの地獄から抜け出しHooksなしでは生きていけないという人も多いと思います。

しかしテストが書きにくい(特にReduxHooks)など課題がありプロダクション環境に取り入れられていないなどの声も聞きます。

そんなHooksのユニットテストで私自身が迷ったカスタムHooksとReduxHooksを使っているコンポーネントのユニットテストをJest・enzymeを使って紹介します。

## カスタムHook
[カスタムHook](https://reactjs.org/docs/hooks-custom.html)とはHooks APIを組み合わせることで、独自のHooksを定義することができるものです。

今回は`useInput`というinput系の処理を良しなにしてくれるHooksを用意しました

```react:useInput.js
import { useState } from 'react';

const useInput = initialValue => {
  const [value, setValue] = useState(initialValue);

  return { value, onChange: (e) => setValue(e.target.value) }
}

export default useInput;
```

```react:Hoge.jsx
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コンポーネントの内部でしか動作することができません。

なのでテストをするためにテスト用のコンポーネントでラップする方法をとります。

```react:useInput.spec.js
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](https://react-redux.js.org/next/api/hooks)とは`connect()`を使わなくてもコンポーネントでdispatch,stateを利用することができます。

`mapStateToProps`や`mapDispatchToProps`という闇の魔術を使わなくてよくなりました

今回は簡単なカウンターアプリを用意しました。

```javascript:reducer.js
const initialState = {
  count: 0
}

export default function reducer(state = initialState, action) {
  switch (action.type) {
    case 'INCREMENT': {
      return { count: state.count + 1 }
    }
    default:
      return state
  }
}
```

```javascript:action.js
export default {
  increment: () => {
    return { type: 'INCREMENT' }
  },
}
```

```react:Count.jsx
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することでユニットテストを容易にすることができました。(以下のような感じ)



```react:Count.jsx
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](https://sinonjs.org/)を使います。
Sinonはスタブやモックなどの実装に役立つJavaScriptのライブラリです。

Jestでもモックは用意することができますが、今回はSinonの方が使いやすそうだったのでコチラにしました。


まず、カスタムHooksを使う方法に書き換えていきます。

```javascript:hooks.js
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())
  };
}
```

```react:Count.jsx
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しか扱えないっぽいです。

```javascript:Count.spec.js
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