testing-libraryの入力イベントが遅い

ラクスパートナーズのWebエンジニアの安井です。
普段は主にWebフロントエンドの開発を行なっています。

この記事は　[ラクス Advent Calendar 2023](https://qiita.com/advent-calendar/2023/rakus)　の20日目の記事になります。
[ラクスパートナーズ Advent Calendar 2023 の4日目](https://qiita.com/yassii_dev/items/77803c693fc5329a4d10)　にも参加しているのでそちらもよろしくお願いいたします。

## はじめに
業務の中で[testing-library](https://testing-library.com/)を利用してフロントエンドのインテグレーションテストを書いていたところ、やけに実行時間が長いテストケースがありタイムアウトでCIが落ちてしまっていました。
今後CIを安定して稼働させていくためにも実行時間を短くできないかと思い改善を図りました。

本記事ではタスク管理アプリケーションを例にして同様のケースでの改善事例を紹介します。

## 今回の事例について

利用したパッケージは下記です。
| パッケージ名 | バージョン |
| --------- | ------- |
| @testing-library/react | 14.1.2 |
| @testing-library/user-event | 14.5.1 |
| Vitest | 1.0.1 |
| happy-dom | 12.10.3 |

:::note warn
現状Vitest v1.0.2以降だとhappy-dom起因でエラーが発生するようだったため、今回の検証ではVitestはv1.0.1を利用しました。
https://github.com/vitest-dev/vitest/issues/4730
:::

### テストケース
今回改善対象となったテストケースについてです。

```ts
test.todo("各フィールドの入力文字数が上限を超過している場合エラーが表示される")
```

このテストケースはタスク管理アプリケーションのタスク作成画面における入力フォームのバリデーションが機能しているか確認するテストケースです。
バリデーションはreact-hook-formとvalibotを用いた下記のようなコードで実装しています。

```tsx:TaskForm.tsx
import { object, string, maxLength, minLength, type Output } from "valibot";
import { valibotResolver } from "@hookform/resolvers/valibot";

const formSchema = object({
  title: string([
    minLength(1, "タスク名が入力されていません"),
    maxLength(30, "タスク名は30文字以内で入力してください"),
  ]),
  description: string([
    maxLength(1000, "タスクの内容は1000文字以内で入力してください"),
  ]),
});

type FormSchema = Output<typeof formSchema>;

export const TaskForm = (props: Props) => {
  const {
    register,
    handleSubmit,
    formState: { errors },
    setValue,
    setError,
  } = useForm<FormSchema>({
    resolver: valibotResolver(formSchema),
  });
  
  // 以下省略
};
```

タスク名とタスク内容が記入できるシンプルなフォームUIがある画面です。

通常時
![スクリーンショット 2023-12-19 23.42.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549769/3b715cc4-792b-8d0b-3cd4-b3c10e151e9e.png)

バリデーションエラー時
![スクリーンショット 2023-12-19 23.45.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549769/4c1d5851-fcd7-8957-10b3-ea76b993192f.png)


この実装に対して次のようなテストコードを書きました。

```tsx:src/__tests__/tasks/create.test.tsx
import { renderWithProviders } from "../custom-render";
import TaskCreatePage from "@/app/tasks/create/page";
import Layout from "@/app/tasks/layout";

test("各フィールドの入力文字数が上限を超過している場合エラーが表示される", async () => {
  const { user } = renderWithProviders(
    <Layout>
      <TaskCreatePage />
    </Layout>,
  );

  const titleField = screen.getByRole("textbox", { name: "タスク名" });
  await user.type(titleField, "a".repeat(31));

  const descriptionField = screen.getByRole("textbox", { name: "内容" });
  await user.type(descriptionField, "a".repeat(1001));

  screen.getByRole("button", { name: "作成" }).click();

  await waitFor(() => {
    expect(titleField).toHaveAccessibleErrorMessage(
      "タスク名は30文字以内で入力してください",
    );
  });
  expect(descriptionField).toHaveAccessibleErrorMessage(
    "タスクの内容は1000文字以内で入力してください",
  );
  expect(createTaskMutationInterceptor).not.toHaveBeenCalled();
});
```

上記テストで利用しているカスタムrender関数です。
testing-libraryの[user-event](https://testing-library.com/docs/user-event/intro/)を利用しています。
ドキュメントに記載のあるようにコンポーネントのレンダーより前にセットアップ関数を呼び出し、そのユーザーインスタンスをテストの中で利用します。

```tsx:custom-render.tsx
import userEvent from "@testing-library/user-event";
import { render } from "@testing-library/react";
import { Providers } from "@/app/providers";

export const renderWithProviders = (ui: React.ReactElement) => {
  const user = userEvent.setup();

  return {
    user,
    ...render(<Providers>{ui}</Providers>),
  };
};
```

### 実行結果

![スクリーンショット 2023-12-19 21.32.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549769/e4f4a0e8-2d2e-c389-12de-f697658ad0e3.png)

上記はVitest UIのタスク作成画面のテストの実行結果のキャプチャです。
今回取り上げた上から４番目の「各フィールドの入力文字数が上限を超過している場合エラーが表示される」のテストケースだけかなり実行に時間がかかっていることがわかります。

## 改善結果
先に改善結果です。

![スクリーンショット 2023-12-19 21.53.35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549769/bc7dfe4d-2efc-9d4e-670d-d2df6af39a67.png)

検証内容は同じですが、改善前は実行に1,531msかかっていたテストが改善後はなんと8msで実行できるようになりました。

## 改善方法
入力フィールドの値の変更をuser-eventではなくfireEventで行う。

```diff_tsx
   const titleField = screen.getByRole("textbox", { name: "タスク名" });
-  await user.type(titleField, "a".repeat(31));
+  fireEvent.change(titleField, { target: { value: "a".repeat(31) } });

   const descriptionField = screen.getByRole("textbox", { name: "内容" });
-  await user.type(descriptionField, "a".repeat(1001));
+  fireEvent.change(descriptionField, { target: { value: "a".repeat(1001) } });
```

## なぜ遅かったか

[testing library公式](https://testing-library.com/docs/user-event/intro/)によると、`fireEvent`は単なる軽量なdispatchEvent APIのラッパーであるのに対して、`user-event`はユーザーの実際の操作に近い形でインタラクションをシュミレートする機能を提供するライブラリであるとのことです。

より実際のユーザーの操作に近い形でテストできた方が良いため`user-event`の利用が推奨されている模様です。
ただ`user-event`だけでは実現できないこともあるため、必要に応じて`fireEvent`を利用する機会もあるようです。

解決策に至ったきっかけですが今回の課題について色々と改善方法を模索していたところこちらの記事を拝見しました。
https://deflis.hatenablog.com/entry/2023/01/27/213631

> 注意点としては、テストが遅くなる副作用もなくはないです。 例えば、100文字を超えるような文字を入力しようとすると100文字分のキーストロークをシミュレーションしてしまうのでかなりテストに時間がかかるようになります。 その場合は fireEvent で書くと余計なイベントが発生しないで済むので、使い分けすれば良いと思います。

なぜ遅かったかについてですが、`user-event`だと実際のユーザーの操作による挙動を再現するためにコストがかかる、というのが理由になりそうでした。
逆に`fireEvent`だと単にイベントを発火させているだけなので低コストで処理を行えたということになりそうです。

たしかにユーザーの実際の操作で1000文字のテキストを入力するとなるとかなりの数のイベントが発生しそうです。
`user-event`によるシュミレーションはコストの高い処理であるということがわかりました。
ただ一方で`fireEvent`だと実際のユーザーの操作によるインタラクションを十分に表現できているとは言いづらいというのが、`user-event`を使う意義になりそうです。

以上のことから`user-event`の利用を基本とし、コストもしくはその他の要因で`user-event`の利用が困難な場合は`fireEvent`の利用を検討するのが良いと思いました。

## まとめ
`user-event`はテストで直感的にわかりやすくUIの操作を実現してくれるので使いやすいと感じています。
一方でテストの数を増やしていくと実行時間がどんどん長くなるため、極力低コストで実行できることは重要だと思っています。

今回直面した事例で言えばテストの実行時間が伸びてCIが安定稼働できないことのリスクが大きく、必ずしもuser-eventを使ってテストしないといけないほどクリティカルなユースケースではないので、fireEventを使って実行時間を短縮する選択を取りたいと思いました。

今回調べてみて`user-event`, `fireEvent`にもそれぞれトレードオフが存在していることがわかったので、それぞれの特性を理解して、ソフトウェアの質をより高く担保できるようなテストを書いていきたいと思います。

## 余談
今回元々は本記事のネタとしてJestで実行に時間のかかっているテストをVitest & happy-domで実行してみたらどれくらい実行パフォーマンスに影響があるのか調査しようとしたのが始まりでした。

その調査においてJestでの改善比較は下記のような結果になりました。

Jest改善前(user-event)
![Jest改善前](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549769/b086ac2d-9d51-8165-992c-cd2e22b48d3f.png)

Jest改善後(fireEvent)
![Jest改善後](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/549769/1adfa41e-90d4-21c4-3842-1bfabcb0f618.png)

まとめると下記のようになります。

|  | user-event | fireEvent |
| -- | ------- | --------- |
| Jest | 1844ms | 25ms |
| Vitest | 1531ms | 8ms |

Vitestでのuser-eventの実行時間(1,531ms)とJestのuser-eventの実行時間(1,833ms)の比較では、期待していたほどの短縮ができなかったためこちらは余談としています。
ただ収穫としてVitestとhappy-domを利用することでJestのときと比較して概ね50%前後の短縮が期待できそうなことがわかりました。

実行する処理の内容によっては期待したより速度が出ないということもありそうなので、引き続きテストを書きながら情報を蓄積していきたいと思います。