役割でテストする: ヘッドレス UI がテストにもたらすもの
ここからの 4 章はテストを扱います。道具の話に入る前に、この章で決めておくことが 1 つあります。テストが画面の要素を探すとき、何を手がかりにするかです。
ヘッドレス UIヘッドレス UI (Headless UI)見た目を持たず、振る舞いと HTML の構造と支援技術向けの属性だけを提供する UI 部品、またはその部品集。色や余白などのスタイルは使う側が書く。 を使うと、この問いに答えやすくなります。Base UI が付けてくれた役割と名前が、そのままテストの手がかりになるからです。
この章で学ぶこと
- クラス名や要素の入れ子を手がかりにすると何が起きるか
- Base UI が付ける役割と名前を、テストでどう使うか
- 表示用の計算を純粋関数純粋関数 (Pure Function)同じ引数なら常に同じ値を返し、外部の状態を変更しない関数。テストが書きやすく、結果を予測しやすい。へ切り出すと、テストがどう変わるか
- テストを 3 つの層に分けたときの、それぞれの持ち場
この章で扱わないこと
| 観点 | 参照先 |
|---|---|
| 役割で探すテストの考え方と、クエリの優先順位 | 振る舞いをテストする |
| ロジックを関数やフックへ切り出すかどうかの判断 | ロジックをフックへ押し出す |
| 各層のテストの書き方 | 06 単体テスト から 08 実ブラウザでテストする |
手がかりの選び方で、テストが落ちる場面が決まる
03 章のダイアログ本体には、次のクラスが付いています。
fixed top-1/2 left-1/2 flex w-96 max-w-[calc(100vw-2rem)] -translate-x-1/2 -translate-y-1/2
flex-col gap-4 rounded-xl bg-white p-6 text-slate-900 shadow-xl transition-[opacity,scale]
duration-200 data-starting-style:scale-95 data-starting-style:opacity-0
data-ending-style:scale-95 data-ending-style:opacity-0
テストがダイアログを探すのに、このクラスのどれかを使ったとします。角丸を rounded-xl から rounded-lg に変えただけで、そのテストは落ちます。開閉の動作は何も変わっていないのにです。
要素の入れ子を手がかりにしても、事情は似ています。02 章で見たとおり、Base UI はダイアログ本体の前後に、フォーカスを受け止めるための要素を足しています。こうした内部の構造は、ライブラリの版が上がれば変わりえます。「幕の次の要素」のような探し方は、自分が何も変えていなくても落ちる可能性があります。
手がかりごとに、テストが落ちる場面を並べます。
| 手がかり | テストが落ちる場面 | その変更は利用者に影響するか |
|---|---|---|
| Tailwind CSS のクラス名 | 見た目を調整したとき | 動作には影響しない |
| 要素の入れ子や並び順 | マークアップを整理したとき。ライブラリの版を上げたとき | 影響しないことが多い |
| 役割と名前 | ダイアログでなくなったとき。名前が変わったとき | 影響する |
テストに落ちてほしいのは、3 行めの場面だけです。この考え方そのものは 振る舞いをテストする が詳しく扱っています。ここでは、ヘッドレス UI を使うとそれが実行しやすくなる理由を見ます。
Base UI が付けた情報をそのまま手がかりにする
役割と名前で探すには、探される側の要素に役割と名前が付いている必要があります。自作のモーダルでは、ここが最初の障害になります。role="dialog" や aria-labelledby を書き忘れていれば、テストから探す手段がそもそもありません。
Base UI のパーツは、置いた時点で役割と名前を持っています。
| パーツ | 付くもの | テストでの探し方 |
|---|---|---|
Dialog.Trigger | button の役割。中の文字が名前になる | getByRole('button', { name: /お知らせ/ }) |
Dialog.Popup と Dialog.Title | dialog の役割。タイトルの文字が名前になる | getByRole('dialog', { name: 'お知らせ' }) |
Dialog.Description | ダイアログの説明 | toHaveAccessibleDescription('…') で確かめる |
Dialog.Close | button の役割。中の文字が名前になる | getByRole('button', { name: '閉じる' }) |
ここでいう名前はアクセシブルネームのことです。スクリーンリーダーが要素を読み上げるときに使う名前で、ボタンなら中の文字、ダイアログなら aria-labelledby が指す要素の文字から決まります。getByRole の name は、このアクセシブルネームと照合されます。
getByRole('dialog', { name: 'お知らせ' }) という 1 行は、2 つのことを同時に確かめています。ダイアログが開いていることと、そのダイアログが支援技術から「お知らせ」という名前で見えていることです。
後者が確かめられていることは、実装を壊してみると分かります。03 章のコードで Dialog.Title を素の h2 に置き換えると、画面の見た目は変わりません。それでも、次の章で書く 8 本のテストのうち 4 本が落ちます。どれも、名前を指定してダイアログを探しているテストです。見出しとダイアログを結ぶ aria-labelledby が付かなくなり、ダイアログが名前を失うためです。クラス名で探すテストでは、この壊れ方に気づけません。
ライブラリに任せた部分も、テストの対象から外れるわけではありません。Base UI が正しく動くことを確かめ直す必要はありませんが、自分がパーツを正しく置いたことは確かめる価値があります。役割と名前で探すテストは、操作の検証と一緒にそれを確かめます。
コラム: テストしやすい設計と純粋関数
03 章のコンポーネントは、未読の件数やバッジの文言を自分では計算せず、src/lib/notifications.ts の関数に任せていました。03 章に載せた 4 つの関数のうち、件数を数える関数と、バッジの文言を作る関数を再掲します。
/** バッジに出す件数の上限。これを超えたら「99+」と表示する。 */
const BADGE_MAX = 99
/**
* 未読のお知らせの件数を数える。
*
* @param notifications - お知らせの一覧
* @returns 未読 (`read` が false) の件数
*/
export function countUnread(notifications: readonly Notification[]): number {
return notifications.filter((notification) => !notification.read).length
}
/**
* 未読件数を、ボタンに添えるバッジの文言に変える。
*
* @param count - 未読件数
* @returns 0 件以下なら空文字、上限を超えたら「99+」、それ以外は件数そのまま
*/
export function formatUnreadBadge(count: number): string {
if (count <= 0) return ''
if (count > BADGE_MAX) return `${BADGE_MAX}+`
return String(count)
}
4 つの関数は、どれも純粋関数です。同じ引数を渡せば必ず同じ値が返り、引数にも外の世界にも手を加えません。画面も、通信も、現在の時刻も使っていません。
純粋関数のテストは、引数を渡して戻り値を見るだけで書けます。
import { describe, expect, it } from 'vitest'
import {
countUnread,
describeNotifications,
formatUnreadBadge,
markAllRead,
type Notification,
} from './notifications'
// 純粋関数のテストは、入力を渡して戻り値を見るだけで書ける。描画もモックも要らない
const notifications: Notification[] = [
{ id: 'n1', title: 'メンテナンスのお知らせ', read: false },
{ id: 'n2', title: '新機能を公開しました', read: false },
{ id: 'n3', title: '請求書を発行しました', read: true },
]
describe('countUnread', () => {
it('未読の件数を返す', () => {
expect(countUnread(notifications)).toBe(2)
})
it('空の配列なら 0 を返す', () => {
expect(countUnread([])).toBe(0)
})
})
describe('formatUnreadBadge', () => {
// 境界 (0 と 1、99 と 100) をまたぐ値を並べて確かめる
it.each([
[0, ''],
[1, '1'],
[99, '99'],
[100, '99+'],
[1000, '99+'],
])('%i 件のとき「%s」を返す', (count, expected) => {
expect(formatUnreadBadge(count)).toBe(expected)
})
})
describe('describeNotifications', () => {
it('お知らせが無いとき', () => {
expect(describeNotifications([])).toBe('お知らせはありません。')
})
it('未読があるとき、件数を含める', () => {
expect(describeNotifications(notifications)).toBe('未読のお知らせが 2 件あります。')
})
it('すべて既読のとき', () => {
expect(describeNotifications(markAllRead(notifications))).toBe('すべて確認済みです。')
})
})
describe('markAllRead', () => {
it('全件を既読にした新しい配列を返し、元の配列は変えない', () => {
const result = markAllRead(notifications)
expect(result.every((notification) => notification.read)).toBe(true)
// 元の配列がそのまま残っていること (副作用が無いこと) も確かめる
expect(notifications[0].read).toBe(false)
})
})
このテストには、コンポーネントのテストに付きものの手間がありません。
- 描画しません。
renderも、要素を探す処理も、操作を待つ処理も出てきません - 差し替えるものがありません。副作用副作用 (Side Effect)戻り値を返す以外に、外部の状態を変えてしまう処理のこと。ファイル書き込み・DB 更新・グローバル変数の書き換えなどが該当する。を持たないので、モックを用意する相手がいません
- 境界の値を並べやすくなります。バッジの文言は 0 件、1 件、99 件、100 件で変わります。関数なら表に 1 行足すだけですが、同じことを画面の操作で確かめるなら、4 通りのデータで描画し直すことになります
手元で 5 回実行すると、この 11 本は合わせて 5 ミリ秒から 6 ミリ秒で終わりました。次の章で書くダイアログのテストは、8 本で約 0.8 秒かかります。
この分け方は、ヘッドレス UI の分け方の続きにあります。
| 受け持つもの | 置き場 | 確かめ方 |
|---|---|---|
| 開閉、フォーカス、ARIA | Base UI | 役割と名前で探すテストで、パーツの置き方を確かめる |
| 表示用の計算、データの変換 | 純粋関数 | 引数と戻り値のテスト |
| 何をどこに並べるか、どんな見た目か | コンポーネント | story と、少数のコンポーネントのテスト |
振る舞いを Base UI へ、計算を純粋関数へ出すと、コンポーネントに残るのは「受け取った値を並べる」ことと「操作を親へ伝える」ことになります。コンポーネントのテストで確かめる分岐が減るので、本数も少なくて済みます。
何でも関数へ切り出せばよい、という話ではありません。切り出しやすいのは、React の機能 (状態、副作用、コンテキスト) を使っていない計算です。切り出すかどうかの判断は、ロジックをフックへ押し出す が扱っています。
テスト 3 層の全体像
本ガイドは、テストを 3 つの層に分けます。
図は第 1 層を下に描いています。以降の章で「下の層」と書いたら第 1 層の側、「上の層」と書いたら第 3 層の側を指します。
| 層 | 道具 | 動く場所 | 得意なこと | 向かないこと |
|---|---|---|---|---|
| 1 | Vitest と React Testing Library | Node.js の上の jsdom | 計算のロジック、開閉、役割と名前の確認。1 本あたりが速い | CSS が当たらないので、見た目と遷移は確かめられない |
| 2 | Storybook (play 関数、a11y アドオン、Chromatic) | 実ブラウザ | 状態ごとの見た目、a11y の自動検査、見た目の差分の検知 | story にしていない状態は対象にならない |
| 3 | Vitest Browser Mode と Playwright | 実ブラウザ | 本物のキー入力、CSS の遷移、ページに組み込んだ状態での操作 | 1 本あたりが遅く、準備も多い |
第 2 層の「見た目の差分の検知」は、ビジュアルリグレッションテストビジュアルリグレッションテスト (VRT)画面を画像として保存しておき、変更後の画像と比べて、見た目の意図しない変化を検知するテスト。基準になる画像を baseline と呼ぶ。と呼ばれるものです。07 章で扱います。
下の層ほど本数を多く、上の層ほど少なくする配分は、テストピラミッドテストピラミッドどの層にどれだけテストを書くかの配分を、下を厚く上を薄くした三角形で表した図。単体テストを土台に置き、結合テストと E2E テストを絞る。と同じ考え方です。手元で、各層のコマンドの実行にかかった時間を 5 回ずつ測りました。表の時間は中央値です。
| 層 | 本数 | かかった時間 |
|---|---|---|
| 第 1 層 | 19 本 | 約 1.6 秒 |
| 第 2 層 | 5 本 | 約 3.0 秒 |
| 第 3 層 (Browser Mode) | 4 本 | 約 3.0 秒 |
ブラウザの起動を含めた時間を本数で割ると、実ブラウザを使う層は、第 1 層に比べて 1 本あたり 7 倍から 9 倍ほどかかっています。
第 2 層と第 3 層の違い
1 つ、誤解しやすい点があります。Storybook の play 関数も、現在の構成では実ブラウザで動きます。07 章で使う Vitest アドオンが、story を Chromium の上で実行するためです。第 2 層と第 3 層は、動く場所では分かれません。
違いは 2 つあります。
| 第 2 層 (Storybook) | 第 3 層 (Browser Mode、Playwright) | |
|---|---|---|
| 検証の単位 | story。コンポーネントの 1 つの状態 | 操作の流れ。story に結びつかない |
| 入力の作り方 | Testing Library の user-event。ページの中の JavaScript がイベントを作る | Playwright がブラウザの外から入力を送る |
2 つめの違いは、キー入力のテストに現れます。Tab キーを押したときにフォーカスがどこへ動くかは、本来ブラウザが決めます。user-event の tab() は、次にフォーカスする要素を DOM から自分で選び、その要素へフォーカスを直接移します。Playwright は、ブラウザに実際のキー入力を送り、移動先はブラウザに任せます1。
フォーカスの閉じ込めフォーカストラップ (Focus Trap)モーダルなどが開いている間、Tab キーで動くフォーカスをその中に閉じ込める仕組み。背景に隠れた要素へキーボードの操作が抜けるのを防ぐ。のように、キーを押したあとのフォーカスを確かめるテストでは、この違いが「いつ確かめれば正しい結果が見えるか」の違いになります。06 章と 08 章で実際に測ります。
まとめ
- クラス名や要素の入れ子を手がかりにしたテストは、動作が変わっていなくても落ちます
- Base UI のパーツは役割と名前を持っているので、
getByRoleでそのまま探せます - 役割と名前で探すテストは、操作の検証と同時に、パーツを正しく置いたことも確かめます
- 表示用の計算は純粋関数へ出すと、描画もモックも無しでテストできます
- テストは 3 層に分けます。第 2 層と第 3 層はどちらも実ブラウザで動き、違いは検証の単位と入力の作り方にあります
次に読む
- 06 単体テスト — 第 1 層を書きます
- 振る舞いをテストする — 手がかりの選び方を、落ちるテストの実例で追えます