35
21

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 + Jest + TypeScript でユニットテスト

Last updated at Posted at 2020-01-14

だいぶ前に書いたReact Redux Hooks API でユニットテストをたまに見てくれる方もいるようですが、Sinonに依存しているサンプルのため、ここではJestで解決する例を取り上げてみます。
また、useSelectorを複数回実行するコンポーネントをテストしたいといったケースにどう対応するかも紹介してみます。

前提

  • Jest: 24.9.0
  • Enzyme: 3.11.0

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

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を使ってテストを書く

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

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のスタブ

useSelectoruseDispatchのスタブを用意します。

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でモジュールを自動モックすることができます(第2引数にファクトリを指定することができますが、今回は各テストで異なる値を返したい場合に対応するため、別のアプローチをとります)。

jest.mock('react-redux')

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

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

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

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

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

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

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

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

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

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

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

  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
つまり、先程のコンポーネントで別のStateの値(ここではsomething.isActiveを例とします)を使う際に、

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

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

ではなく、

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を呼び出す順番に依存するため、扱いづらさは残ります。

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

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

方法2: mockImplementationを使う

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

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

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

export function isSomethingActive({ something }): boolean {
  return something.isActive
}
const count = useSelector<RootState, CounterSelectedState>(getCount)
const isActive = useSelector<RootState, boolean>(isSomethingActive)

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

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

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

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

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

export function useIsActive() {
  return useSelector<RootState, boolean>(({ something }) => something.isActive)
}
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:

35
21
2

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
35
21

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?