【React Testing Library】ローカルで通るテストがGitHubActionsのCI環境ではエラーになってしまう

# はじめに

お疲れ様です、りつです。

タイトルの事象でハマりました。

# 問題

まず前提として、現在Reactで作成した学習記録アプリのテスト実装を行っており、GitHubActionsでプルリク作成時（＋そのプルリクに`push`時）に自動でテストを実行するように設定を行いました。

今回、ローカル上では全てのテストをパスした状態でした。
ところが、いざプルリクエストを作成すると、GitHubActionsのワークフローが失敗してしまいました。

ログを見ると、ローカルではパスしたテストが、CI環境だと失敗してしまうようです。

<details><summary>GitHubActionsのログ</summary>

```terminal:GitHubActionsのログ
Run npm run test

> study-record@0.0.0 test
> jest

FAIL src/tests/componentApp.spec.jsx (6.535 s)
  App Component Test
    ✓ タイトルが「学習記録一覧」であること (820 ms)
    ✕ フォームに学習内容と時間を入力して登録ボタンを押すと新たに記録が追加されていること (1905 ms)
    ✕ 削除ボタンを押すと学習記録が削除されること (1790 ms)
    ✓ 入力をしないで登録を押すとエラーが表示される (773 ms)

  ● App Component Test › フォームに学習内容と時間を入力して登録ボタンを押すと新たに記録が追加されていること

    expect(received).toHaveLength(expected)

    Expected length: 4
    Received length: 3
    Received array:  [<li>テスト記録 10時間<button>削除</button></li>, <li>テスト記録 10時間<button>削除</button></li>, <li>テスト記録 10時間<button>削除</button></li>]

    Ignored nodes: comments, script, style
    <html>
      <head />
      <body>
        <div>
          <h1
            data-testid="title"
          >
            学習記録一覧
          </h1>
          <p>
            学習内容
            <input
              data-testid="inputText"
              type="text"
              value=""
            />
          </p>
          <p>
            学習時間
            <input
              data-testid="inputTime"
              min="0"
              type="number"
              value="0"
            />
            時間
          </p>
          <p>
            入力されている学習内容：
          </p>
          <p>
            入力されている時間：
            0
            時間
          </p>
          <ul>
            <li>
              テスト記録 10時間
              <button>
                削除
              </button>
            </li>
            <li>
              テスト記録 10時間
              <button>
                削除
              </button>
            </li>
            <li>
              テスト記録 10時間
              <button>
                削除
              </button>
            </li>
          </ul>
          <button>
            登録
          </button>
          <p>
            合計時間：
            30
             / 1000 (h)
          </p>
        </div>
      </body>
    </html>

      32 |     await waitFor(() => ***
      33 |       const afterLists = screen.getAllByRole('listitem');
    > 34 |       expect(afterLists).toHaveLength(beforeLists.length + 1);
         |                          ^
      35 |     ***);
      36 |   ***);
      37 |

      at toHaveLength (src/tests/componentApp.spec.jsx:34:26)
      at runWithExpensiveErrorDiagnosticsDisabled (node_modules/@testing-library/dom/dist/config.js:47:12)
      at checkCallback (node_modules/@testing-library/dom/dist/wait-for.js:124:77)
      at checkRealTimersCallback (node_modules/@testing-library/dom/dist/wait-for.js:118:16)
      at Timeout.task [as _onTimeout] (node_modules/jsdom/lib/jsdom/browser/Window.js:520:19)

  ● App Component Test › 削除ボタンを押すと学習記録が削除されること

    expect(received).toHaveLength(expected)

    Expected length: 3
    Received length: 4
    Received array:  [<li>テスト記録 10時間<button>削除</button></li>, <li>テスト記録 10時間<button>削除</button></li>, <li>テスト記録 10時間<button>削除</button></li>, <li>テスト記録 10時間<button>削除</button></li>]

    Ignored nodes: comments, script, style
    <html>
      <head />
      <body>
        <div>
          <h1
            data-testid="title"
          >
            学習記録一覧
          </h1>
          <p>
            学習内容
            <input
              data-testid="inputText"
              type="text"
              value=""
            />
          </p>
          <p>
            学習時間
            <input
              data-testid="inputTime"
              min="0"
              type="number"
              value="0"
            />
            時間
          </p>
          <p>
            入力されている学習内容：
          </p>
          <p>
            入力されている時間：
            0
            時間
          </p>
          <ul>
            <li>
              テスト記録 10時間
              <button>
                削除
              </button>
            </li>
            <li>
              テスト記録 10時間
              <button>
                削除
              </button>
            </li>
            <li>
              テスト記録 10時間
              <button>
                削除
              </button>
            </li>
            <li>
              テスト記録 10時間
              <button>
                削除
              </button>
            </li>
          </ul>
          <button>
            登録
          </button>
          <p>
            合計時間：
            40
             / 1000 (h)
          </p>
        </div>
      </body>
    </html>

      44 |     await waitFor(() => ***
      45 |       const afterLists = screen.getAllByRole('listitem');
    > 46 |       expect(afterLists).toHaveLength(beforeLists.length - 1);
         |                          ^
      47 |     ***);
      48 |   ***);
      49 |

      at toHaveLength (src/tests/componentApp.spec.jsx:46:26)
      at runWithExpensiveErrorDiagnosticsDisabled (node_modules/@testing-library/dom/dist/config.js:47:12)
      at checkCallback (node_modules/@testing-library/dom/dist/wait-for.js:124:77)
      at checkRealTimersCallback (node_modules/@testing-library/dom/dist/wait-for.js:118:16)
      at Timeout.task [as _onTimeout] (node_modules/jsdom/lib/jsdom/browser/Window.js:520:19)

Test Suites: 1 failed, 1 total
Tests:       2 failed, 2 passed, 4 total
Snapshots:   0 total
Time:        6.919 s
Ran all test suites.
Error: Process completed with exit code 1.
```
</details>

GitHubActionsのワークフローの内容は以下の通りです。

```yml:.github/workflows/firebase-hosting-pull-request.yml
# This file was auto-generated by the Firebase CLI
# https://github.com/firebase/firebase-tools

name: Deploy to Firebase Hosting on PR
on: pull_request
permissions:
  checks: write
  contents: read
  pull-requests: write
jobs:
  build_and_preview:
    if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
    runs-on: ubuntu-latest
    env:
      VITE_SUPABASE_URL: ${{secrets.SUPABASE_URL}}
      VITE_SUPABASE_ANON_KEY: ${{secrets.SUPABASE_ANON_KEY}}
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - run: npm run test
      - uses: FirebaseExtended/action-hosting-deploy@v0
        with:
          repoToken: ${{ secrets.GITHUB_TOKEN }}
          firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_STUDY_RECORD_10E03 }}
          projectId: study-record-10e03
```

今回の事象の特徴は以下の通りでした。
- ローカルではテストが全てパスしている
- （テストケースを修正していないのに）`push`した際に、毎回同じテストで失敗するわけではない

ご参考までに、以下が各テストの実行時間と結果です。

| やったこと | タイトルのテスト | 登録ボタンのテスト | 削除ボタンのテスト | 入力チェックのテスト |
|:-------------|:-----------|:------------|:-------------|:-------------|
| ymlの修正 | 〇 (950 ms) | ✕ (1915 ms) | ✕ (1829 ms) | 〇 (771 ms) |
| ymlの修正 | 〇 (930 ms) | ✕ (1898 ms) | ✕ (1778 ms) | 〇 (787 ms) |
| ymlの修正 | ✕ (1039 ms) | 〇 (1371 ms) | 〇 (1200 ms) | 〇 (188 ms) |
| タイトルのテストの修正 | 〇 (984 ms) | ✕ (1917 ms) | ✕ (1834 ms) | 〇 (766 ms) |
| ymlの修正 | 〇 (635 ms) | 〇 (1626 ms) | 〇 (1530 ms) | 〇 (531 ms) |
| タイトルのテストの修正[^1] | 〇 (704 ms) | ✕ (1644 ms) | 〇 (1566 ms) | 〇 (546 ms) |
| タイトルのテストの修正 | 〇 (901 ms) | ✕ (1865 ms) | ✕ (1765 ms) | 〇 (767 ms) |
| タイトルのテストの修正[^1] | 〇 (820 ms) | ✕ (1905 ms) | ✕ (1790 ms) | 〇 (773 ms) |

※ ymlの修正：`.github/workflows/firebase-hosting-pull-request.yml`で、`env`や`npm run test`の位置を調整
※ テスト1の修正：`findBy`を`waitFor`へ書き直し、またその逆など

[^1]: この2件はソースコードの内容が全く一緒の状態でした。

# 解決方法

いろいろ試してみたのですが、結果的に以下のようにテストコードを修正することでテストが安定してパスするようになりました。

<details>
<summary>修正前のテストコード</summary>

```react:src/tests/componentApp.spec.jsx
import App from "../App";
import React from "react";
import '@testing-library/jest-dom'
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

describe("App Component Test", () => {
  beforeEach(() => {
    // confirmダイアログをモックし、常に「OK」をクリックした状態にする
    jest.spyOn(window, 'confirm').mockImplementation(() => true);
  });

  afterEach(() => {
    // 他のテストに影響を与えないように、confirmモックを元に戻す
    window.confirm.mockRestore();
  });

  it("タイトルが「学習記録一覧」であること", async () => {
    render(<App />);
    const title = await screen.findByTestId("title");
    expect(title).toHaveTextContent("学習記録一覧");
  });

  it("フォームに学習内容と時間を入力して登録ボタンを押すと新たに記録が追加されていること", async () => {
    render(<App />);
    const beforeLists = await screen.findAllByRole('listitem');

    await userEvent.type(screen.getByTestId('inputText'), 'テスト記録');
    await userEvent.type(screen.getByTestId('inputTime'), '10');
    await userEvent.click(screen.getByRole('button', {name: '登録'}));

    await waitFor(() => {
      const afterLists = screen.getAllByRole('listitem');
      expect(afterLists).toHaveLength(beforeLists.length + 1);
    });
  });

  it("削除ボタンを押すと学習記録が削除されること", async () => {
    render(<App />);
    const beforeLists = await screen.findAllByRole('listitem');

    await userEvent.click(screen.getAllByRole('button', {name: '削除'})[beforeLists.length - 1]);

    await waitFor(() => {
      const afterLists = screen.getAllByRole('listitem');
      expect(afterLists).toHaveLength(beforeLists.length - 1);
    });
  });

  it("入力をしないで登録を押すとエラーが表示される", async () => {
    render(<App />);
    const beforeLists = await screen.findAllByRole('listitem');

    await userEvent.click(screen.getByRole('button', {name: '登録'}));

    await waitFor(() => {
      const errorMsg = screen.getByText('入力されていない項目があります');
      expect(errorMsg).toBeInTheDocument();
    });

    await waitFor(() => {
      const afterLists = screen.getAllByRole('listitem');
      expect(afterLists).toHaveLength(beforeLists.length);
    });
  });
});
```
</details>


<details>
<summary>修正後のテストコード</summary>

```react:src/tests/componentApp.spec.jsx
import App from "../App";
import React from "react";
import '@testing-library/jest-dom'
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

describe("App Component Test", () => {
  beforeEach(() => {
    // confirmダイアログをモックし、常に「OK」をクリックした状態にする
    jest.spyOn(window, 'confirm').mockImplementation(() => true);
  });

  afterEach(() => {
    // 他のテストに影響を与えないように、confirmモックを元に戻す
    window.confirm.mockRestore();
  });

  it("タイトルが「学習記録一覧」であること", async () => {
    render(<App />);
    const title = await screen.findByTestId("title");
    expect(title).toHaveTextContent("学習記録一覧");
  });

  it("フォームに学習内容と時間を入力して登録ボタンを押すと新たに記録が追加されていること", async () => {
    render(<App />);
    const beforeLists = await screen.findAllByRole('listitem');

    await userEvent.type(screen.getByTestId('inputText'), 'テスト記録');
    await userEvent.type(screen.getByTestId('inputTime'), '10');
    await userEvent.click(screen.getByRole('button', {name: '登録'}));

    await waitFor(() => {
      const afterLists = screen.getAllByRole('listitem');
      expect(afterLists).toHaveLength(beforeLists.length + 1);
    }, { timeout: 2000 }); // タイムアウト設定を追加
  });

  it("削除ボタンを押すと学習記録が削除されること", async () => {
    render(<App />);
    const beforeLists = await screen.findAllByRole('listitem');

    await userEvent.click(screen.getAllByRole('button', {name: '削除'})[beforeLists.length - 1]);

    await waitFor(() => {
      const afterLists = screen.getAllByRole('listitem');
      expect(afterLists).toHaveLength(beforeLists.length - 1);
    }, { timeout: 2000 }); // タイムアウト設定を追加
  });

  it("入力をしないで登録を押すとエラーが表示される", async () => {
    render(<App />);
    const beforeLists = await screen.findAllByRole('listitem');

    await userEvent.click(screen.getByRole('button', {name: '登録'}));

    await waitFor(() => {
      const errorMsg = screen.getByText('入力されていない項目があります');
      expect(errorMsg).toBeInTheDocument();
    });

    await waitFor(() => {
      const afterLists = screen.getAllByRole('listitem');
      expect(afterLists).toHaveLength(beforeLists.length);
    });
  });
});
```
</details>

アサーション実行時の`waitFor`のタイムアウト時間を`2000 ms`に設定しています（`waitFor`のタイムアウトのデフォルト値は`1000 ms`）

https://testing-library.com/docs/dom-testing-library/api-async/#waitfor

> The default `timeout` is `1000ms`.

理由としては、表やログを見ていて、『CI環境はローカルよりもテスト実行に時間がかかっているため、画面描画が正しく行われる前にアサーションが行われているのでは？』と考えたからです。

:::note info
登録ボタンのテストと削除ボタンのテストで失敗していることが多かったので、一旦その二つで対応しています。
:::

# おわりに

なかなかピンポイントに解決する方法が見つからず困りました。
暫定対応だと思うので、他によい対処方法があれば知りたいです。

# 参考
https://testing-library.com/docs/dom-testing-library/api-async/#waitfor