😺

Reactのユニットテスト2021

2021/02/11に公開

React でユニットテストをするときのベストプラクティスはいつも悩むのですが、とりあえず 2021 年 2 月時点では、こうかなーというのをまとめてみます。

まずテストランナーは jest で確定です。ここで悩む要素はまずありません。

では、React のテストをどうやるか?です。

  1. 公式の react-dom/test-utils を使う
  2. 公式の react-test-renderer を使う
  3. @testing-library/react を使う

選択肢としてはこの三種類が有名なところでしょう。

公式という響きはとても魅力的ですが、実は公式ドキュメントから「ボイラープレートを減らすため、エンドユーザが使うのと同じ形でコンポーネントを使ってテストが記述できるように設計されている、React Testing Library の利用をお勧めします。」という形で、@testing-library/react が推奨されてもいます。

react-test-renderer を使った参考資料は割とありますが、これはすでに @testing-lirary/react に置き換えられてます(renamed?)。

同様に React Hooks のテストも、公式が説明している手段では、めちゃくちゃ面倒です。@testing-library/react-hooks がとても簡単です。

そういった理由により、現時点で採用するなら @testing-library/react 一択かなーと思っております。とりあえず @testing-library ファミリー使っておけば間違いはないでしょう。

@testing-library/react を使う

# npm
npm i -D @testing-library/react

# yarn
yarn add -D @testing-library/react

@testing-library/react の基本は render 関数です。

/**
 * @jest-environment jsdom
 */
import React from 'react'
import { render } from '@testing-library/react'
import { Hoge } from '.'

test('Hoge', () => {
  const renderResult = render(<Hoge />)
  // expect...
})

render の戻り値を使って expect を記述します。戻り値は RenderResult 型です。詳しくは https://testing-library.com/docs/react-testing-library/api/#render-result を参照してください。

@testing-library/jest-dom 拡張マッチャー

@testing-library/jest-dom によるカスタムマッチャーも便利です。

# npm
npm i -D @testing-library/jest-dom @types/testing-library__jest-dom

# yarn
yarn add -D @testing-library/jest-dom @types/testing-library__jest-dom

詳しくは https://github.com/testing-library/jest-dom を参照してください。

snapshot test

test('snapshot testing', () => {
  const { asFragment } = render(<Hoge />)
  expect(asFragment()).toMatchSnapshot()
})

DOM 構造のスナップショットをとって、変化したらエラーになるタイプのテストです。変化が妥当だと判断できるなら jest -u でスナップショットのアップデートをします。

文字列マッチング

test('matching text', () => {
  const { container } = render(<Hoge />)
  expect(container.innerHTML).toMatch('hoge')
})

吐き出される HTML に hoge という文字列が含まれていれば OK というテストです。toMatch マッチャーは、テキストもしくは正規表現が利用できます。HTML に文字列マッチングをするのでいささか乱暴です。

@testing-library/jest-dom 拡張マッチャーを使っている場合は、.toHaveTextContent でそのものずばりテキストコンテンツのテストができます。

expect(container).toHaveTextContent('hoge')

クラス名

test('matching text', () => {
  const { container } = render(<Hoge />)
  expect(container.getElementsByClassName('fuga').length).toEqual(1)
})

クラス名 fuga を持つエレメントが 1 つあれば OK というテストです。1 つとは限らない場合は .toBeGreaterThan(0) のように別のマッチャーを使いましょう。

jest-dom 拡張マッチャーを使っていれば、toHaveClass マッチャーを使うという方法もあります。

test('Hoge', () => {
  const { getByText } = render(<Hoge />)
  expect(getByText('hoge')).toHaveClass('fuga')
})

getByText('hoge') により hoge というテキストを持つエレメントを取得しそのクラス名に fuga が含まれているかテストしています。より精密なテストを書く場合には考慮に入れてもいいかもしれません。

イベントハンドラのテスト

たとえば Button というコンポーネントを用意して onClick ハンドラをテストするとします。

import { render, fireEvent } from '@testing-library/react'

test('onClick', () => {
  const handleClick = jest.fn()
  const { getByText } = render(
    <Button onClick={() => handleClick()}>hoge</Button>,
  )
  fireEvent.click(getByText('hoge'))
  expect(handleClick).toHaveBeenCalledTimes(1)
})

まず jest.fn() でモック関数を生成し、onClick の引数に渡します。

次に @testing-libraryfireEvent オブジェクトを使ってイベントを発火します。クリックイベントを発生させる場合は fireEvent.click(getByText('hoge')) のようにします。

そのあと expect(handleClick).toHaveBeenCalledTimes(1) のようにモック関数が 1 回呼び出されたことを確認します。

<input type="text" onChange={...} /> をテストするような場合は、第 2 引数にイベントを指定する必要があるでしょう。

文字入力コンポーネントを作るときは、扱いづらいイベントを直接触るよりも (text: string) => void のような直接文字列を受け取れるハンドラを書くことも多いでしょう。

fireEvent.input(element, { target: { value: 'hoge' } })
expect(handleChange0).toHaveBeenCalledTimes(1)
expect(handleChange0.mock.calls[0][0]).toEqual('hoge')

この場合、モック関数の呼び出し回数が 1 回で、結果としてハンドラが hoge というテキストを受け取ったというテストが可能です。

Next.js のユニットテスト

最近は Next.js が当たり前になりました。

Next.js の next/link だの next/router だのを使っている場合、どうしてもこれらをモックする必要があります。

jest.mock('next/link', () => {
  const Link = ({
    href,
    children,
  }: {
    href: string
    children: string
  }): JSX.Element => {
    return <a href={href}>{children}</a>
  }
  return Link
})

ここではいったん <a> にしていますがべつに <hoge> でもなんでもいいです。どうせこれをもとに実際に HTML として動かすわけではないからです。

テスト対象で <Link> がちゃんと <a> に展開されていることさえ確認できれば OK です。

expect(container.innerHTML).toMatch('<a href="http://example.com">hoge</a>')

Link の子要素が単純にならないケースなら '<a href="http://example.com">' とだけマッチすればいいでしょう。

Discussion