19
12

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.

React Redux Hooks API でユニットテスト

Last updated at Posted at 2019-06-17

追記: 2020-01-14
たまにこちらの記事を見てくれている方もいるようですが、Sinonに依存しているサンプルのため、新たにReact Redux Hooks API + Jest + TypeScript でユニットテストにSinon依存なしverを書いておきましたので参考までに:bow:


React Redux Hooks APIが利用可能となりました。
また、型定義も@types/react-redux@7.1.0からこれに対応したものが利用可能です。

テストはどうするのか

従来のconnectを使った場合、いわゆるPresentational ComponentContainer Componentに分離されるため、
Presentational Componentのテストは抽象化されたPropsで行うことができました。

一方、useSelectoruseDispatchを使った場合、コンポーネントとStoreが密になり、
テストの際にStoreのモックが必要となってしまいます。
Storeのモックを用意して<Provider>でラップして……と、あまりやりたくないのですが、
まだこのAPIが出て間もないため、現状このあたりの確立された手法が存在していない気がします。

カスタムフックのスタブを用いる

ここではuseSelectoruseDispatchをまとめたカスタムフックを定義し、
テスト時にはそのスタブを用いるような実装を試みます。

ファイル構成は、

- Counter.tsx
- Counter.hooks.tsx
- Counter.spec.tsx

を想定します。

まずContainer Componentで行うような処理をカスタムフックに実装します。

Counter.hooks.ts
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "...";
import { increment } from "...";

export function useCounter() {
  const dispatch = useDispatch()
  return {
    ...useSelector(({ counter }: RootState) => ({
      count: counter.count,
    })),
    increment: () => dispatch(increment()),
  }
}

次に、これを使ってCounterコンポーネントを実装します。

Counter.tsx
import React, { FC } from 'react'
import { useCounter } from './Counter.hooks';

const Counter: FC = () => {
  const { count, increment } = useCounter()
  return (
    <div>
      <span>{count}</span>
      <button onClick={increment}>+1</button>
    </div>
  )
}

export default Counter

もちろんTypeScriptを使っていればサジェストされます。

いよいよテストの実装です。
今回は有名所ですがSinon.JSを使ってみます(Sinonに加え、Jest, Enzymeも使っていきます)。

Sinon.JSでは、あるオブジェクトのメソッドのスタブは(やり方は何通りかありますが)、

import sinon from 'sinon'

const obj = {
  method() {
    return 'foo'
  }
}

sinon.stub(obj, 'method').returns('bar')
obj.methods() // bar

のようにできます。
※sinonでは、関数オブジェクトをそのままスタブできないようです(?)。そのため、スタブを用いたい場合はオブジェクトのメソッドとして定義する必要があります。

まずはスタブを使わないで実行してみます。

Counter.spec.tsx
import React from 'react'
import { shallow } from 'enzyme'
import sinon from 'sinon'
import Counter from './Counter';
import * as hooks from './Counter.hooks'

describe('<Counter />', () => {
  it('rendered without store', () => {
    const wrapper = shallow(<Counter />)
    expect(wrapper).toHaveLength(1)
  })
})

テストを実行すると、Invariant Violation: could not find react-redux context value; please ensure the component is wrapped in a <Provider> とでます。Storeがありませんからね。想定内です。

では、スタブを使用してみましょう。

  • import * as hooks でモジュールをインポート
  • returnsメソッドでダミーの返り値を定義
Counter.spec.tsx
import React from 'react'
import { shallow } from 'enzyme'
import Counter from './Counter';
import * as hooks from './Counter.hooks'
import sinon from 'sinon'

describe('<Counter />', () => {
  const increment = sinon.spy()
  sinon.stub(hooks, 'useCounter').returns({
    count: 10,
    increment,
  })
  it('rendered without store', () => {
    const wrapper = shallow(<Counter />)

    expect(wrapper.find('span').text()).toBe('10')
    wrapper.find('button').simulate('click')
    expect(increment.callCount).toBe(1)
  })
})

雑な例ですが、Storeを用意せず、useCounterのスタブを用意することで通りました。

TypeScriptを使っていると、
sinon.stub(hooks, 'useCounter').returns({ /* ここ */ }) でサジェストされ、
簡単にダミーのデータを定義できます。

Sinonについて詳しくはここでは触れませんが、必要に応じてスタブやスパイ、モックのresetも行いましょう。
詳細はSinonのドキュメントをご覧ください。

「俺はやっぱりContainerは分離したいんだ!」という方はもちろんconnectで実装してもいいでしょうし、Hooksを使いつつContainer ComponentPresentational Componentを分離することも可能です。

いずれにせよ、テストは書いていきましょう

19
12
0

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
19
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?