ReactコンポーネントのDOMをユニットテストする

2020/11/01に公開

React コンポーネントのDOM構造を自動テスティングをするとしたら、スナップショットテストするのが一般的です。

スナップショットテストは、構造に変化があれば警告するという仕組みであり、その中身はあまり意識しません。

ところが、特定の事情により、どうしてもDOM構造そのものを保証しなければいけない事例のときには、DOM構造をユニットテストしたくなるんですが、そういうことをやってる事例を見つけられなかったので、記事にしました。

スナップショットテスト

さて、本題のテストを記述する前に React コンポーネントのスナップショットテストのおさらいです。

import React from 'react'
import { create } from 'react-test-renderer'

test('hoge-', () => {
  const tree = create(<div>ほげー</div>).toJSON()
  expect(tree).toMatchSnapshot()
})

詳しくは、Jest 公式の Snapshot Testing · Jest をご覧ください。

ユニットテスト

では、これをユニットテストしましょうか。

test('hoge-', () => {
  const tree = create(<div>ほげー</div>).toJSON()
  expect(tree).toEqual(<div>ほげー</div>)
})

これを実行すると、

    Expected: <div>ほげー</div>
    Received: <div>ほげー</div>

という風になりますが、見た目からは何が違うのかは分かりません。まぁ、JSXElementと、なぞのオブジェクトでは多分違うんでしょうね。その割には、どっちも同じ見た目に見えるのが罠っぽいです。

test('hoge-', () => {
  const tree = create(<div>ほげー</div>).toJSON()
  expect(tree).toEqual(create(<div>ほげー</div>).toJSON())
})

なら、これでどうでしょうか?どちらも react-test-renderer を使えば、同じモノになるはずです。実際、これは成功します。

さて、この自己言及的なテストはアレなので、より本物に近いようにコンポーネントでテストをします。

const Hoge = () => {
  return <div>ほげー</div>
}

test('hoge-', () => {
  const tree = create(<Hoge />).toJSON()
  expect(tree).toEqual(create(<div>ほげー</div>).toJSON())
})

はい。コンポーネント化しても問題なくテストが通ります。

ハンドラ込みのテストをする

さて、ユニットテストできるようになったので、イベントハンドラ込みのコンポーネントをテストします。

const Hoge = ({ onChange, value }) => {
  return <input type="text" onChange={onChange} value={value} />
}

test('hoge-', () => {
  const handleChange = () => {}
  const tree = create(<Hoge onChange={handleChange} value="hoge" />).toJSON()
  expect(tree).toEqual(
    create(<input type="text" onChange={() => {}} value="hoge" />).toJSON()
  )
})

このコードなんですが、エラーになります。

-   onChange={[Function onChange]}
+   onChange={[Function handleChange]}

やだ、細かい。

test('hoge-', () => {
  const handleChange = () => {}
  const tree = create(<Hoge onChange={handleChange} value="hoge" />).toJSON()
  expect(tree).toEqual(
    create(<input type="text" onChange={handleChange} value="hoge" />).toJSON()
  )
})

こうすると、エラーは出なくなりました。

では Hoge コンポーネントでハンドラを自前で設定している場合はどうなのでしょうか?

const Hoge = ({ onChange, value }) => {
  const handleChange = React.useCallback(
    ev => {
      onChange(ev.target.value)
    },
    [onChange]
  )
  return <input type="text" onChange={handleChange} value={value} />
}

test('hoge-', () => {
  const handleChange = () => {}
  const tree = create(<Hoge onChange={handleChange} value="hoge" />).toJSON()
  expect(tree).toEqual(
    create(<input type="text" onChange={handleChange} value="hoge" />).toJSON()
  )
})

これはエラーが出ます。

-   onChange={[Function handleChange]}
+   onChange={[Function anonymous]}

細かいわ!!!

では、名前を揃えればいけるか?

test('hoge-', () => {
  const anonymous = () => {}
  const tree = create(<Hoge onChange={anonymous} value="hoge" />).toJSON()
  expect(tree).toEqual(
    create(<input type="text" onChange={anonymous} value="hoge" />).toJSON()
  )
})

ダメです。

Expected: <input onChange={[Function anonymous]} type="text" value="hoge" />
Received: <input onChange={[Function anonymous]} type="text" value="hoge" />

オブジェクトの比較をしてるんでしょうね。名前が同じでも生成が別なので。

さて、こうなると、今のやり方ではテストできません。

ところが、先ほどのスナップショットテストで出来上がった、Snapshotを見ると、

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

exports[`hoge- 1`] = `
<input
  onChange={[Function]}
  type="text"
  value="hoge"
/>
`;

onChange の中身は [Function] になってるじゃないですか。まぁ、jest 本体を真面目に追いかけて、というのもあれなので、もっと手っ取り早い手段に訴えます。

test('hoge-', () => {
  const anonymous = () => {}
  const tree = JSON.stringify(
    create(<Hoge onChange={anonymous} value="hoge" />).toJSON()
  )
  expect(tree).toEqual(
    JSON.stringify(
      create(<input type="text" onChange={anonymous} value="hoge" />).toJSON()
    )
  )
})

JSON.stringify したものを比較するとイベントハンドラのコールバックオブジェクトの違いは無視されるようになります。

では、エラーがでるようなときは、どのように表示されるのでしょうか。

  expect(tree).toEqual(
    JSON.stringify(
      create(<input type="text" onChange={anonymous} value="fuga" />).toJSON()
    )
  )

というふうに、一部を書き換えて、エラーが出るようにしてみました。

- {"type":"input","props":{"type":"text","value":"fuga"},"children":null}
+ {"type":"input","props":{"type":"text","value":"hoge"},"children":null}

分かりづらいわ!

せめて、もう少し見やすくしましょう。JSON.stringify は第三引数を追加すると、いい感じにインデントされます。

test('hoge-', () => {
  const anonymous = () => {}
  const tree = JSON.stringify(
    create(<Hoge onChange={anonymous} value="hoge" />).toJSON(),
    null,
    '  '
  )
  expect(tree).toEqual(
    JSON.stringify(
      create(<input type="text" onChange={anonymous} value="fuga" />).toJSON(),
      null,
      '  '
    )
  )
})

この場合、

  ● hoge-

    expect(received).toEqual(expected)

    Difference:

    - Expected
    + Received

      {
        "type": "input",
        "props": {
          "type": "text",
    -     "value": "fuga"
    +     "value": "hoge"
        },
        "children": null
      }

というエラーになります。JSX で比較できるわけじゃないので、まだ分かりづらいですが、まだ、value が違ってるということは分かります。

この難点は、JSXツリーが、どういう JSON になるか?を理解していないといけないことにありますが、まぁなんとか許容範囲かと思います。

そもそも、ReactコンポーネントのDOM構造をユニットテストするという時点でレアケースだと思うので、これ以上の労力を割くのはオーバーエンジニアリングになりそうです。

まとめ

Reactコンポーネントをユニットテストしたい場合、コールバックのことも考えると react-test-renderer の結果を JSON.stringify したものを比較すると良くて、JSON.stringify(object, null, ' ') のように指定しておくと、一応見やすい diff が取れます。

Discussion