メインコンテンツまでスキップ

役割でテストする: ヘッドレス UI がテストにもたらすもの

ここからの 4 章はテストを扱います。道具の話に入る前に、この章で決めておくことが 1 つあります。テストが画面の要素を探すとき、何を手がかりにするかです。

ヘッドレス UI を使うと、この問いに答えやすくなります。Base UI が付けてくれた役割と名前が、そのままテストの手がかりになるからです。

この章で学ぶこと​

  • クラス名や要素の入れ子を手がかりにすると何が起きるか
  • Base UI が付ける役割と名前を、テストでどう使うか
  • 表示用の計算を純粋関数へ切り出すと、テストがどう変わるか
  • テストを 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.Triggerbutton の役割。中の文字が名前になるgetByRole('button', { name: /お知らせ/ })
Dialog.Popup と Dialog.Titledialog の役割。タイトルの文字が名前になるgetByRole('dialog', { name: 'お知らせ' })
Dialog.Descriptionダイアログの説明toHaveAccessibleDescription('…') で確かめる
Dialog.Closebutton の役割。中の文字が名前になる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 つの関数のうち、件数を数える関数と、バッジの文言を作る関数を再掲します。

src/lib/notifications.ts
/** バッジに出す件数の上限。これを超えたら「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 つの関数は、どれも純粋関数です。同じ引数を渡せば必ず同じ値が返り、引数にも外の世界にも手を加えません。画面も、通信も、現在の時刻も使っていません。

純粋関数のテストは、引数を渡して戻り値を見るだけで書けます。

src/lib/notifications.test.ts
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 も、要素を探す処理も、操作を待つ処理も出てきません
  • 差し替えるものがありません。副作用を持たないので、モックを用意する相手がいません
  • 境界の値を並べやすくなります。バッジの文言は 0 件、1 件、99 件、100 件で変わります。関数なら表に 1 行足すだけですが、同じことを画面の操作で確かめるなら、4 通りのデータで描画し直すことになります

手元で 5 回実行すると、この 11 本は合わせて 5 ミリ秒から 6 ミリ秒で終わりました。次の章で書くダイアログのテストは、8 本で約 0.8 秒かかります。

この分け方は、ヘッドレス UI の分け方の続きにあります。

受け持つもの置き場確かめ方
開閉、フォーカス、ARIABase UI役割と名前で探すテストで、パーツの置き方を確かめる
表示用の計算、データの変換純粋関数引数と戻り値のテスト
何をどこに並べるか、どんな見た目かコンポーネントstory と、少数のコンポーネントのテスト

振る舞いを Base UI へ、計算を純粋関数へ出すと、コンポーネントに残るのは「受け取った値を並べる」ことと「操作を親へ伝える」ことになります。コンポーネントのテストで確かめる分岐が減るので、本数も少なくて済みます。

何でも関数へ切り出せばよい、という話ではありません。切り出しやすいのは、React の機能 (状態、副作用、コンテキスト) を使っていない計算です。切り出すかどうかの判断は、ロジックをフックへ押し出す が扱っています。

テスト 3 層の全体像​

本ガイドは、テストを 3 つの層に分けます。

図は第 1 層を下に描いています。以降の章で「下の層」と書いたら第 1 層の側、「上の層」と書いたら第 3 層の側を指します。

層道具動く場所得意なこと向かないこと
1Vitest と React Testing LibraryNode.js の上の jsdom計算のロジック、開閉、役割と名前の確認。1 本あたりが速いCSS が当たらないので、見た目と遷移は確かめられない
2Storybook (play 関数、a11y アドオン、Chromatic)実ブラウザ状態ごとの見た目、a11y の自動検査、見た目の差分の検知story にしていない状態は対象にならない
3Vitest Browser Mode と Playwright実ブラウザ本物のキー入力、CSS の遷移、ページに組み込んだ状態での操作1 本あたりが遅く、準備も多い

第 2 層の「見た目の差分の検知」は、ビジュアルリグレッションテストと呼ばれるものです。07 章で扱います。

下の層ほど本数を多く、上の層ほど少なくする配分は、テストピラミッドと同じ考え方です。手元で、各層のコマンドの実行にかかった時間を 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。

フォーカスの閉じ込めのように、キーを押したあとのフォーカスを確かめるテストでは、この違いが「いつ確かめれば正しい結果が見えるか」の違いになります。06 章と 08 章で実際に測ります。

まとめ​

  • クラス名や要素の入れ子を手がかりにしたテストは、動作が変わっていなくても落ちます
  • Base UI のパーツは役割と名前を持っているので、getByRole でそのまま探せます
  • 役割と名前で探すテストは、操作の検証と同時に、パーツを正しく置いたことも確かめます
  • 表示用の計算は純粋関数へ出すと、描画もモックも無しでテストできます
  • テストは 3 層に分けます。第 2 層と第 3 層はどちらも実ブラウザで動き、違いは検証の単位と入力の作り方にあります

次に読む​

Footnotes​

  1. Vitest の Browser Mode は、@testing-library/user-event を「イベントを実際に起こす代わりに模擬する」ものと説明しています。vitest/browser の userEvent については、provider に応じて Chrome DevTools Protocol か WebDriver を使う、としています。Storybook の play 関数が受け取る userEvent が Testing Library の user-event であることは、Interaction tests にあります。user-event が移動先を選ぶ処理は、@testing-library/user-event 14.6 のソースの getTabDestination にあります。 ↩