105
87

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Hooks時代のユニットテスト

Last updated at Posted at 2019-07-29

[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を用意しました

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

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

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とはconnect()を使わなくてもコンポーネントでdispatch,stateを利用することができます。

mapStateToPropsmapDispatchToPropsという闇の魔術を使わなくてよくなりました

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

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
  }
}
action.js
export default {
  increment: () => {
    return { type: 'INCREMENT' }
  },
}
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することでユニットテストを容易にすることができました。(以下のような感じ)

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を使います。
Sinonはスタブやモックなどの実装に役立つJavaScriptのライブラリです。

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

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

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())
  };
}
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しか扱えないっぽいです。

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

105
87
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
105
87

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?