振る舞いをテストする: 実装の内側に触らない
コンポーネントのテストは、何に対して書くかで寿命が変わります。
見た目を作るコードは、よく書き換わります。クラス名が変わり、要素の入れ子が変わり、状態の持ち方が変わる。そのたびにテストが落ちるなら、テストは開発を遅くするだけのものになります。
落ちてほしいのは、利用者から見た振る舞いが変わったときだけです。この章は、そのためにどこへテストを当てるかを扱います。
この章で学ぶこと
- Testing Library の基本的な書き方
- 内側に寄ったテストがリファクタで落ちる様子
- 利用者の視点で要素を取る書き方と、その選び方
02 Red-Green-Refactor を読んでいることを前提にします。第 2 部を読んでいなくても、この章から入れます。
この章で扱わないこと
| 観点 | 参照先 |
|---|---|
| Vitest の設定、jsdom と happy-dom の選定、カバレッジ | TypeScript 開発ツールチェーン |
| React のレンダリングの仕組み、再描画の条件 | コンポーネント |
カスタムフックを renderHook で単体テストする書き方 | カスタムフック |
| 非同期の待ち方、通信を伴う画面のテスト | 10 非同期とネットワーク境界 |
この章で作るもの
08 章で決めた API を叩く画面のうち、通信を伴わない部分を作ります。予約フォームの入力と、送信前の検証表示です。通信は次の章で足します。
仕様は 1 つです。終了時刻が開始時刻より後でなければ、送信できず、その理由が画面に出ます。
Testing Library の最小面
中心になるのは 4 つです。
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
it('入力した値が反映される', async () => {
const user = userEvent.setup()
render(<ReservationForm />)
await user.type(screen.getByLabelText('開始時刻'), '13:00')
expect(screen.getByLabelText('開始時刻')).toHaveValue('13:00')
})
| 名前 | 役割 |
|---|---|
render | コンポーネントを DOM に描画する |
screen | 描画された DOM から要素を探す入り口 |
getByRole / getByLabelText / getByText | 利用者が要素を見つける手がかりで探す |
userEvent | クリックや入力を、実際の操作に近い形で起こす |
screen が返すのは DOM の要素です。コンポーネントのインスタンスではありません。Testing Library はこれを方針として掲げています1。
API の詳細は上の参照先に譲ります。この章で使うのは、この 4 つとあと 1 つだけです。
内側に寄ったテストを書いてみる
まず、落ちやすい書き方を意図的にします。2 つ並べます。どちらもエラー表示を、利用者に見えないものを手がかりに探しています。1 つ目は CSS クラスです。
it('終了が開始より前ならエラーを出す', async () => {
const user = userEvent.setup()
const { container } = render(<ReservationForm />)
await user.type(screen.getByLabelText('開始時刻'), '14:00')
await user.type(screen.getByLabelText('終了時刻'), '13:00')
await user.click(screen.getByRole('button', { name: '予約する' }))
expect(container.querySelector('.error-message')).toBeInTheDocument()
})
通ります。実装はこうなっています。
{error && <p className="error-message">{error}</p>}
もう 1 つ、よく見る書き方があります。data-testid というテスト用の目印を付け、getByTestId で探す形です (優先順位は後の「クエリの選び方」で扱います)。ここでは state 変数の名前をそのまま目印にしています。
it('終了が開始より前ならエラーを出す', async () => {
const user = userEvent.setup()
render(<ReservationForm />)
await user.type(screen.getByLabelText('開始時刻'), '14:00')
await user.type(screen.getByLabelText('終了時刻'), '13:00')
await user.click(screen.getByRole('button', { name: '予約する' }))
expect(screen.getByTestId('errorMessage')).toBeInTheDocument()
})
こちらは別案として置いたもので、上とは実装が違います。errorMessage という state を持っていて、目印はその名前をそのまま写しています。
{errorMessage && <p data-testid="errorMessage">{errorMessage}</p>}
どちらのテストも通ります。
リファクタして落とす
スタイルの整理で、クラス名を変えたとします。振る舞いは何も変えていません。
{error && <p className="form-feedback form-feedback--error">{error}</p>}
テストが落ちます。利用者から見て何も変わっていないのに落ちました。
落ちた理由を調べると、エラー表示は正しく出ています。テストが古いクラス名を探していただけです。テストを直して終わりますが、この作業に価値はありません。
2 つ目も同じです。あとで通信エラーを別に出すことになり、errorMessage では何のエラーか分からなくなったので validationError へ改名したとします。目印は変数名を写したものだったので、一緒に変わります。
{validationError && <p data-testid="validationError">{validationError}</p>}
これも落ちます。画面に出るものは、やはり何も変わっていません。
これが繰り返されると、2 つのことが起きます。テストを直す手間が惜しくなってリファクタを避けるようになるか、落ちたテストを反射的に直すようになって、本物の不具合を見逃すようになります。
使われ方に寄せて書き直す
利用者はクラス名も目印も知りません。知っているのは「エラーが表示される」ことだけです。そこを見ます。
it('終了が開始より前ならエラーを出す', async () => {
const user = userEvent.setup()
render(<ReservationForm />)
await user.type(screen.getByLabelText('開始時刻'), '14:00')
await user.type(screen.getByLabelText('終了時刻'), '13:00')
await user.click(screen.getByRole('button', { name: '予約する' }))
expect(screen.getByRole('alert')).toHaveTextContent('終了は開始より後にしてください')
})
この書き方なら、クラス名を変えても、目印を付け替えても落ちません。role="alert" を外したときと、文言を変えたときに落ちます。どちらも利用者に影響する変更です。
実装側は role を持たせます。
{error && <p role="alert" className="form-feedback form-feedback--error">{error}</p>}
**テストを先に書くと、この role が自然に付きます。**テストが「利用者はこれをエラーとして認識する」と要求するからです。支援技術にも同じ情報が伝わります。テストのために付けた属性ではありません。
何が違ったのか
3 つのテストは同じ機能を確かめていますが、どこに結びついているかが違います。
| 結びつく先 | 落ちるとき | |
|---|---|---|
| クラス名で探す | 見た目の実装 | スタイルを整理したとき |
| state 名の目印で探す | テスト用の目印 | 目印の名前を変えたとき |
| 役割と文言で探す | 利用者に見える意味 | 意味が変わったとき |
前の 2 つは、利用者が見ていないものに当てています。クラス名も、ここで使った目印も画面には出ません。だから画面が何も変わっていなくても落ちます。
逆向きの穴もあります。前の 2 つは要素が在ることしか見ていないので、エラーの文言が別の内容に差し替わっても通ります。利用者にとっては壊れているのに、テストは気づきません。3 つ目は文言まで見るので、そこで落ちます。
これは 05 章の状態検証と相互作用検証の区別と同じ構図です。何に結びつけるかを選ぶと、何で落ちるかが決まります。
クエリの選び方
要素を取る方法には優先順位があります。利用者に近い順です。
| 順 | 方法 | 何を手がかりにするか |
|---|---|---|
| 1 | getByRole | ボタン、見出し、入力欄といった役割と、そのラベル |
| 2 | getByLabelText | フォーム項目に付いたラベル |
| 3 | getByText | 画面に出ている文字そのもの |
| 4 | getByTestId | テスト用に付けた目印 |
上から順に試し、取れないときだけ下へ降ります。
getByTestId を最後に置くのは、それが利用者から見えないからです。data-testid を付ければ必ず取れますが、取れたことと利用者が見つけられることは別です。ボタンに data-testid を付けて取れてしまうと、そのボタンにアクセシブルな名前が無いことに気づけません。
ただし最後の手段として有効です。役割も文言も安定しない箇所 (例えば表の特定の行) では使います。使うこと自体が悪いのではなく、上から試さずに飛びつくのが問題です。
まとめ
- クラス名に結びついたテストは見た目を整理しただけで落ち、内部の名前を写した目印に結びついたテストは改名に巻き込まれて落ちます
- どちらも要素が在ることしか見ていないので、文言が別の内容に差し替わっても通ります
- 役割と文言で探すと、利用者に影響する変更でだけ落ちます
- テストを先に書くと、
roleのような利用者に見える手がかりが自然に付きます - クエリは利用者に近い順に試します。
getByTestIdは取れることを保証するだけで、見つけられることを保証しません
次に読む
- 10 非同期とネットワーク境界 — 08 章の API を叩く側を書きます
- カスタムフック —
renderHookを使ったフック単体のテスト