Reactのテストライブラリを比較する - react-test-renderer, enzyme, react-testing-library

Reactのユニットテストでよく使われるライブラリに`enzyme`があります。
`enzyme`を紹介する記事を見ていると`shallow`が旨味の一つであり、簡単に単体テストが出来るとよく紹介されるのを見かけます。
Jestと用いられる、`enzyme`以外のライブラリもいくつかあるようなので、人気な理由とその使い方を備忘録がてら紹介しようと思います。

## Jest
まずはJestについて簡単に特徴を紹介します。
公式サイト：https://jestjs.io/docs/ja/tutorial-react

- facebook製のテストフレームワーク
- create-react-appでデフォルトで入っている
- React Component のユニットテストに使える
- Unit Tests ＋ Integration Tests
- アサーションライブラリ（Mocha + Chai → Jest）

普段下のようなテストをすることはありませんが、「こんな風に書くんだな〜」と参考程度にソースコードを紹介しておきます。

```javascript
test("Fake Test", () => {
  expect(true).toBeTruthy() // ← PASS
})

test("Fake Two", () => {
  expect(false).toBeTruthy() // ← FAILED
})
```

上はかなりシンプルなテストです。もう一つ例ですが、例えば以下のようなApp.jsを作ったとして、`add`関数のテストをしたい時は

```javascript
export const add = (x, y) => {
  return x + y
}
```

```javascript
import { add } from './App'

test('add', () => {
  expect(add(1, 2)).toBe(3) // PASS
  expect(add(5, 2)).toBe(7) // PASS
})
```

のようにしてテストすることができます。

`expect`を使うことになりますが、使える関数一覧は[👉こちらから](https://jestjs.io/docs/ja/expect) VSCodeで補完が効くので、便利です。

## react-test-renderer

Facebook公式のライブラリです。特徴は以下の通りです。

- React コンポーネントをピュアな JavaScript オブジェクトにレンダーすることができる React レンダラを提供
- スナップショットテストで使用
- 出力を走査して特定のノードを検索し、それらに対してアサーションを行うこともできます

公式サイト：https://www.npmjs.com/package/react-test-renderer
React公式ドキュメント「Test Renderer」：https://ja.reactjs.org/docs/test-renderer.html：

テストにスナップショットを使いたい時に登場するライブラリのイメージでしたが、enzymeのよう単体テストに使うことも出来るので、使ってみることにします。

### 検証したいテスト項目
検索窓で使用する`<InputSearchWord />`コンポーネントについてテストをしたいと思います。
テストするのは以下の通り、

- スナップショットテスト
- 1文字入力すると検索ボタンが非活性な状態からアクティブになる
- 30文字入力するとエラーメッセージは表示されない

主に、入力文字数と入力文字に関するエラーメッセージと、正しくボタンがアクティブ・非活性の表現が出来ているかテストしたいと思います。

### 実際のコード

#### スナップショットテスト
スナップショットは

- `const tree = renderer.create(<コンポーネント).toJSON()`
- `expect(tree).toMatchSnapshot()`
でスナップショットを作ることができます。テストを走らせると同時に「snapshots」フォルダが自動で作成され、`toMatchSnapshot()`した瞬間のDOMを生成してくれます。

```javascript
import React from "react"
import renderer from "react-test-renderer"
import Button from "@material-ui/core/Button"
import InputSearchWord from "components/InputSearchWord"

describe("<InputSearchWord />", () => {
  // ここにテストを書いていく
  // スナップショットテスト
  it("スナップショットテスト", () => {
    const props = {
      onSubmit: () => {},
    }
    const tree = renderer.create(<InputSearchWord {...props} />).toJSON()
    expect(tree).toMatchSnapshot()
  })
})
```

#### 1文字入力すると検索ボタンが非活性な状態からアクティブになる
次は実際にテストしたいコンポーネントを作成して、コンポーネント内にある`Button`タグ、テキストフィールドにそれぞれイベントを発火させたいと思います。
`Button`はMaterial-UIを使用しているため、上記のソースコードのようにimportしています。（`import Button from "@material-ui/core/Button"`）

- コンポーネントの生成は`renderer.create`
  - ①`const testRenderer = renderer.create(コンポーネント)`
  - ②`const testInstance = testRenderer.root`
- コンポーネント内にあるButtonの取得は`findByType`
- Buttonタグのpropsの値をテストしたい時は`props.props名`
- イベント発火は`.props.onChange({ target: { value: "ダミーテキスト" } })`

以上を踏まえ、このように書きます。

```javascript
it("1文字入力すると検索ボタンが非活性な状態からアクティブになる", () => {
  const props = {
    onSubmit: () => {},
  }
  const testRenderer = renderer.create(<InputSearchWord {...props} />)
  const testInstance = testRenderer.root
  // デフォルト表示
  // 検索エリア未入力時は検索ボタンは非活性
  expect(testInstance.findByType(Button).props.disabled).toBe(true)

  // 1文字入力する
  testInstance
    .findByType(TextField)
    .props.onChange({ target: { value: "a" } })
  // 検索エリア未入力時は検索ボタンがアクティブになる
  expect(testInstance.findByType(Button).props.disabled).toBe(false)
})
```

今回は入力文字数制限「30文字」とした時、エラーメッセージを表示させたいとします。

```javascript
// 31文字入力する
testInstance.findByType(TextField).props.onChange({
  target: {
    value: "1234567890123456789012345678901",
  },
})

// 文字数制限のエラーメッセージが表示される
expect(testInstance.findByType(TextField).props.errorMessage).toBe(
  "検索キーワードは30文字以下である必要があります"
)
```

`errorMessage`はpropsで渡す仕様にしていたので、上記のように書いています。
これでテストはALL PASSです！


## Enzyme

Airbnb製ののライブラリで、使い勝手が良いためか、Jest+enzymeの２コンビは人気なように見受けます。特徴は以下の通りです。

- Reactでのユニットテストを助けてくれるテストユーティリティツール
- airbnb製
- テスト用のバーチャルDOMを作ったり、DOM内の要素を探してくれる
- シンプルなイベントをシュミレートしてくれる
- 「shallow（浅い）」レンダリングで子コンポーネントをネストしている親コンポーネントを無視してレンダリングしてくれる → コンポーネント単体でテストができる

「Jest + enzyme」で紹介されている記事が多いため、問題にぶつかった時解決しやすいかもしれません。

早速使ってみます！

### 検証したいテスト項目
App.js内にモーダル用のコンポーネントをimportするとします。
モーダル表示はApp.jsのローカルなステート`open`の`true/false`で制御しており、モーダルのキャンセルボタンが正しく動作するかテストしていきます。

### 実際のコード

enzymeの`shallow`を紹介つもりでしたが、Material-UIが提供している`createShallow`で実装していきます。他の使い方はenzymeと同じです。

- `enzyme-adapter-react-16`の併用が必要
- `shallow`でコンポーネントを作成する
- コンポーネント内のエレメントは`find`で取得
- イベント発火は`.simulate("イベント")`
- `setProps`でステートの値をセットできる

```javascript
import React from "react"
import Enzyme from "enzyme"
import EnzymeAdapter from "enzyme-adapter-react-16"
import { createShallow } from "@material-ui/core/test-utils"
import App from "components/App"

Enzyme.configure({
  adapter: new EnzymeAdapter(),
  disableLifecycleMethods: true,
})

describe("<App />", () => {
  let shallow
  const props = {
    onClose: jest.fn(),
  }
  beforeAll(() => {
    shallow = createShallow({ dive: true })
  })

  it("「キャンセル」ボタンをクリックするとモーダルが消える", () => {
    const wrapper = shallow(<App {...props} />)
    // 最初にモーダルを開いた状態にする
    wrapper.setProps({ open: true })
    // 「キャンセル」ボタンのクリックシュミレート
    wrapper.find("[data-test='cancelButton']").simulate("click")
    // `close`functionが呼ばれているか確認
    expect(props.onClose).toHaveBeenCalled()
  })
})
```

こちらもテストはALL PASSでしたが、選んだコンポーネントが悪かったのか、実装にかなり悩まされました。。

## react-testing-library
こちらはenzymeと似たような使い方ができるライブラリです。`Jest + enzyme`よりは記事数が少ないものの、`Jest + react-testing-library`コンビは人気が出てきているように見受けます。
「ユーザーが実際に操作するイベントに沿ってテストする」を掲げたライブラリなので、まだ触って間もないですが使い勝手が良いです。

### 検証したいテスト項目
テキストフィールドコンポーネントを検証しようと思います。
このコンポーネントでは以下、

- ①テキスト入力エリア
- ②残り何文字入力できるか、残文字数表示

を表現するようになっています。


### 実際のコード

react-testing-libraryはざっくりと、以下のような実装が可能です。

- `"jest-dom/extend-expect"`の読み込みが必要
- `react-testing-library`はイベント処理の発火に`fireEvent`を提供している
- 同様にレンダリングしたDOMのunmountに`cleanup`
- スナップショットを使いたい時は[asFragment](https://testing-library.com/docs/react-testing-library/api#asfragment)

```javascript
import TextField from "components/TextField"
import "jest-dom/extend-expect"
import React from "react"
import { cleanup, fireEvent, render } from "react-testing-library"

describe("<TextField />", () => {
  afterEach(cleanup)

  it("スナップショットテスト", () => {
    const { asFragment } = render(<TextField />)

    // スナップショットテスト
    expect(asFragment()).toMatchSnapshot()
  })
})
```


#### テキストエリアに入力するイベントを発火させる
こちらのQiitaの記事が非常に参考にさせていただきました！
→ 「[フロントエンドでTDDを実践する（react-testing-libraryを使った実践編）](https://qiita.com/taneba/items/b21f5fee17eb593b30c8)」

こちらにある通り、本当なら`getByTestId`の多用は避けるべきとありますが、すみません、、まだ使い始めたばかりなのでこれしか使いませんでした。。
`getByTestId`を使う場合、テスト対象となるエレメントに`<div data-testid="foo">`のように`data-testid`を付与する必要があります。

- テスト対象のエレメントに`data-testid="DUMMY"`を付与（避けるのが良い）
- テストコードでまず
  - `const { getByTestId } = render(コンポーネント)`
  - `getByTestId`関数を使って取得
  - `const input = getByTestId("textField")`
- テキストの値をテストしたい時は`toHaveTextContent()`

```javascript
it("入力文字数が表示される", () => {
  const { getByTestId } = render(<TextField />)
  // 入力エリア
  const input = getByTestId("textField")
  // カウント数表示
  const count = getByTestId("counterNumber")

  // デフォルトは空であることを確認
  expect(input).toHaveTextContent("")

  // デフォルト表示"140"
  expect(count).toHaveTextContent("140")

  // テキストを1文字入力する
  input.innerHTML = "a"
  fireEvent.input(input)
  expect(input).toHaveTextContent(input.innerHTML)
  // カウント数が"140"から"139"に変更、且つ、エラーは表示されていない
  expect(count).toHaveTextContent("139")

  // テキストを200文字入力する
  input.innerHTML =
    "ここには200文字のテキストが入ります。ここには200文字のテキストが入ります。ここには200文字のテキストが入ります。ここには200文字のテキストが入ります。ここには200文字のテキストが入ります。ここには200文字のテキストが入ります。ここには200文字のテキストが入ります。ここには200文字のテキストが入ります。ここには200文字のテキストが入ります。ここには200文字のテキストが入ります。"
  fireEvent.input(input)
  expect(input).toHaveTextContent(input.innerHTML)
  // エラー表示される
  expect(count).toHaveTextContent("-45")
})
```

イベント処理のところ、今回`input`イベントですが、恐らくよく使われるイベントは
`fireEvent.click(getByText("Up"))`
とか

```javascript
fireEvent.change(getByPlaceholderText("placeholder"), {
  target: { value: "new value" }
})
```
でしょうか。

感覚的にテストがしやすかったので、個人的にreact-testing-libraryの理解をさらに深めようと思っています。

## 終わりに
比較してみましたが、「結果これが良い！」と断定出来ていません^ ^;
「このライブラリのここが良かった」等ありましたら気軽にご意見ください！