だいぶ前に書いたReact Redux Hooks API でユニットテストをたまに見てくれる方もいるようですが、Sinonに依存しているサンプルのため、ここではJestで解決する例を取り上げてみます。
また、useSelector
を複数回実行するコンポーネントをテストしたいといったケースにどう対応するかも紹介してみます。
前提
- Jest: 24.9.0
- Enzyme: 3.11.0
今回テストするコンポーネントはこちらです。
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を使ってテストを書く
まずテストしたいことを書いてみましょう。
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
のスタブを用意します。
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でテストを書いていると、mockReturnValue
がuseSelector
から生えていないので、
力技ですが下記のようにダウンキャストします。
この際、型を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())
})
かなりシンプルな例ではありますが一助になれば幸いです。
他にも色々と方法がありますが、「おすすめのこんなやり方あるよ」という方はぜひ共有ください