ラクスパートナーズの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を用いた下記のようなコードで実装しています。
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がある画面です。
この実装に対して次のようなテストコードを書きました。
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を利用しています。
ドキュメントに記載のあるようにコンポーネントのレンダーより前にセットアップ関数を呼び出し、そのユーザーインスタンスをテストの中で利用します。
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>),
};
};
実行結果
上記はVitest UIのタスク作成画面のテストの実行結果のキャプチャです。
今回取り上げた上から4番目の「各フィールドの入力文字数が上限を超過している場合エラーが表示される」のテストケースだけかなり実行に時間がかかっていることがわかります。
改善結果
先に改善結果です。
検証内容は同じですが、改善前は実行に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での改善比較は下記のような結果になりました。
まとめると下記のようになります。
user-event | fireEvent | |
---|---|---|
Jest | 1844ms | 25ms |
Vitest | 1531ms | 8ms |
Vitestでのuser-eventの実行時間(1,531ms)とJestのuser-eventの実行時間(1,833ms)の比較では、期待していたほどの短縮ができなかったためこちらは余談としています。
ただ収穫としてVitestとhappy-domを利用することでJestのときと比較して概ね50%前後の短縮が期待できそうなことがわかりました。
実行する処理の内容によっては期待したより速度が出ないということもありそうなので、引き続きテストを書きながら情報を蓄積していきたいと思います。