きみはねこみたいなにゃんにゃんなまほう

ねこもスクリプトをかくなり

React のテストは react-dom/test-utils から @testing-library/react に進むと理解しやすそう

lightbulbcat.hatenablog.com

の続きです。React のテスト用ライブラリを色々試した結果 react-dom/test-utils をちょっと触った後で、 @testing-library/react を導入するといいんじゃないかなと思いました。学習順序的な話です。

react-dom/test-utils は React のテストを素朴で馴染みの深いDOM・イベント操作で行えることを確認できます。 ただし記述量が多くボイラープレート的なコードも必要にります。 そこで @testing-library/react を導入するとテストコードが簡潔になります。

@testing-library は高機能ですがその分どのAPIをどう使うべきかで初めは迷います。 react-dom/test-utils を利用した手法を知っていると「とりあえず element だけ捕まえてあとは低レベルな操作でなんとかする」という選択肢がとれるので、初めに react-dom/test-utils を触っておくのは有用だと思いました。

今回は Jest 上で試しました。

比較してみる

スナップショットテストとイベントの発火を伴う簡単なテストを書いて両者のコードを比較していきます。

テスト対象のコンポーネント

コンポーネント内の要素を取得する方法も確認したかったので div の子要素にカウントアップボタンを持つコンポーネントを用意しました。

import React, { useState } from 'react'

function CountButton() {
  const [count, setCount] = useState(0)
  const increment = () => { setCount(s => s + 1) }
  return <button onClick={increment}>{count}</button>
}

export default function MyComponent() {
  return (
    <div>
      <CountButton />
    </div>
  )
}

react-dom/test-utils

初めは react-dom/test-utils です。

import React from 'react'
import { render } from 'react-dom'
import { act } from 'react-dom/test-utils'
import MyComponent from './MyComponent'

// 使い回すコンテナ要素
let container: HTMLDivElement

beforeEach(() => {
  container = document.createElement('div');
  document.body.appendChild(container);
});

afterEach(() => {
  document.body.removeChild(container);
});

test('MyComponent renders fine :)', () => {
  render(<MyComponent />, container)
  expect(container).toMatchSnapshot()
})

test('MyComponent works fine :)', () => {
  render(<MyComponent />, container)

  const button = container.querySelector('button')
  if (!button) throw new Error('button not found')

  expect(button.textContent).toBe('0')
  act(() => {
    button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
  })
  expect(button.textContent).toBe('1')
})

DOMのAPIと同様 container.querySelector で子要素であるボタンを取得できます。 act 内での dispatchEvent でイベントをディスパッチすることでクリックイベントを発火します。 普段こういうAPIは使わないので、イベントの発火ってこういう風にするんだなとかちょっと新鮮でした。 基本的に document.body 以下のDOMを操作しているだけのコードに見えるので、 React のテストと言えど素朴な感じで書けることがわかりますね。

Node.js 上で動いているはずなので document へのアクセスはどうやっているのか若干お膳立てされている感じがありますが、多分 Jest が jsdom なんかをいい感じにセットアップしてくれているのでしょう。

スナップショットも普通に動きました。

// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`MyComponent renders fine :) 1`] = `
<div>
  <div>
    <button>
      0
    </button>
  </div>
</div>
`;

react-dom だけでこれが得られるなら react-test-renderer は特に無くてもいい気がしましたが、shallow レンダリングしたい場合は必要になるとか、そんな感じでしょうか。

@testing-library/react

次に @testing-library/react で同様の処理を書いていきます。

import React from 'react'
import { render, cleanup, fireEvent } from '@testing-library/react'
import MyComponent from './MyComponent'

beforeEach(cleanup)

test('MyComponent renders fine :)', () => {
  const { container } = render(<MyComponent />)
  expect(container).toMatchSnapshot()
})

test('MyComponent works fine :)', () => {
  const { container } = render(<MyComponent />)
  
  const button = container.querySelector('button')
  if (!button) throw new Error('button not found')

  expect(button.textContent).toBe('0')
  fireEvent.click(button)
  expect(button.textContent).toBe('1')
})

だいぶ簡潔に書けていますね。

セットアップの

let container: HTMLElement

beforeEach(() => {
  container = document.createElement('div');
  document.body.appendChild(container);
});

afterEach(() => {
  document.body.removeChild(container);
});

が省けているのが楽です。テストファイルが多くなるとこの差は大きくなると思います。 クリーンナップ処理は beforeEach(cleanup) で行います。

container 要素を作ってそこにテスト対象のコンポーネントレンダリングするのは同様なのですが render 関数にその処理が含まれていて返り値で container 要素が帰ってくるのが楽ですね。 型も react-dom/test-utils の例と同様 HTMLElement なのでスナップショットを撮ったり querySelector を生やせたりと同じ使い方ができます。

他にイベント発火も

  act(() => {
    button.dispatchEvent(new MouseEvent('click', { bubbles: true }))
  })

から

fireEvent.click(button)

と楽になりました。テストは簡潔に書けるに越したことはないですね。

react-dom/test-utils のコードを書いた後だと、だいたい裏でどんな処理がなされているかが想像できて良いです。