8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ラクスAdvent Calendar 2023

Day 20

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

Last updated at Posted at 2023-12-19

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

この記事は ラクス Advent Calendar 2023 の20日目の記事になります。
ラクスパートナーズ Advent Calendar 2023 の4日目 にも参加しているのでそちらもよろしくお願いいたします。

はじめに

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

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

今回の事例について

利用したパッケージは下記です。

パッケージ名 バージョン
@testing-library/react 14.1.2
@testing-library/user-event 14.5.1
Vitest 1.0.1
happy-dom 12.10.3

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

テストケース

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

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

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

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

バリデーションエラー時
スクリーンショット 2023-12-19 23.45.08.png

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

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を利用しています。
ドキュメントに記載のあるようにコンポーネントのレンダーより前にセットアップ関数を呼び出し、そのユーザーインスタンスをテストの中で利用します。

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

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

改善結果

先に改善結果です。

スクリーンショット 2023-12-19 21.53.35.png

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

改善方法

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

   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公式によると、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改善前

Jest改善後(fireEvent)
Jest改善後

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

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

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

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

8
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?