React Redux Hooks API + Jest + TypeScript でユニットテスト

だいぶ前に書いた[React Redux Hooks API でユニットテスト](https://qiita.com/r_1105/items/ddcb1ac56df13a0c77ca)をたまに見てくれる方もいるようですが、[Sinon](https://github.com/sinonjs/sinon)に依存しているサンプルのため、ここでは[Jest](https://jestjs.io/)で解決する例を取り上げてみます。
また、`useSelector`を複数回実行するコンポーネントをテストしたいといったケースにどう対応するかも紹介してみます。

## 前提

- Jest: 24.9.0
- Enzyme: 3.11.0

今回テストするコンポーネントはこちらです。

```tsx:Counter.tsx
import React, { FC, useCallback } from 'react'
import { useSelector, useDispatch } from 'react-redux'
import { RootState } from 'path/to/store'
import { increment } from 'path/to/actions'

export type CounterSelectedState = number

const Counter: FC = () => {
  const count = useSelector<RootState, CounterSelectedState>(
    ({ counter }) => counter,
  )
  const dispatch = useDispatch()
  const handleClick = useCallback(() => {
    dispatch(increment())
  }, [dispatch])

  return (
    <div>
      <span>{count}</span>
      <button onClick={handleClick}>+1</button>
    </div>
  )
}

export default Counter
```

※ `CounterSelectedState` でエイリアスを作っているのは、後でテストを書く際に使うためですが、必要なければなくてもいいです。

## Jestを使ってテストを書く

まずテストしたいことを書いてみましょう。

```tsx:Counter.spec.tsx
import React from 'react'
import { shallow } from 'enzyme'
import Counter from './Counter'
import { increment } from 'path/to/actions'

describe('<Counter />', () => {
  it('dispatches increment action', () => {
    const wrapper = shallow(<Counter />)
    wrapper.find('button').simulate('click')

    expect(wrapper.find('span').text()).toBe('10')
    expect(increment).toBeCalledTimes(1)
  })
})
```

このままでは当然まだ動きません。

### useSelector, useDispatchのスタブ

`useSelector`と`useDispatch`のスタブを用意します。


```diff
import React from 'react'
import { shallow } from 'enzyme'
+ import Counter, { CounterSelectedState } from './Counter'
import { increment } from 'path/to/actions'
+ import { useSelector, useDispatch } from 'react-redux'


+ jest.mock('path/to/actions')
+ jest.mock('react-redux')
+ const useSelectorMock = useSelector as jest.Mock<CounterSelectedState>
+ const useDispatchMock = useDispatch as jest.Mock

describe('<Counter />', () => {
+   beforeEach(() => {
+     useSelectorMock.mockReturnValue(10)
+     useDispatchMock.mockReturnValue(jest.fn())
+   })
+   afterEach(() => {
+     jest.resetAllMocks()
+   })

  it('dispatches increment action', () => {
    const wrapper = shallow(<Counter />)
    wrapper.find('button').simulate('click')

    expect(wrapper.find('span').text()).toBe('10')
    expect(increment).toBeCalledTimes(1)
  })
})

```

Jestでは、[jest.mock](https://jestjs.io/docs/ja/jest-object#jestmockmodulename-factory-options)でモジュールを自動モックすることができます（第2引数にファクトリを指定することができますが、今回は各テストで異なる値を返したい場合に対応するため、別のアプローチをとります）。

```ts
jest.mock('react-redux')
```

TypeScriptでテストを書いていると、`mockReturnValue`が`useSelector`から生えていないので、
力技ですが下記のようにダウンキャストします。
この際、型を`jest.Mock<CounterSelectedState>`とすることで、返り値の型を`CounterSelectedState`として指定することができます。

```ts
const useSelectorMock = useSelector as jest.Mock<CounterSelectedState>
const useDispatchMock = useDispatch as jest.Mock
```

また今回は、アクションクリエイタが呼ばれたことをテストしているため、合わせて、下記のようにアクションクリエイターに対してもモックしておきます（`expect(increment).toBeCalledTimes(1)` の部分です）。

```ts
jest.mock('path/to/actions')
```

そして、各テスト前に `useSelector` が10を返すように設定し、各テスト後にモックをリセットしておきましょう。

```ts
beforeEach(() => {
  useSelectorMock.mockReturnValue(10)
  useDispatchMock.mockReturnValue(jest.fn())
})

afterEach(() => {
  jest.resetAllMocks()
})
```

※ `jest.Mock<CounterSelectedState>`としたことで、`useSelectorMock.mockReturnValue`では、`CounterSelectedState`を返すように補完が効いてくれます。
今回は`number`なので恩恵はあまり感じませんが、オブジェクトを返す場合などに有効です。

これでテストが通ります。

## 各テストで異なる値を取りたい

上記では1つのテストしか存在しておらず、`useSelectorMock.mockReturnValue(10)`でカウントが10固定になっていますが、異なる値を扱いたい場合が往々にして存在します。
その場合はテスト内部で直接値を指定できます。

```tsx
  it('has count as 11', () => {
    useSelectorMock.mockReturnValue(11)

    const wrapper = shallow(<Counter />)
    expect(wrapper.find('span').text()).toBe('11')
  })
```

## 複数回の`useSelector`に対応する

複数の値をストアから参照したい場合に、一度の`useSelector`で複数の値をまとめたオブジェクトとして返すのではなく、
**`useSelector`を複数回実行し、小さな単位で取得することが推奨されています**。
FYI: [Call useSelector Multiple Times in Function Components](https://redux.js.org/style-guide/style-guide/#call-useselector-multiple-times-in-function-components)
つまり、先程のコンポーネントで別のStateの値（ここでは`something.isActive`を例とします）を使う際に、

```tsx
export type CounterSelectedState = {
  count: number
  isActive: boolean
}

const count = useSelector<RootState, CounterSelectedState>(
  ({ counter, something }) => ({
    count: counter,
    something: something.isActive,
  }),
)
```

ではなく、


```tsx
export type CounterSelectedState = number

const count = useSelector<RootState, CounterSelectedState>(
  ({ counter }) => counter,
)
const isActive = useSelector<RootState, boolean>(
  ({ something }) => something.isActive,
)
```

が望ましいということです。
前者であれば、上記の`useSelectorMock.returnValue`で対応可能ですが、
後者は複数回`useSelector`を実行しているため、異なるアプローチが必要となります。

色々と対応方法はありますが、簡単にいくつか紹介しておきます。

### 方法1: `mockReturnValueOnce`を使う

Jestのモックには`mockReturnValueOnce`メソッドが存在しており、呼び出し回数に応じて返す値をコントロールすることができます。ただし、`useSelector`を呼び出す順番に依存するため、扱いづらさは残ります。

```tsx
test('mockReturnValueOnceを使った場合', () => {
  useSelectorMock
    .mockReturnValueOnce(11)
    .mockReturnValueOnce(true)
})
```

※ ライフサイクルが絡む場合のテストなどにおいて、Enzymeの`shallow`ではなく`mount`をつかってテストを行いたい場合には、`mockReturnValueOnce`をうまく使うことでHooksの返り値をコントロールすることができますので、こういったユースケースには有効かもしれません。

### 方法2: `mockImplementation`を使う

このケースでは、`re-ducks`などの設計パターンに見られる、Stateから値を取得するための`selector`と呼ばれる関数を用います。上記の例でいえば、`useSelector`に渡しているコールバック関数がそれですが、`mockImplementation`で参照を比較するため、関数として定義します。

ここでは下記のような実装を想定します。

```tsx
export function getCount({ counter }: RootState): number {
  return counter
}

export function isSomethingActive({ something }): boolean {
  return something.isActive
}
```

```tsx
const count = useSelector<RootState, CounterSelectedState>(getCount)
const isActive = useSelector<RootState, boolean>(isSomethingActive)
```

テストでは、`useSelector.mockImplementation`を使い、引数に応じて返り値をコントロールすることができます。
つまり、useSelectorに渡された引数の関数の参照が一致する場合に、それに応じた値を返すということになります。

```tsx
beforeEach(() => {
  useSelectorMock.mockImplementation(selector => {
    if (selector === getCount) {
      return 10
    } else if (selector = isSomethingActive) {
      return true
    }
  })
})
```

## 方法3: カスタムフックを定義する

これが一番ラクです。
`useSelectorMock`は使わずに、`useSelector`を内部で呼び出すカスタムフックを定義し、
テストではこのカスタムフックのスタブを用意することで対応します。

```tsx
export function useCount() {
  return useSelector<RootState, number>(({ counter }) => counter)
}

export function useIsActive() {
  return useSelector<RootState, boolean>(({ something }) => something.isActive)
}
```

```tsx
jest.mock('path/to/hooks')
const useCountMock = useCount as jest.Mock<number>
const useIsActiveMock = useIsActive as jest.Mock<boolean>

beforeEach(() => {
  useCountMock.mockReturnValue(10)
  useIsActive.mockReturnValue(true)
  useDispatchMock.mockReturnValue(jest.fn())
})
```

---

かなりシンプルな例ではありますが一助になれば幸いです。
他にも色々と方法がありますが、「おすすめのこんなやり方あるよ」という方はぜひ共有ください:bow: