ロゴ
clock2019.12.20

React Hooks Testing

DEV

この記事は「DMMグループ Advent Calendar 2019」21日目の記事です。

フロントエンドエンジニアは数年前に比べ、技術の幅が広がったことにより担当するべき作業も増えてきています。

テストもその中の一つです。フロントエンドのテストは実装に対するユニットテストだけでなく、コンポーネントのユニットテスト、e2eテスト、Visual Regression Testと考えることが多くあります。

その中で以前書いた「Hooks時代のユニットテスト」というReact Hooksのテストに関する記事が結構反響があり、数ヶ月テストを運用してみて、個人的なReact Hooksにおけるユニットテストをどうすべきかを考えたので、Testing Library・Jestを使う方法で紹介します。

TypeScriptがデファクトになりつつあるので、今回はTypeScriptで書いてるよ!

React DOM

まずはHooksでのDOMのテストです。

Reactでのテストではenzymeが有名ですが、今回は使いません。 enzymeはHooksと相性が悪く、Shallow Render時にuseEffectを呼び出すことができません。

そのことについてはenzymeのissueでも話し合われています。 「useEffect not called when the component is shallow renderered #2086

今回は@testing-library/reactを使用します。

React Testing Library

React Testing Libraryはユーザがコンポーネントを使用するようにテストが設計されreact-dom/test-utilsをラップしたライブラリです。

enzymeのように実装をテストするライブラリに比べるとシンプルに書くことができ、コンポーネントのテストという観点でいうと個人的にはenzymeよりReact Testing Libraryの方が合っているように感じます。

ユニットテスト

テストを行うために下記のFuga.tsxというコンポーネントを用意しました。

Fuga.tsx
import React, { CSSProperties } from 'react';

type Props = {
  text: string;
  onClick: () => void;
  style?: CSSProperties;
}

const Fuga: React.FC<Props> = ({ text, style, onClick }) => {

  return (
    <div style={style} onClick={onClick} data-testid="fuga-wrapper"> 
      <p data-testid="fuga-text">Fuga text: { text }</p>
    </div>
  )
}

export default Fuga;

data-testidはテストをする際に使用するデータ属性です。 React Testing Libraryのドキュメントではdata-testidは実際のユーザの使用方法とは異なるため、なるべく使用するのは避けるように書かれています。

ライブラリ追加

次に実際にテストを書くためにライブラリを追加します。

yarn add -D @testing-library/react @testing-library/jest-dom

@testing-library/jest-domはjestのカスタムマッチャーでDOMテストをより読みやすく簡単に行うことができます。

テストコード

次に実際にテストを書いていきます。

Fuga.spec.tsx
import React from 'react';
import '@testing-library/jest-dom/extend-expect'
import { render, fireEvent } from '@testing-library/react'

import Fuga from './Fuga'

describe('<Fuga>', () => {
    const props = {
        text: 'ふがふが',
        onClick: jest.fn(),
    }
    it('テキストが表示されているか', () => {
        const { getByTestId } = render(<Fuga { ...props } />)

        expect(getByTestId('fuga-text')).toHaveTextContent('Fuga text: ふがふが')
     })

     it('スタイルが適用されているか', () => {
         const attrs = { ...props, style: { fontSize: '24px' } }
         const { getByTestId } = render(<Fuga { ...attrs } />)

         expect(getByTestId('fuga-wrapper')).toHaveStyle('font-size: 24px')
     })

     it('イベントハンドラが呼ばれるか', () => {
        const { getByTestId } = render(<Fuga { ...props } />)

        fireEvent.click(getByTestId('fuga-wrapper'))

        expect(props.onClick).toHaveBeenCalled()
     })
})

getByTestIdで指定しているのが、componentに書いたdata-testid属性です。

また@testing-library/jest-domを使用しているのでtoHaveTextContenttoHaveStyleなどのカスタムマッチャーで容易にテストすることができます。

React Custom Hooks

Custom HookとはHooks APIを組み合わせることで、独自のHooksを定義することができるものです。

前回の記事ではテストをするためにテスト用のコンポーネントでラップする方法をとりましたが、今回はTesting Libraryが出している@testing-library/react-hooksを使います。

React Hooks Testing Library

Custom Hookは普通のJavaScript関数に見えますが、HooksはReactコンポーネントの内部でしか動作することできないので、テストをするためにコンポーネントを書く必要がありますが、そんな煩わしさを解消するテストユーティリティです。

ユニットテスト

前回同様useInputというinput系の処理を良しなにしてくれるHooksを用意しました

useInput.ts
import React, { useState } from 'react'

const useInput = (initialValue = '') => {
    const [value, setValue] = useState(initialValue)

    return {
        value,
        onChange: (e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value)
    }
}

export default useInput

ライブラリ追加

@testing-library/react-hooksreact-test-rendererがPeer Dependenciesとなっているので追加する必要があります。この際使用しているReactのバージョンに合わせる必要があリます。

yarn add -D @testing-library/react-hooks react-test-renderer@^16.12.0

テストコード

useInputのユニットテストを書いていきます。

useInput.spec.tsx
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import { renderHook, act } from '@testing-library/react-hooks'

import useInput from './useInput'

describe('useInput', () => {
    it('initial value', () => {
        const { result } = renderHook(() => useInput('hello'))

        expect(result.current.value).toBe('hello')
    })

    it('onChange', () => {
        const { result } = renderHook(() => useInput('hello'))

        const { container } = render(<input type="text" { ...result.current } data-testid="input"  />)

        const input = container.querySelector('input')

        act(() => {
            fireEvent.change(input!, {target: { value: 'kuma'}})
        })

        expect(result.current.value).toBe('kuma')
    })
})

renderHook関数がCustomHookをモックコンポーネントでラップしてくれます。

また今回はinput関連のhooksだったため@testing-library/reactと併用し、fireEventでイベントを発火させることでテストをしています。

React Router Hooks

React Router HooksはReactRouter v5.1から対応したフックです。

このフックによってwithRouterを使用せずlocationやhistoryを使うといったこれまでできなかったことができるようになりました。

しかし、こういった値がHOCを使ったPropsで渡って来なくなったため、下記のようなコンポーネントの場合ユニットテストをするとRouterコンポーネントでラップされていないため、エラーが出るようになってしまいました。

Pero.tsx
import React from 'react'
import { useHistory } from 'react-router-dom'

const Pero: React.FC = () => {
    const history = useHistory()

    return (
        <>
            <p>テキストダヨ</p>
            <button onClick={() => history.push('/')} />
        </>
    )
}

export default Pero

解決策

こういったエラーを解決するために、renderWithRouterというRouterコンポーネントでラップするユーティリティを用意します。

render.tsx
import React from 'react'
import { Router } from 'react-router-dom'
import { createMemoryHistory, MemoryHistory } from 'history'
import { render } from '@testing-library/react'

type Option = {
  history?: MemoryHistory;
}

export const renderWithRouter = (ui: React.ReactElement, option?: Option) => {
  const Wrapper: React.FC = ({ children }) => (
    <Router history={option?.history ?? createMemoryHistory()}>{ children }</Router>
  )
  return { ...render(ui, { wrapper: Wrapper }) }
}

テストコード

実際にReact Router Hooksを使ったコンポーネントのテストコードを書いていきます。

Pero.spec.tsx
import React from 'react';
import '@testing-library/jest-dom/extend-expect'
import { renderWithRouter } from './render'

import Pero from './Pero'

describe('<Pero>', () => {
    it('テキストが表示されているか', () => {
        const { container } = renderWithRouter(<Pero  />)

        const p = container.querySelector('p')

        expect(p).toHaveTextContent('テキストダヨ')
     })
})

先程用意したrenderWithRouterを使うことでテストができるようになりました。

Redux Hooks

Redux Hooksはv7.1.0から追加されたconnect()を使わずにコンポーネントでdispatch、stateを使うことができるフックです。

前回同様、簡単なカウンターアプリを用意しました。

store/counter.ts
import { Action } from 'redux'

const enum Type {
    INCREMENT = 'INCREMENT'
}

type CounterActions = IncrementAction

type IncrementAction = Action<Type.INCREMENT>

export const actions = {
    increment: (): IncrementAction => ({ type: Type.INCREMENT })
}

export type CounterState = {
    count: number
}

const initialState: CounterState = {
    count: 0
}

export function reducer(state = initialState, action: CounterActions) {
    switch (action.type) {
        case Type.INCREMENT: {
            return { count: state.count + 1 }
        }
        default:
            return state
    }
}
Conut.tsx
import React from 'react';
import { useDispatch, useSelector } from "react-redux";
import  { State } from './store';
import  { actions } from './store/counter';

const counterSelector = (state: State) => ({
    count: state.count,
 });

const Count: React.FC = () => {
  const dispatch = useDispatch();
  const { count } = useSelector(counterSelector);

  return (
    <div>
      <p>{ count }</p>
      <button onClick={() => dispatch(actions.increment())}>+</button>
    </div>
  )
}

export default Count;

テストコード

Redux Hooksを使用したコンポーネントは必然的にStoreと密結合になってしまいユニットテストではStoreのモックを用意しないとエラーが発生してしまいます。

前回の記事でも書きましたが、Storeを用意するとユニットテストではなく、インテグレーションテストになってしまいます。

なので今回はjest.mockを使いuseSelectoruseDispatchのモックを作り対処します。

これは前回の記事で使用したsinonでスタブを作ることでも実現できます。

Count.spec.tsx
import React from 'react';
import { render, fireEvent } from '@testing-library/react'

import Count from './Count';

const mockDispatch = jest.fn()

jest.mock('react-redux', () => ({
    useSelector: jest.fn(fn => fn()),
    useDispatch: () => mockDispatch,
}))

describe('<Count>', () => {

    afterEach(() => {
        jest.restoreAllMocks()
    })
    
    it('Redux Hooks', () => {
        const { container } = render(<Count />)

        const button = container.querySelector('button')

        fireEvent.click(button!)

        expect(mockDispatch).toBeCalled();
    })
});

これでRedux Hookを使っているコンポーネントでもユニットテストをすることができるようになりました。

おわりに

React Router・ReduxのHooksが登場し、よりHooksが使いやすくなりました。 これを機にrecomposeやClassコンポーネントから脱却して、どんどんテスト書いていきましょう!!

個人的にはコンポーネントのユニットテストは頑張らなくて良いと思っているので、ほどほどに...

\ SHARE /

プロフィール画像
くま

フロントエンドエンジニア👨‍💻サーバサイドもちょっと書けるよ

イケてるエンジニア目指してるよ


最近は昆布と旅行雑誌🏄‍♀️に絶賛ハマり中