[react-testing-library] waitFor を使う時に await を用いないとどうなるか

この記事は [コネヒト Advent Calendar 2021](https://qiita.com/advent-calendar/2021/connehito) 19日目の記事です。

フロントエンド開発者のみなさん、テストコードは書いていますか？
私の担当しているプロジェクトではジョインした時から Jest + testing-library というアセットでフロントエンドのテストコードがコンポーネントごとに書かれています。上記のスキルセットには自身でも親しみがあり、日々の開発でもテストコードは欠かさず書いているのですが、ある時つまづいた点がありました。今日はその失敗談とそれに気づいたきっかけなどについて振り返ってみます。

# TL;DR
- testing-library で`waitFor(()=>...`を記述する時に、`await`を接頭辞につけていなかった
- 見かけ上はテストコードがパスしていたが、`waitFor(()=>...`の中の記述内容が検査されていなかった
- `await`を付けない`waitFor(()=>...`は無意味
  - `waitFor`は Promise を返すので、`await`を頭に記述しないと結果が返る前にテストが終わってしまう

以下、コンポーネントとそれに対するテストに分けて説明していきます。

# コンポーネントの構造

- セレクトボックスと保存ボタンからなるコンポーネント。
- 親カテゴリが選択されると、紐づく子カテゴリのセレクトボックスが出現するコンポーネントが`<CategoryList/>`という名前で内包されている。
- 上記のロジックは、`onChangeCategory`という名前で他の箇所に実装されており、親カテゴリが選択されるとそのIDに紐づく子カテゴリを出現させる。
- セレクトボックス内に生成される option 要素は`categories`という名前で Props に渡す。
- 各セレクトボックスのデフォルト値は「選択してください」と表示されている。


```CategorySelect.tsx
// ※プロダクトコードとは異なります。

type Category = {
  id: number
  parent_id?: number // 子カテゴリが紐づいている親カテゴリのID
  name: string // option の children
}
 
export type CategorySelectProps = {
  categories: Category[]
  onChangeCategory: (event: React.ChangeEvent<HTMLSelectElement>, parentId: number) => void
  onSubmit: () => void
}

export const CategorySelect: React.FC<CategorySelectProps> = ({
  categories,
  onChangeCategory,
  onSubmit,
}) => (
  <>
    <CategoryList categories={categories} onChange={onChangeCategory} />
    <button onClick={onSubmit}>
      保存する
    </button>
  </>
)
```

# 誤ったテストコード

上記のコンポーネントの挙動をテストするために、以下のようなテストコードを書きました。

```CategorySelect.test.tsx
// 配列のモック
const mockCategories = [
  {
    id: 1,
    name: 'Category 1',
  },
  {
    id: 2,
    parent_id: 1, // <- id: 1の子カテゴリ
    name: 'Category 2',
  },
]

describe('<CategorySelect />', () => {
  test('子カテゴリがレンダリングされる', async () => {
    render(
      <CategorySelect
        categories={mockCategories}
        onSubmit={onSubmit}
        onChangeCategory={() => {}}
      />
    )

    const selectParentCategory = screen.getByDisplayValue('選択してください')
    // 親カテゴリのセレクトボックスの値を変更
    fireEvent.change(selectParentCategory, { target: { value: '1' } })
    expect(screen.getByDisplayValue(mockCategories[0].name)).toBeInTheDocument()
```

しかし、上記の`fireEvent.change`の後にそのまま`expect(...). toBeInTheDocument()`を記述し、セレクトボックスの値が変わることを検査しようとしたところ、テストが失敗しました。

```CategorySelect.test.tsx
● <CategorySelect /> › 子カテゴリがレンダリングされる

TestingLibraryElementError: Unable to find an element with the display value: Category 1.
.
.
.
// 親カテゴリのセレクトボックスの値を変更
fireEvent.change(selectParentCategory, { target: { value: '1' } })
expect(screen.getByDisplayValue(mockCategories[0].name)).toBeInTheDocument()
              ^
```

上記のコンポーネントの説明に記述したように、セレクトボックスの値が変更された時に`onChangeCategory`の処理が走ってしまうため、即座に変更された値が DOM 上に反映されず`getByDisplayValue`がエラーになってしまいます。

# 誤ったテストコード 2

そこで、testing-library の 標準 API である`waitFor`でラップして、値が DOM に反映されるのをテストコードが待つよう変更を加えました。
また、続けて子カテゴリがレンダリングされ、値が変更可能なことを検査するためテストコードを追加しました。

```CategorySelect.test.tsx
const selectParentCategory = screen.getByDisplayValue('選択してください')
// 親カテゴリのセレクトボックスの値を変更
fireEvent.change(selectParentCategory, { target: { value: '1' } })
waitFor(() => expect(screen.getByDisplayValue(mockCategories[0].name)).toBeInTheDocument())

// 子カテゴリのセレクトボックスの初期値を取得
const selectChildCategory = screen.getByDisplayValue('選択してください')
// 子カテゴリのセレクトボックスの値を変更
fireEvent.change(selectChildCategory, { target: { value: '1' } })
waitFor(() => expect(screen.getByDisplayValue(mockCategories[1].name)).toBeInTheDocument())
```

結論から述べると、このテストコードは**パスしますが誤っています。**コードレビューで指摘されるまで誤りに気づくことができませんでしたが、`waitFor`を使用する際は`await`と共に記述しないと、内部の検査結果が boolean を返す前にテストコードが実行終了してしまいます。

コンポーネントの挙動が期待通りであるならば、子カテゴリもレンダリングされ、画面上に現れるセレクトボックスの数は**2つ**となるはずです。それを検査するテストコードを続けて記述すると、期待結果と異なりテストは失敗します。

```test.tsx
expect(screen.getAllByRole('combobox')).toHaveLength(2)

// 実行結果
expect(received).toHaveLength(expected)

    Expected length: 2
    Received length: 1 <- 子カテゴリのセレクトボックスがレンダリングされていない
```

つまり、テストケース内でセレクトボックスの項目の変更と新しいカテゴリのレンダリングがうまくいっていないことがわかります。

# 対処法と学び

「テストコードを正しく書く」という意味では、「`waitFor`の前に`await`をつければよい」という結論に至りますが、この`<CategorySelect />`コンポーネントではそもそも内包している`<CategoryList　/>`内で実行される`onChangeCategory`を空の関数でモックしているため、やはりテストは失敗します。
この場合、`onChangeCategory`に対してテストコードを書くことがアプリケーションの動作を担保することにつながります。

今回、実装したコンポーネントにテストコードを書くこと自体が目的となってしまい、「テストコードで何を担保するのか」という目線が抜けていたため、成功させることに囚われテストコードの誤りに気づけませんでした。
testing-library は非常に強力なテストツールですが、正しい書き方を身につけないと「不十分だが成功するテスト」で品質を担保しているという勘違いに繋がりかねません。
月並みな感想ですが、プロダクトコードと同様に、テストコードもコードレビューなどで第三者のチェックを行う必要があると感じました。

# 関連リンク
[@testing-library/react (rtl) 'waitFor' makes only success without await keyword - Stack Overflow](https://stackoverflow.com/questions/65606979/testing-library-react-rtl-waitfor-makes-only-success-without-await-keyword)