【Jest & Testing Library】非同期処理のテストで失敗しがちなパターンと解決策

# はじめに

この記事はニジボックスQiita記事投稿リレーの最終日の記事です🌈

JavaScriptで非同期処理を扱う部分は複雑になりがちでバグが起きやすいです。
このバグをテストコードで防止するのは素晴らしいアプローチです。
しかし、非同期処理のテストコードには、そのテストコード自体の欠陥が潜んでいることがよくあります。
この記事では、JestとTesting Libraryを使用した非同期処理のテストでよく起きる問題と、それらを解決する方法について紹介します。
また、執筆の際に[『フロントエンド開発のためのテスト入門 今からでも知っておきたい自動テスト戦略の必須知識』](https://www.shoeisha.co.jp/book/detail/9784798178639)が大変参考になりました。
フロントエンドのテストを具体的な実装方法を交えながら体系的に習得できる非常におすすめの一冊です。

# 非同期処理のテストの落とし穴

例えば `waitFor` 内で `expect` を実行するテストコードを考えてみます。
下記の `somePromiseFunc` はテスト対象の非同期関数とします。

```ts
export const somePromiseFunc = (value: number) =>
  new Promise((resolve, reject) => {
    setTimeout(() => {
      if (value >= 100) {
        resolve("100以上です");
      } else if (value >= 0) {
        resolve("100未満です");
      } else {
        reject("0未満はエラー");
      }
    }, 1000)
  });
```

`somePromiseFunc` を対象にしたテストコードを

```tsx
test('waitForのサンプル', () => {
  render(<SomeComponent />);
  waitFor(async () => {
    expect(await somePromiseFunc(500)).toBe("100未満です");
  });
});
```

のように書くとします。
`somePromiseFunc` の引数が100以上の場合は `"100以上です"` がresolveされるため、本来こちらのテストコードはFailすることが期待されます。
(引数に `500` を与えているのに `"100未満です"` とアサーションしているので。)
しかし、こちらのテストコードを実行するとPassしてしまいます。

なぜPassしてしまうかと言うと `waitFor` はPromiseを返却する関数ですが、このPromiseの完了を待たずにテスト実行が終了してしまうためです。
`expect` がそもそも実行されないため、期待値に誤りがあってもテストとしては正常終了となっていたのです。

# 偽陽性と偽陰性

先程のテストコードは、偽陰性（⁠false negative）となってしまいました。

:::note info
失敗すべきでないときに失敗してしまうことを「偽陽性」（⁠false positive）と言います。失敗すべきときに失敗してくれないことを「偽陰性」（⁠false negative）と言います。
(中略)
自動テストの偽陽性と偽陰性は、どちらも手強い問題です。偽陽性を放置すると、テストの失敗に対して鈍感になったり、テストが失敗しやすいためリファクタリングに後ろ向きになったりしてしまいます。偽陰性は、テストできていると思っていたらできていない、静かで恐ろしい問題です。まずは概念を正確に理解し、問題を認識できるようになることが上達への第一歩です。

引用) [「サバンナ便り ～ソフトウェア開発の荒野を生き抜く～」](https://gihyo.jp/dev/serial/01/savanna-letter/0002)
:::

先程のテストコードを書き直すと、

```tsx
test('waitForのサンプル', async () => {
  render(<SomeComponent />);
  await waitFor(async () => {
    expect(await somePromiseFunc(500)).toBe("100未満です");
  });
});
```

というコードであれば、期待通りFailします。
(テスト関数をasync関数にして `waitFor` を `await` しました。)

他にも `try...catch` 文 の `catch` 節で `expect` したものの、テスト対象のコードに例外が発生しなければ、テスト自体はPassしてしまい、これもまた偽陰性となってしまいます。

先程の `somePromiseFunc` のrejectパターンのテストで偽陰性を再現すると、

```tsx
test("try...catchのサンプル", async () => {
  render(<SomeComponent />);
  try {
    await somePromiseFunc(500);
  } catch (err) {
    expect(err).toBe("0未満はエラー");
  }
});
```

このようなコードです。
テストはPassして偽陰性となってしまいます。

# 欠陥挿入で偽陰性を防ぐ

偽陰性に対して有効な回避策として欠陥挿入という手法があります。

:::note info
プロダクトコードに明らかな誤りを一時的に混入させ、その誤りをテストが検知できるかを確かめます。明らかな誤りを入れたのにテストが成功のままならば、偽陰性が発生しています。このような手法を欠陥挿入と言います。

引用) [「サバンナ便り ～ソフトウェア開発の荒野を生き抜く～」](https://gihyo.jp/dev/serial/01/savanna-letter/0002)
:::

テスト対象の `somePromiseFunc` の実装をあえて書き換えます。

```ts
export const somePromiseFunc = (value: number) =>
  new Promise((resolve, reject) => {
    setTimeout(() => {
      if (value >= 100) {
        resolve("100以上です");
      } else if (value >= 0) {
        resolve("100未満です");
      } else {
        resolve("あえてresolve");
      }
    }, 1000)
  });
```

元々 `reject("0未満はエラー")` としていた部分を `resolve("あえてresolve")` として、例外が発生しないようにしました。

この欠陥挿入をした状態でも、

```tsx
test("try...catchのサンプル", async () => {
  render(<SomeComponent />);
  try {
    await somePromiseFunc(500);
  } catch (err) {
    expect(err).toBe("0未満はエラー");
  }
});
```

このテストコードはPassするので、何かおかしいぞと気づける訳です。

欠陥挿入でテストコードを検証した後は、欠陥挿入前の状態に戻すため、テストコード上には試行錯誤した形跡が残りません。

```ts
expect(err).toBe("0未満はエラー"); // 安心してください。アサーションは実行されてますよ。
```

偽陰性がないことをアピールするために、上記のようなコメントアウトを残すよりももっと良い方法があります。

# expect.assertionsを使用したアサーションの検証

`expect.assertions` を使うことでアサーションの回数を検証することができます。

```tsx
test("try...catchのサンプル", async () => {
  expect.assertions(1);
  render(<SomeComponent />);
  try {
    await somePromiseFunc(500);
  } catch (err) {
    expect(err).toBe("0未満はエラー");
  }
});
```

アサーションが指定された回数と異なっていればテストがFailするため、偽陰性を防ぐことができます。
また、アサーションの回数を検証していることをテストコードに残せるのでレビューの際にも有用です。

# 非同期処理以外の注意点

非同期処理以外でも、テストコードの品質を高めるための注意点があります。
例えば、モック関数をクリアせずにtoHaveBeenCalled()などのマッチャーを使用すると、偽陽性や偽陰性の問題が発生する可能性があります。

# まとめ

この記事では、JavaScriptで非同期処理を扱う際のテストコードによく起きる問題とその解決方法について紹介しました。
非同期処理のテストでは、偽陽性や偽陰性といった問題が発生することがあります。
偽陰性は特にテストが失敗したときに気づきにくく、問題のあるコードを見逃す可能性があります。
「欠陥挿入」や `expect.assertions` を適宜使用して快適なテストライフをすごしましょう！

# 参考文献

[サバンナ便り ～ソフトウェア開発の荒野を生き抜く～](https://gihyo.jp/dev/serial/01/savanna-letter/0002)
[フロントエンド開発のためのテスト入門 今からでも知っておきたい自動テスト戦略の必須知識](https://www.shoeisha.co.jp/book/detail/9784798178639)

---

:::note info
この記事はニジボックス投稿リレー企画、最終日の記事です。
@ken7253_さん、@ment_REさん、@t_kokuboさん、@sakamuuyさん 完走お疲れ様でした！！！
:::