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

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

---

[React Redux Hooks API](https://react-redux.js.org/api/hooks)が利用可能となりました。
また、型定義も`@types/react-redux@7.1.0`からこれに対応したものが利用可能です。

## テストはどうするのか

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

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

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

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

ファイル構成は、

```
- 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](https://sinonjs.org/)を使ってみます（Sinonに加え、Jest, Enzymeも使っていきます）。

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

```TypeScript
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のドキュメント](https://sinonjs.org/releases/v7.3.2/)をご覧ください。

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

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