React Testing LibraryでLoading画面終了後の画面表示テキストを取得する方法

# はじめに

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

今回はReact Testing Libraryでテストケースを実装中に「なるほどなぁ」と思ったtipsをご紹介します。

一言でいうと、DOM要素を`waitFor`してから`getBy`したい場合は、`findBy`で書き直すこともできるというお話です。

このことを知った経緯について順を追って説明します。

# ローディング完了後の画面タイトルを取得しようとするがエラー発生

今回、こちらの学習記録アプリのテストケースを実装しています。

https://github.com/ritsu21ctws/study-record

テスト対象となる`App`コンポーネントの内容は以下の通りです。

<details><summary>コンポーネントのソースコード</summary>

```react:src/App.jsx
import { useEffect, useState } from "react";
import "./App.css";
import { addRecord, deleteRecord, getAllRecords } from "../utils/supabaseFunctions";

function App() {
  const [isLoading, setIsLoading] = useState(true);
  const [title, setTitle] = useState("");
  const [time, setTime] = useState(0);
  const [records, setRecords] = useState([]);
  const [error, setError] = useState("");
  const [totalTime, setTotalTime] = useState(0);

  const getRecords = async () => {
    const records = await getAllRecords();
    setRecords(records);

    // 合計時間の計算
    const totalTime = records.reduce((accumulator, currentValue) => accumulator + parseInt(currentValue.time), 0);
    setTotalTime(totalTime);

    setIsLoading(false);
  }

  // 画面初期表示
  useEffect(() => {
    getRecords();
  }, []);

  const onChangeTitle = event => setTitle(event.target.value);
  const onChangeTime = event => setTime(event.target.value);

  const onClickAdd = async () => {
    if (title === "" || time === "" || time === 0) {
      setError("入力されていない項目があります");
      return;
    }

    const result = await addRecord({
      title,
      time
    });

    if (result.status !== 201) {
      setError("登録処理に失敗しました");
      return;
    }

    getRecords();

    // 初期化
    setTitle("");
    setTime(0);
    setError("");
  }

  const onClickDelete = async (id) => {
    if (confirm("削除します。よろしいですか？")) {
      const result = await deleteRecord(id);

      if (result.status !== 204) {
        setError("削除処理に失敗しました");
        return;
      }

      getRecords();

      // 初期化
      setError("");
    }
  }

  if (isLoading) {
    return <>
      <h1>Loading...</h1>
    </>;
  } else {
    return <>
      <h1 data-testid="title">学習記録一覧</h1>
      <p>
        学習内容
        <input type="text" value={title} onChange={onChangeTitle} />
      </p>
      <p>
        学習時間
        <input type="number" min="0" value={time} onChange={onChangeTime} />
        時間
      </p>
      <p>入力されている学習内容：{title}</p>
      <p>入力されている時間：{time}時間</p>
      <ul>
        {records.map((record) => (
          <li key={record.id}>
            {`${record.title} ${record.time}時間`}<button onClick={() => onClickDelete(record.id)}>削除ボタン</button>
          </li>
        ) )}
      </ul>
      <button onClick={onClickAdd}>登録ボタン</button>
      {error !== "" && <p className="error">{error}</p>}
      <p>合計時間：{totalTime} / 1000 (h)</p>
    </>;
  }
}

export default App;
```

</details>

今回実装したかったテストケースが、『タイトルが「学習記録一覧」であること』を確認するためのものだったのですが、以下のエラーに遭遇しました。

- 対象のコード
  ```react:src/tests/componentApp.spec.jsx
  import App from "../App";
  import React from "react";
  import '@testing-library/jest-dom'
  import { render, screen } from "@testing-library/react";
  
  describe("Title Test", () => {
    it("タイトルが「学習記録一覧」であること", async () => {
      render(<App />);
      const title = screen.getByTestId("title");
      expect(title).toHaveTextContent("学習記録一覧");
    });
  });
  ```

- エラー内容
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3911933/8a3d00af-2e55-43e2-e098-a838c8ae2017.png)

# エラーの原因

この学習記録アプリは、supabaseからデータを取得して画面の初期表示を行うのですが、データ取得完了まではローディング画面が表示されます。

今回のテストケースでは、以下のDOMのテキストを取得したかったのですが、

```react:src/App.jsx
<h1 data-testid="title">学習記録一覧</h1>
```

以下のテストケースが実行された時点では、まだローディング画面の状態のままであり、

```react:src/tests/componentApp.spec.jsx
const title = screen.getByTestId("title");
```

DOMの状態としては、エラー内容に記載の通り以下の状態となっていました。

```html
<body>
  <div>
    <h1>
      Loading...
    </h1>
  </div>
</body>
```

この状態だと、先ほどの`screen.getByTestId("title");`で取得しようとしている`data-testid="title"`が見つからないためエラーとなっています。

# 対処方法 ①

まず最初に試したのが以下のように、`waitFor`を使用する方法です。
これにより、DOMに`data-testid="title"`が表示されるまで待ったうえで、タイトルを取得することができます。

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

describe("Title Test", () => {
  it("タイトルが「学習記録一覧」であること", async () => {
    render(<App />);

+   // loading画面の表示が終わるまで待機
+   await waitFor(() => {
+     expect(screen.getByTestId("title")).toBeInTheDocument();
+   });

    const title = screen.getByTestId("title");
    expect(title).toHaveTextContent("学習記録一覧");
  });
});
```

# 対処方法 ②

上記の方法でも特に問題はなさそうだったのですが、以下の記事を見つけました。
`findBy`の説明部分を読んでみて、『先ほどのコードも`findBy`でいけるのでは？』と思いました。

https://qiita.com/ossan-engineer/items/4757d7457fafd44d2d2f#findby%E3%82%92%E3%81%84%E3%81%A4%E4%BD%BF%E3%81%86%E3%81%8B

> まだ存在しないものの最終的に存在する要素については、getByやqueryByではなくfindByを使用してください。

結果的に、以下のように書き直すことでもテストがパスすることを確認できました。

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

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

ドキュメントを確認したところ、どうやら`findBy`は、`getBy`と`waitFor`の合わせ技のようです。

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

# おわりに

`fidBy`をうまく使うと`waitFor`よりもすっきりしたコードを書くことができます。

今回初めてReactのテスト実装を行っているため、まだまだつかみ切れていない部分も多いですが引き続き学んでいきたいと思います。

# 参考

https://qiita.com/ossan-engineer/items/4757d7457fafd44d2d2f

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