こんにちは、フロントエンドエンジニアの樫福です。
タイミーのフロントエンドの開発に関わるエンジニアの人数が増えてきました。大人数で開発しながら品質を高い状態に保つには、品質に対する共通認識を作ることが大切です。
このたび、チームでフロイントエンドの単体テストについての勉強会を開催しました。 testing-library というフロントエンドのテストに使うライブラリを例に挙げ、具体的な手法よりも、テストを実装する前に抑えておきたい思想についてフォーカスしました。
- フロントエンドでテストしたい項目
- フロントエンドの単体テストを難しくする要因
- testing-library を使って壊れにくいテストを作る方法
- 実際にテストを書いてみる
- 運用するときの注意点
- おわりに
フロントエンドでテストしたい項目
フロントエンドでテストしたい項目には次のようなものがあると思います。
- ユーティリティ関数やビジネスロジックが正しく実装されていること
- 意図しない見た目の変更が起こらないこと
- ユーザの動作に対して期待した正しい応答が返ってくること
ユーティリティ関数やビジネスロジックは関数やクラスとして切り出し、それらに対しての単体テストを書くことになります。基本的には入力に対する出力を確認する出力値ベーステストでテストします。クラスで実装する場合にはアクションに伴って変更した状態を検査する状態ベーステストも併せて使います。これらはフロントエンド以外でも扱うトピックです。
意図しない見た目の変更が起こらないことをテストする場合は Visual Regression Test (VRT) を使います。 reg-suit などのツールを使って、変更ごとの画像のキャプチャを撮って比較します。
ユーザがボタンをクリックする、などの動作のテストは単体テストで実装することができます。 testing-library というライブラリを使ってユーザの動作をシミュレーションすることで、 UI コンポーネントの挙動を検査します。画面上の表示に対しては状態ベーステストを、 API にリクエストを送ることのテストはコミュニケーションベーステストを実装することになります。
勉強会では単体テストにおけるフロントエンド特有の難しさについて扱いたいので、3つ目のような「ユーザの画面上に表示された要素に対する動作を起点とするようなテスト」に注目しました。
フロントエンドの単体テストを難しくする要因
一般的なソフトウェアと比較してフロントエンドにおいて特筆すべき点に次のようなものがあります。
フロントエンドのテストが壊れやすかったり理解するのが難しくなったりする場合、とくに、要素の取得をシミュレートすることに躓いていることが多いように感じています。
testing-library を使って壊れにくいテストを作る方法
testing-library はフロントエンドのテストに使うツール群です。 testing-library には次のような基本方針があります。
The more your tests resemble the way your software is used, the more confidence they can give you.
そのソフトウェアが実際に使われる姿に似ているほど、テストの信頼性が高くなります。 testing-library は、ソフトウェアの使われ方に似たテストを作れるような機能を提供してくれています。ライブラリを使ってテストを実装するときには、この考え方に則ってテストを書くように心がけましょう。
以下では、 testing-library が提供している機能を3つ紹介します。
要素を見つけるクエリ
一つ目は、ページに表示されている要素を見つけるクエリです。要素に対して動作をする場合も、要素の状態を検査する場合も、まずは要素を見つけることから始まります。
クエリには優先度があり、なるべく優先度が高いものを使うことが推奨されています。たとえば、 getByRole
は優先度が高く、 getByPlaceholderText
は優先度が低いです。これらはどのように決められているのでしょうか。
たとえば、ラベルが『生年月日』でプレースホルダーが "2023-03-27" であるような入力要素を見つけるクエリを作ってみます。次の2種類の実装のいずれも想定通りに動きました。
// 1. ラベルが『生年月日』である入力要素を取得する screen.getByRole("textbox", { name: "生年月日" }); // 2. プレースホルダーが "2023-03-27" であるような入力要素を見つける screen.getByPlaceholderText("2023-03-27");
1, のクエリは「ラベルが『生年月日』である入力要素」を取得しています。2. のクエリは「プレースホルダーが "2023-03-27" であるような入力要素」を取得しています。ユーザがアプリケーションを使用する際、おそらく 1. のクエリのような考え方で要素を認識するでしょう。 getByRole
が getByPlaceholderText
よりも優先度が高い理由は、このようなユーザの考え方を反映しやすい傾向にあるからです。
必ずしも優先度が高いクエリを使うことが良いわけではないですが、なるべくユーザの考え方を反映させて要素を取得するクエリを書くことはよいテストを作りに欠かせないと思います。
ユーザの動作をシミュレーションする user-event
ユーザの動作をシミュレーションするために user-event というライブラリを提供しています。
特定の要素をクリックしたり、キーボードで入力したりをシミュレーションする際は次のように実装します。
// ユーザオブジェクトの生成 const user = userEvent.setup(); // ボタンをクリックする await user.click(screen.getByRole("button")); // 入力要素に「こんにちは」と入力する await user.type(screen.getByRole("textbox"), "こんにちは");
user.click
を実行すると、実際に要素をクリックしたときと同じようにイベントが発火します。これによって、実装者はボタンをクリックしたときに裏側でどのような挙動が取られるかを気にすることなくテストを実装することができます。
同じようにユーザの動作をシミュレーションする方法に、同じく testing-library の fire-event を使った実装があります。こちらは、ユーザの動作そのものではなく DOM のイベントを発火させる機能を持っています。 user-event と fireEvent ではどちらを使うべきでしょうか。 ユーザがアプリケーションを使う場合、「このボタン要素の click イベントを発火させよう」と考えるのではなく「このボタン要素をクリックしよう」と思って使うはずです。したがって、テストをソフトウェアが使われる姿に似せるという観点において、 user-event を使うことが推奨されています。ただし、 user-event が再現できていないブラウザの挙動もいくつか存在します。そのような挙動に対してテストを書きたいときには fireEvent を使うといいでしょう*2。
要素の状態を検査する jest-dom
要素の状態を検査する機能として、 jest-dom というライブラリが提供されています。
jest のマッチャーを追加して、確認(AAA パターン*3における Assert)をしやすくしてくれます。提供している関数の一覧を見るとよくわかりますが、直感的に状態を確認できるようになっています。
たとえば、要素がフォーカスされていることをテストする場合は次のように実装します。要素がどういう状態のときにフォーカスされているかという実装の詳細には立ち入らず、要素がフォーカスされていることをユーザが認識するのと同じように、テストが実装されていることがわかります。
const inputElement = screen.getByRole("textbox"); expect(inputElement).toHaveFocus();
実際にテストを書いてみる
具体的なテストの例を見てみましょう。次のような UI コンポーネントに対してテストを書いてみます。
このコンポーネントは次のような仕様を満たします。
- ラベル『ユーザ名』『ニックネーム』『生年月日』の入力要素がある
- 入力して『送信』ボタンをクリックすると、 props で渡す
submit
関数が呼ばれる。入力値はその引数として与えられる - 『ユーザ名』『生年月日』は必須項目である。空文字列のまま『送信』ボタンをクリックすると
submit
が呼ばれず、アラートメッセージが表示される
次のような二つのテストを実装します。
- すべての入力要素にデータを入力し『送信』ボタンをクリックすると、データが送信されること
- 『ユーザ名』だけ空文字列にして『送信』ボタンをクリックすると、データが送信されずにアラートメッセージが表示されること
まずは、すべての入力要素に値を入力し、 submit
関数が呼び出されていることを確認するテストを実装してみます。
test("入力したデータが送信される", async () => { // 準備フェーズ const mockSubmit = jest.fn(); render(<UserForm submit={mockSubmit} />); const user = userEvent.setup(); // 実行フェーズ await user.type(screen.getByRole("textbox", { name: "ユーザ名" }), "太郎"); await user.type(screen.getByRole("textbox", { name: "ニックネーム" }), "たろちゃん"); await user.type(screen.getByRole("textbox", { name: "生年月日" }), "2023-03-27"); await user.click(screen.getByRole("button", { name: "送信" })); // 確認フェーズ expect(mockSubmit).toBeCalledWith({ name: "太郎", nickname: "たろちゃん", "birthday": "2023-03-16" }); });
実行フェーズの await user.type(screen.getByRole("textbox", { name: "ユーザ名" }), "太郎");
は、「アクセシブルな名前が『ユーザ名』であるような入力要素に "太郎" と入力する」という意味です。
確認フェーズでは、 submit
が呼ばれていることと、その引数として渡されるオブジェクトを検査しています。
次に、『ユーザ名』の入力要素に値を入力せずに送信ボタンをクリックした場合のテストを実装します。このとき、 submit
関数が呼び出されず、アラートメッセージが表示されることを確認したいです。
test("ユーザ名の入力がないと、データが送信されない", async () => { // 準備フェーズ const mockSubmit = jest.fn(); render(<UserForm submit={mockSubmit} />); const user = userEvent.setup(); // 実行フェーズ await user.type(screen.getByRole("textbox", { name: "ニックネーム" }), "たろちゃん"); await user.type(screen.getByRole("textbox", { name: "生年月日" }), "2023-03-27"); await user.click(screen.getByRole("button", { name: "送信" })); // 確認フェーズ expect(mockSubmit).not.toBeCalled(); const alertTextBox = await screen.queryByRole("alert"); expect(alertTextBox).toHaveTextContent("ユーザ名が入力されていません。"); });
前のテストと比べて、実行フェーズにおけるはアクセシブルな名前が『ユーザ名』である入力要素への入力がなくなりました。
確認フェーズでは、 submit
が呼ばれなくなったことを確認しています。また、アラートが表示され、そのテキストについても検査しています。
いずれのテストも、ユーザがアプリケーションを使う際の使用方法や状態を観測する方法がそのままテストに反映されているのがわかると思います*4。 このようなテストは、要素の順番が入れ替わったり一部の属性が変わったりしても影響を受けにくく、人にとって理解もしやすいです。なるべくシンプルなテストを実装できるように、ぜひ testing-library の提供している API を眺めて使い方を考えてみてください。
テストが書けないケース
もし、テストを書いた UI が次のようなマークアップで実装されているとどうでしょうか。
<p>ユーザ名</p> <input id="name" /> <p>ニックネーム</p> <input id="nickname" /> <p>生年月日</p> <input id="birthday" /> <div>送信</div>
この状態だと先ほどのテストを実行することはできなくなります。たとえば、『ユーザ名』という文字列は id="name"
である入力要素のアクセシブルな名前として認識されませんし、『送信』と書かれている要素はボタンとして認識されません。
この実装は、テスト容易性が低いという以前にマシンリーダビリティが低い状態にあります。マシンリーダビリティとは、機械にとってのコンテンツの読み取りやすさの度合いです。フロントエンドのテストは、機械がコンテンツを読み取ったり操作をしたりしてソフトウェアの動作をシミュレーションするという性質上、マシンリーダビリティが高いほうがテスト容易性が高くなる傾向にある。
フロントエンドのテストの導入の前に、マシンリーダビリティ(ひいてはアクセシビリティ)の向上を目指すとよいと思います。先ほどの実装は、たとえば、次のように修正することでマシンリーダビリティもテスト容易性も高まります。
<label for="name">ユーザ名</label> <input id="name" /> <label for="nickname">ニックネーム</label> <input id="nickname" /> <label for="birthday">生年月日</label> <input id="birthday" /> <button>送信</button>
運用するときの注意点
テストはソフトウェアの品質の向上になくてはならないですが、それ自体がソフトウェアの品質を向上させる魔法ではありません。 ソフトウェアの品質を上げられるようなテストを実装するために、次のようなことを事前に決めておくとよいです。
単体テストを作る目的を明確にする
バリデーションを含む入力フォームのコンポーネント、特定の API を叩くコンポーネント、他のページへの導線があるコンポーネントなどなど、多種多様なコンポーネントごとに必要になるテストは異なります。
単体テストを作る目的は、ソフトウェアの退行に対する保護やリファクタリングへの耐性を与えて、ソフトウェア開発プロジェクトの成長を持続可能にすることです*5。しかし、何をもってして「持続可能である」と主張するかは人によって変わります。
- どれだけのシナリオをカバーするテストを実装するか
- ソフトウェアがあるシナリオで仕様通りに動作することを検査するにはどのようなテストがよいか
- どういう状態のとき、"よいテストである"といえるか
これらは、チームの思想や対象となるソフトウェアによって回答は様々でしょう。カバレッジを上げることを目的としてテストが大量に実装しても、これらを軽視してしまうと技術的負債になってもったいなんです。テストに取り組む前に、テストを実装する目的をしっかり考えられるとよいテストの実装ができると思います。もちろん、これらのことはフロントエンド以外のテストでも同じです。
アクセシビリティのガイドラインを定める
前述のとおり、マシンリーダビリティが高いほど testing-library を使ったテストが実装しやすくなります。 testing-library を使ったテストの品質を高めるためには、アクセシビリティの向上が必須と言ってよいでしょう*6。 ただし、テストの品質の向上のためだけにアクセシビリティの向上を目指すと、本当にユーザによってアクセシブルなソフトウェアになるとは限りません。アクセシビリティを向上させる目的がわからなくなってしまっては本末転倒です。
たとえば、 img
タグに alt
属性を付与すると、 getByAltText
を使って要素を取得することができます。一方、 aria-label
を付与すると、 getByRole
を使ったクエリで要素を取得することができるようになります。テストだけを考えると getByRole
のほうがクエリの優先度が高いので aria-label
属性を付与するほうがよいように感じます。しかし、 alt
と aria-label
では挙動がことなり、一概に aria-label
を使うことがよいとは言えません *7。テストはあくまで内部品質の向上のために実装されるもので、内部品質の向上ために外部品質を棄損するのは避けたほうがよいです。 alt
属性を使った実装のほうがユーザ体験がよくなると判断したなら、 testing-library のクエリの優先度は無視して実装をするべきです。
まずはテストを気にせずに、アクセシビリティのガイドラインを定めるのがよいでしょう。そして、制定されたガイドラインをもとにした実装に対するテストの実装方法について検討します。 もし、ガイドラインに沿った実装ではテストを実装しづらいと感じるならば、ユーザへの影響がない範囲でガイドラインを改定するのがよいと思います。
おわりに
テストはユーザ体験に影響を与えませんが、開発者体験の向上に大きく寄与します。せっかくテストを作るのだから、より効果的なテストを書いて開発者体験を向上させたいです。
今回はフロントエンドの単体テストという観点でよいテストの書き方について考えましたが、 VRT や E2E テストでは、また違った観点が必要になります。様々なテストを使いこなすまでの道のりは長いですが、少しづつ改善していけるように努力していきたいです。
*1:画像引用: https://testingjavascript.com
*2:https://ph-fritsche.github.io/blog/post/why-userevent
*4:コミュニケーションベーステストはその性質上、ユーザが知覚するままのテストにはならないです。submit が呼ばれることの検査はコミュニケーションベーステストです。
*6:https://logmi.jp/tech/articles/328087#s3
*7:https://www.scottohara.me/blog/2019/05/22/contextual-images-svgs-and-a11y.html#images-that-convey-information