Help us understand the problem. What is going on with this article?

Hooks時代のユニットテスト

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

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away