testing-library でユーザの気持ちになって書くフロントエンドのテスト
TL;DR
- フロントエンドのテストが壊れやすく要因の一つは、ユーザがどのようにソフトウェアを使うかをクエリに反映できていないからかも
- testing-library はソフトウェアを使うユーザの気持ちを反映させやすいようにクエリの優先度をつけていて、それに従うほうがいい
- 優先度の低いクエリも役に立つことがある
- 運用しているアクセシビリティなどの実装のガイドラインに沿うようなテストを作るとき
- アクセシビリティの低い実装をリファクタリングするためのテストを作るとき
はじめに
フロントエンドのテストに用いるツールとして testing-library が知られています。testing-library は提供しているクエリに優先度をつけています。この優先度は、どういう基準でつけられているのでしょうか。
この記事では、 testing-library のガイドを読みながら、クエリの優先度を「ユーザの気持ちになる」という観点で解釈し、効果的なテストの書き方について考えます。
フロントエンドのテストの難しさ
一般的なソフトウェアと比較してフロントエンドにおいて特筆すべき点に次のようなものがあります[1]。
- ユーザが要素を取得する
- ユーザが(マウスやキーボードの操作などの)アクションを実行する
- ユーザのアクションなどに応じて API リクエストをする
フロントエンドのテストが壊れやすかったり理解するのが難しくなったりする場合、とくに、要素の取得をシミュレートすることに躓いていることが多いように感じています。
要素の取得がうまく実現できていない例
次のような入力フォームに対してテストを書いてみましょう。『生年月日』の入力要素に文字列を入力するようなテストケースを書くことを考えてみます。
「生年月日」を含むフォームの UI
testing-library を使った次のようなコードで『生年月日』の入力要素を見つけることができます。
test("生年月日を見つけたい", () => {
render(<UserForm submit={async () => {}} />);
// プレースホルダーが"2023-02-11"の入力要素が生年月日のフォーム
const birthdayElement = screen.getByPlaceholderText("2023-02-11");
expect(birthdayElement).toBeInTheDocument();
});
このコードは screen.getByPlaceholderText("2023-02-11")
でプレースホルダーが"2023-02-11"の入力要素を取得しています。そのような入力要素はひとつだけなので、一意に要素を特定できます。birthdayElement
に対して文字列の入力などの操作ができるようになりました。
さて、screen.getByPlaceholderText("2023-02-11")
は本当に生年月日を入力する要素でしょうか?プレースホルダーは日付のフォーマットのようですが、テストだけ見てもどういう日付かを読み取ることはできません。荷物の配送日の指定かもしれないし、ブログの投稿予定日かもしれません。テストの意味が理解しにくく、テストの保守性が下がります。
また、『生年月日』のプレースホルダーが"2023-02-11"であることに必然性はないです。他の日付に変えられるかもしれないし、他の入力要素のプレースホルダーが"2023-02-11"になる可能性もあります。このようなあいまいな要素を元にテストを実装すると、意図しないタイミングでテストが落ちてしまいます。
マシンリーダビリティとテスタビリティ
マシンリーダビリティとは機械にとってのコンテンツを読み取りやすさの度合いのことです。ブラウザや支援技術などのユーザエージェントがコンテンツを解釈できるようにするためにはマシンリーダビリティの向上が欠かせません。また、 SEO 対策としてマシンリーダビリティを向上させるのも、検索エンジンという機械がコンテンツを読み取りやすくするためです[2]。
フロントエンドの自動テストも、機械がコンテンツを読み取ったり操作をしたりしてソフトウェアの動作を検査します。マシンリーダビリティが高いほうがテスタビリティが高くなる傾向にあります。
ユーザの気持ちになって書くテスト
筆者は、テストを実装するとき「自分がユーザとしてどのような気持ちでサービスを使うか」を考えるようにしています。
先ほどの例で挙げた UI において、ユーザが矢印を指した入力要素に生年月日を入力するのはなぜでしょうか。
生年月日を入力する理由は「『生年月日』というラベルがつけられた入力要素だから」のはずです。入力要素の3つ目だからではなく、プレースホルダーが"2023-02-11"だからでもないです。
この考え方をテストに反映させられると、テストをソフトウェアの使用方法に似せることができます。
次のクエリを使うと同じ入力要素を取得しますが、意味に違いがあります。1つ目のクエリが最もユーザの気持ちを反映している書き方です。
// アクセシブルな名前が『生年月日』なテキストの入力要素
screen.getByRole("textbox", { name: "生年月日" });
// プレースホルダーが "2023-02-11" である入力要素
screen.getByPlaceholderText("2023-02-11");
// 3番目のテキストの入力要素
screen.getAllByRole("textbox")[2];
テスタビリティ向上から取り組むアクセシビリティ向上
先ほどの例で、マークアップの実装がこのようになっていてはどうでしょうか。
<div>
<p>名前</p>
<input />
<p>住所</p>
<input />
<p>生年月日</p>
<input />
</div>
この状態では機械が入力要素と『生年月日』などのラベルの関連を把握できず、支援技術の活用も難しいです。テストにおいても、ユーザの気持ちを反映できる screen.getByRole("textbox", { name: "生年月日" })
のクエリを使うことができません。ユーザの気持ちを反映したテストを実装するためには、マシンリーダビリティが改善させなければなりません。
支援技術をあまり使わない人にとってマシンリーダビリティは関心を持ちづらいトピックかもしれませんが、テストを改善するためにマシンリーダビリティの改善を図るのは悪くないモチベーションの持ち方だと思っています。
testing-library のクエリと優先度
すでにいくつか例で挙げていますが、 testing-library のクエリを紹介します。 testing-library のクエリは、使い方と一緒に優先順位も与えられています。
優先順位は、テストをソフトウェアの使用方法に似せるという testing-library の基本方針に沿うように作られています。前述のとおり、フロントエンドのテストにおいては、ユーザがどのようにその要素を認識するかを反映させることで使用方法に似たテストを作ることができると解釈しています。
クエリの優先度は、大きく次のように決められています。
- 視覚・マウス操作・支援技術を使ったユーザ体験を反映したクエリ群(Queries Accessible to Everyone)
- HTML5 と ARIA に準拠したクエリ群(Semantic Queries)
- TestIDs を利用するクエリ(Test IDs)
視覚・マウス操作・支援技術を使ったユーザ体験を反映したクエリ群は、先述のとおり、使用方法に似たテストを作ることができます。
HTML5 と ARIA に準拠したクエリ群も同様にユーザ体験を反映しています。ただし、これらのクエリで使われる HTML5 や ARIA の属性は、使用するブラウザや支援技術によってユーザ体験が異なる場合があるようです。全てのユーザの体験を必ずしも反映させられないという理由で優先度がやや低いです。
TestIDs を利用するクエリは、 TestIDs がテスト専用の値なので、ソフトウェアの使用方法に似せることが難しいです。よって、優先度も一番低いです。
以下、それぞれのクエリについて詳細を追いながら、クエリ同士の優先度について考えています[3]。
getByRole
WAI-ARIA ロールをもとに要素を見つけることができるクエリです。たとえば、ボタン要素は role="button"
、input タグや textarea タグで作られるテキストの入力要素は role="textbox"
というロールが与えられます。
name
オプションを使ってアクセシブルな名前でフィルタを書けることができます。たとえば、次のようなマークアップで入力要素を作るとします。
<label for="name-input">名前</label>
<input id="name-input"/>
この入力要素は次のように取得することができます。このクエリの意味は、「ラベルが『名前』であるような入力要素を取得する」となり、まさにユーザが入力要素を認識する方法を反映しています。
screen.getByRole("textbox", { name: "名前" });
getByLabelText
入力要素に対して使うクエリです。 getByLabel("名前")
は「『名前』というラベルがつけられている入力要素を取得する」という意味です。
getByRole との比較
入力要素には、テキストの入力、チェックボックスの入力、ラジオボタンでの入力などのバリエーションがあります。 getByLabelText
では、これらのバリエーションを区別せずに、ラベルのみで判別します。
普段ウェブページを利用するとき、ユーザは「テキストの入力要素」「チェックボックスの入力要素」を区別して使用しています。 getByRole
では、これらを区別できるので getByLabelText
よりも優先度が高いです。
一方で、「textbox や checkbox などのバリエーションに関わらず入力要素とみなしたい」といったケースでは、ロールを識別しない getByRole
を使うほうが良いかもしれません。
次の例では、 getAllByRole
を使ってバリエーションを無視して入力要素の数を検査しています。
// ユーザに2つの入力操作を要求していることを検査する。
const inputElements = screen.getAllByLabelText(/ ?/); // 正規表現「/ ?/」はすべての文字列にマッチする
expect(inputElements).toHaveLength(2);
参考
getByPlaceholderText
テキストの入力要素に対して使うクエリです。 getByPlaceholderText("田中太郎")
は「プレースホルダーが"田中太郎"である入力要素を取得する」という意味です。
getByRole との比較
次のような実装において、入力要素を getByRole
と getByPlaceholderText
のそれぞれで取得することを考えてみます。
<label htmlFor="name">名前</label>
<input id="name" placeholder="田中太郎" />
クエリは次のようになります。
const inputElement = screen.getByRole("textbox", { name: "名前" });
// or
const inputElement = screen.getByPlaceholderText("田中太郎");
ユーザは、"名前"というラベルを見て名前を入力する要素だと理解するのであって、"田中太郎"というプレースホルダーを見て名前を入力する要素だと理解するわけではないです。仮に"山田花子"だとしても問題なく名前の入力欄だと認識できます。プレースホルダーはあくまで入力のヒントであり、要素の意味を説明するものではないです。ユーザが要素を探す方法を考えると、 getByRole
を使うことは妥当に思えます。
プレースホルダーをラベルとして使う危険性
プレースホルダーが要素の意味を説明しないから getByRole
を使うほうがよいと述べましたが、プレースホルダーをラベルとして使う場合はどうでしょうか。
紹介文に プレースホルダーをラベルとして使う危険性についての記事 が添えられています。
挙げられている問題
- 入力中に何を書くべきか忘れたとき、入力した文字を消してプレースホルダーを表示させないといけない
- 送信前に入力内容が正しいか確認できない
- エラーメッセージが何に対して書かれているのかわからなくなる
- フォームに注目するだけでプレースホルダーが消える場合、 Tab キー移動した際に何を書いていいかわからない(注目するまでフォームが画面外にいることがある)
- 空のフォームよりも目に止まりにくい
- プレースホルダーを初期値と勘違いしてしまう可能性がある
- ユーザが入力された値と勘違いし、自身が入力するのに不要な手間が必要だと誤解させてしまう
記事にあるとおり、プレースホルダーをラベルに使うのは控えたほうがよさそうです。また、 getByPlaceholderText
を多用していることを、これらの危険にさらされている可能性の指標にすることもできます。
getPlaceholderText
はラベルがない入力要素に対して用いる最終手段と思ったほうがよさそうです。
getByText
表示されている文字列に対して用いるクエリです。たとえば、ユーザに読ませたいテキストが表示されていることを検査できます。
screen.getByText("必須入力だからちゃんと書いてね!");
getByRole との比較
次のように実装した送信ボタンは getByText
で取得することができます。 getByRole
を使った取得と比較してみましょう。
<button>送信</button>
// 送信ボタンを取得するクエリ
// getByText を使った場合
screen.getByText("送信");
// getByRole を使った場合
screen.getByRole("button", { name: "送信" });
ユーザは、要素を「"送信"という文字」ではなく「"送信"と書いているボタン」として認識するはずです。 getByRole
のほうが、そのことをよく表現できています。
getByText
の使用はテキストが表示されていることだけの要素に限定したほうがよさそうです。
getByDisplayValue
入力要素に入力されている文字列を使って要素を取得するクエリです。getByDisplayValue("東京都")
は「"東京都"と入力されている入力要素を取得する」という意味です。
getByRole / getByLabel / getByPlaceholderText との比較
getByDisplayValue
がほかのクエリと異なる点は、要素の取得に用いるデータが動的である点です。要素の取得がテストのシナリオに大きく依存するので、扱いが難しくなります。
他のケースとして、 getByDisplayValue
は「~のときに、入力要素のデータが○○であること」のテストに使うことを考えてみます。下の例では、『入力』ボタンをクリックしたとき、ある入力要素の値が "hello" になっていることを検査しています。
await user.click(screen.getByRole("button", { name: "入力" }));
const inputElement = screen.getByDisplayValue("hello");
expect(inputElement).toBeInTheDocument();
しかし、多くのケースで「どの要素に入力されたか」までテストしたくなるでしょう。たとえば、『挨拶』というラベルの入力要素に "hello" と入力されたことをテストしたいのであれば、次のように書くことができます。上の例とは意味が異なるとわかると思います。
await user.click(screen.getByRole("button", { name: "入力" }));
const greetingInputElement = screen.getByRole("textbox", { name: "挨拶" });
expect(greetingInputElement).toHaveDisplayValue("hello");
筆者は getByDisplayValue
の利用を避けています。要素の取得と入力されているデータの検査が同時に行われてしまい、クエリの目的があいまいになると感じるからです。
getByAltText
alt 属性を使って要素を見つけるクエリです。 img タグの取得に使うことができます。
getByRole との比較
画像の取得に getByAltText
と getByRole
のどちらを使うかは img タグに指定する属性によって決まります。alt 属性を指定する場合は getByAltText
を使い、 aria-label / aria-labelledby 属性を使う場合は getByRole
を使います。
render(<img src="cat.png" alt="うちのネコちゃん" />);
screen.getByAltText("うちのネコちゃん");
render(<img src="cat.png" aria-label="うちのネコちゃん" />);
screen.getByRole("image", { name: "うちのネコちゃん" })
強いて比較するなら、クエリ同士の優先度というよりは実装時にどちらの属性を優先するかになると思います。筆者は、画像の読み込みに失敗したときに表示しようとした画像の説明が表示されるという点において、 alt 属性を優先して使うのがよいのではないかと考えています。
参考
getByTitle
title 属性を使って要素を見つけるクエリです。 title 属性は svg 要素に付与することができます。
<svg>
<title>閉じる</title>
<g><path /></g>
</svg>
getByRole との比較
getByAltText
と getByRole
の比較と同様、 title 属性と aria-label / aria-labelledby 属性を比較してみましょう。
svg 要素で title 属性を使うと機械が理解しやすい状態を作ることができます。この状況では getByTitle
を使うことができます。懸念点を挙げるとすると、一部のユーザエージェントでツールチップとして title 属性を表示することがあります[4]。ふと svg 要素にマウスオーバーしたときにツールチップが表示されると、ユーザは少し戸惑うかもしれません。
次のように role 属性と aria-label 属性を付与すると機械が理解しやすい状況を作り、かつツールチップが表示されなくなります[5]。この実装では、 getByRole
を使うことができます。
<svg role="img" aria-label="Accessible Name" focusable="false">
<use xlink:href="#..." aria-hidden="true"></use>
</svg>
いずれの実装も機械が理解しやすいいい実装だと思います。どちらを使うかは運用しているアクセシビリティのルールに基づいて決めるとよいとでしょう。
getByTestId
data-testid 属性を使って要素を取得します。 data-testid 属性は実行に影響を与えない属性で、ユーザは認知できません。また、要素と data-testid 属性の関連を把握しないとテストが書けないため、テストが実装の詳細を知りすぎてしまいます。
テストをソフトウェアの使用方法に似せるという基本方針により、テスト専用の属性の使用は控えたほうがよいです。クエリの優先度が最も低いです。
優先度の低いクエリの活用方法
優先度が低いクエリも使い方によっては効果的なテストを作ることができます。優先度の低いクエリを使うケースは大きく2つあると思います。
1つは、運用しているアクセシビリティのルールを表現するときです。たとえば、 img タグを使った画像の要素に aria-label 属性でアクセシブルな名前を付与することよりも alt 属性を付与するとチームで定めているとき、画像の取得には getByRole
よりも getByAltText
を使ったほうが直感的にわかりやすいです。
運用しているアクセシビリティのルールを変えてまで testing-library のクエリの優先度を優先するのは本末転倒です。
また、どうしても要素の意味を見出すのが難しい場合には getByTestId
を使うのもありだと思います[6]。
2つ目のケースは、既存の実装がアクセシブルではないけどテストを実装したくなった場合です。
アクセシビリティが低く優先度が高いクエリを使った効果的なテストが作れない場合、実装を直したくなります。しかし、テストがない状態での実装の修正は非常に危険です(テストを書くのは安全に開発するためだったはずなのに、それに逆行しています)。
次のようなステップを踏むと、アクセシブルな実装と効果的なテストの両方をより安全に作ることができます。
- 既存の実装を修正せずに作れるテストを、クエリの優先度を気にせずに実装する
- テストが落ちないようにリファクタリングし、アクセシビリティの高い状態にする
- ソフトウェアの使用方法に似せたテストになるよう、テストをリファクタリングする
getByTestId はテストを作り始めるときに役に立つ
getByTestId
は積極的に使われるべきではないですが、テストが実装されていないコンポーネントに対して初めてテストを書くときには便利です。 data-testid 属性は実行時に影響を与えない属性なので、既存の動作を変化させずにテストを書くことができます。筆者は、テストの作り初めにおいては getByTestId
はむしろ積極的に使ってもいいと思っています。
次のような実装をしたコンポーネントがあったとします。アクセシビリティが高くなるようにリファクタリングしたくなりました。
<div>
<p>名前</p>
<input />
<p>住所</p>
<input />
<div onClick={handleClick}>送信</div>
</div>
ラベルとして使いたい文字列に p タグを使ったり、ボタンとして使いたい要素を div タグで実装したり、適切でない要素が使われているせいで getByRole
などのクエリを使ってテストを作ることが難しいです。また、退行を防ぐという観点において、テストを書かずに実装を修正することは避けたいです。
data-testid 属性と getByTestId
を使って次のようにテストを書くことで、元の挙動に影響を与えずにテストを作ることができます。
まずはテストで操作したい要素に data-testid 属性を付与します。
<div>
<p>名前</p>
<input
+ data-testid="test-input-name"
/>
<p>住所</p>
<input
+ data-testid="test-input-address"
/>
<div
onClick={handleClick}
+ data-testid="test-button-submit"
>
送信
</div>
</div>
次に、 getByTestId
クエリを使ってテストを実装します。
const nameInput = screen.getByTestId("test-input-name");
const addressInput = screen.getByTestId("test-input-address");
const submitButton = screen.getByTestId("test-button-submit");
...
続いて、 data-testid 属性を残したまま、実装をリファクタリングします。目標は getByRole
などの優先度の高いクエリを使えるような状態にすることです。
<div>
- <p>名前</p>
+ <label htmlFor="input-name">名前</label>
<input
+ id="input-name"
data-testid="test-input-name"
/>
- <p>住所</p>
- <label htmlFor="input-address">住所</label>
<input
+ id="input-address"
data-testid="test-input-address"
/>
- <div
+ <button
onClick={handleClick}
data-testid="test-button-submit"
>
送信
- </div>
+ </button>
</div>
これで優先度の高い getByRole
に置き換えることができるようになりました。テストを直したあとに data-testid 属性を削除して完了です。
const nameInput = screen.getByRole("textbox", { name: "名前" });
const addressInput = screen.getByRole("textbox", { name: "住所" });
const submitButton = screen.getByRole("button", { name: "送信" });
...
おわりに
testing-library が提供しているクエリの優先度を理解して使うことで、より効果的なテストが書けるようになります。どのクエリを使うべきか迷ったときは、ユーザの気持ちになることに立ち返って理解しやすいテストが書けると、開発者体験の向上につながると思います。
-
「関数
add(x, y)
に対してadd(1, 2)
を実行すると結果は3になる」といったロジックのテストはフロントエンドで使われますが、他のソフトウェアでも使われるので特筆すべき点として挙げてないです ↩︎ -
参考 Webアプリケーションアクセシビリティ p43-45 https://gihyo.jp/book/2023/978-4-297-13366-5 ↩︎
-
この記事では getByXxx と getAllByXxx のクエリのみを使って要素の判定のために何を考えるべきかを考察します。getByXxx の他に findByXxx や queryByXxx のようなクエリもありますが、同じように考えられます。 ↩︎
-
参考 https://developer.mozilla.org/ja/docs/Web/SVG/Element/title ↩︎
-
参考 https://www.scottohara.me/blog/2019/05/22/contextual-images-svgs-and-a11y.html#svgs-that-convey-information ↩︎
-
要素の意味を見出すのが難しい場合に、その要素を取得するようなテストが本当に必要かは考える必要があります ↩︎
Discussion
リファクタリングは「ソフトウェア開発において、プログラムの動作や振る舞いを変えることなく、内部の設計や構造を見直し、コードを書き換えたり書き直したりすること」です(引用)。
アクセシビリティが高くするためにはプログラムの動作や振る舞いを変えることになるので、リファクタリングと呼べないように思います。
「アクセシビリティが高くなるように改善する」というのが正しそうです。