追記: 2020-01-14
たまにこちらの記事を見てくれている方もいるようですが、Sinonに依存しているサンプルのため、新たにReact Redux Hooks API + Jest + TypeScript でユニットテストにSinon依存なしverを書いておきましたので参考までに
React Redux Hooks APIが利用可能となりました。
また、型定義も@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
で行うような処理をカスタムフックに実装します。
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コンポーネントを実装します。
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では、関数オブジェクトをそのままスタブできないようです(?)。そのため、スタブを用いたい場合はオブジェクトのメソッドとして定義する必要があります。
まずはスタブを使わないで実行してみます。
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メソッドでダミーの返り値を定義
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 Component
とPresentational Component
を分離することも可能です。
いずれにせよ、テストは書いていきましょう。