実ブラウザでテストする: Vitest Browser Mode と Playwright
06 章で、jsdom では確かめられないことを 2 つ挙げました。遷移と、見えているかどうかです。フォーカスの閉じ込めフォーカストラップ (Focus Trap)モーダルなどが開いている間、Tab キーで動くフォーカスをその中に閉じ込める仕組み。背景に隠れた要素へキーボードの操作が抜けるのを防ぐ。は jsdom でも確かめられましたが、Tab キーの移動先を決めていたのは、ブラウザではなく user-event でした。
この章では、ブラウザに本物のキー入力を送り、ブラウザ自身が決めた移動先で確かめます。続けて、ダイアログをアプリのページに組み込んだ状態を、Playwright で通しで確かめます。最後に、3 つの層の持ち場を表にまとめます。
この章で学ぶこと
- Tab キーのテストで、確かめるタイミングによって結果の見え方が変わる理由
- Vitest Browser Mode の project を足す設定
- 本物のキー入力で、フォーカスの閉じ込めを確かめるテスト
- 閉じる遷移が最後まで再生されることを確かめるテスト
- Playwright でアプリのページを通しで確かめるテスト
- どの検証をどの層に置くかの基準
この章で扱わないこと
| 観点 | 参照先 |
|---|---|
| Playwright の全般的な使い方 | Playwright の公式ドキュメント |
| 複数のブラウザでの実行と、CI での並列化 | 本ガイドの範囲外です |
| 画像を比べる見た目の検証 (ビジュアルリグレッションテストビジュアルリグレッションテスト (VRT)画面を画像として保存しておき、変更後の画像と比べて、見た目の意図しない変化を検知するテスト。基準になる画像を baseline と呼ぶ。) | 07 Storybook でテストする |
同じ Tab キーを、3 つの方法で押す
06 章の Tab キーのテストでは、押すたびに waitFor で待ちました。待つ必要があるのは user-event を使ったからなのか、実ブラウザでも同じなのかを、測って比べます。
ダイアログを開いた状態で Tab キーを 5 回押し、フォーカスの位置を記録しました。押し方は 3 通りです。それぞれについて、待たずに続けて押す場合と、押すたびに数フレーム待つ場合を、5 回ずつ実行しています。フォーカスの位置は、どちらの場合も押した直後に見ます。待つ場合は、待ったあとにもう一度見ます。ダイアログの中でフォーカスできるのは、「すべて既読にする」と「閉じる」の 2 つのボタンです。ダイアログの外には、リンクを 1 つ置いてあります。
| 押し方 | 待たずに続けて押す | 押すたびに数フレーム待つ (待ったあとの位置) |
|---|---|---|
jsdom の上で user-event の tab() を呼ぶ (第 1 層) | 5 回とも、途中で body に出た | 5 回とも、2 つのボタンを行き来した |
実ブラウザの上で user-event の tab() を呼ぶ (第 2 層の play 関数) | 結果が 4 通りに分かれた。5 回のうち 3 回は、途中で内部の要素に止まっていた。外へは出なかった | 5 回とも、2 つのボタンを行き来した |
| 実ブラウザに本物のキー入力を送る (この章) | 5 回とも、2 つのボタンを行き来した | 5 回とも、2 つのボタンを行き来した |
「内部の要素」は、02 章で見た、Base UI がダイアログ本体の前後に置いている見えない要素です。
読み取れることが 3 つあります。
1 つめは、待てばどの方法でも同じ結果になることです。右の列は 3 行とも同じです。閉じ込めそのものは、jsdom の上でも実ブラウザの上でも働いています。
2 つめは、user-event では、送り直しの前の状態が見えることです。端のボタンで tab() を呼ぶと、フォーカスは内部の要素へ移り、tab() はその時点で戻ります。Base UI が中へ送り直すのは次のフレームなので、直後に見ると、まだ内部の要素にフォーカスがあることがあります。待たずに次のキーを押すと、そこを起点に移動先が選ばれます。jsdom の上では、その先が body になりました。実ブラウザの上では、外へ出た回はありませんでしたが、内部の要素に止まる位置が実行によって変わりました。左の列の結果は、測るたびに変わります。
3 つめは、本物のキー入力では、押した直後に見ても、どの回も送り直しが済んでいたことです。キー入力を 1 回送って制御が戻るまでの時間を 20 回ずつ 5 度測ると、中央値は 16.7 ミリ秒から 16.8 ミリ秒で、1 フレームの長さ (16.7 ミリ秒) と同じくらいでした。制御が戻るまでのあいだに送り直しが済んでいた、と読めます。ただし、間に合うことが保証されているわけではありません。
この章のテストは、3 つめの結果に頼りません。押した直後に 1 回だけ見る形にはせず、条件を満たすまで待つ形で書きます。
第 1 層でも書ける検査を、ここでも書く理由
フォーカスの閉じ込めは、06 章のとおり第 1 層でも確かめられます。それでもこの層に同じ検査を置くのは、Tab キーの移動先を決めているのが誰か、が違うためです。
第 1 層では、user-event が、CSS の当たっていない DOM から移動先を計算します。この層では、CSS の当たった画面に対して、ブラウザ自身が決めます。06 章の最後に見たとおり、CSS で隠した要素があると、第 1 層の結果は実際の Tab 順と食い違います。CSS の当たる実ブラウザの上で user-event を使った場合は、隠した要素は飛ばされました。それでも、移動先を計算しているのは user-event です。本ガイドのダイアログでは結果が一致しますが、ブラウザ自身の Tab 順で確かめたという裏付けは、この層でしか取れません。
Browser Mode の project を足す
Vitest の Browser Mode は、テストを Node.js の上ではなく、実ブラウザの中で実行する機能です1。ブラウザの起動と操作は Playwright が受け持ちます。
必要なもののうち、vitest、@vitest/browser-playwright、Chromium の本体の 3 つは、04 章の導入コマンドで入っています。足すのは、React のコンポーネントをブラウザの中で描画するためのパッケージです。
npm install -D vitest-browser-react
Storybook を入れていない場合は、npm install -D vitest @vitest/browser-playwright と npx playwright install chromium を先に実行してください。
vite.config.ts に 3 つめの project を足します。ここまでの章で足したものを含めた全体が次のコードです。
/// <reference types="vitest/config" />
import path from 'node:path'
import { storybookTest } from '@storybook/addon-vitest/vitest-plugin'
import tailwindcss from '@tailwindcss/vite'
import react from '@vitejs/plugin-react'
import { playwright } from '@vitest/browser-playwright'
import { defineConfig } from 'vite'
// https://vite.dev/config/
export default defineConfig({
// tailwindcss() を足すと、Vite が CSS を処理するときに Tailwind CSS のクラスが生成される
plugins: [react(), tailwindcss()],
optimizeDeps: {
/*
* ブラウザで動くテストは、Vite が依存パッケージを事前にまとめたものを読み込む。
* まとめたあとで新しい依存を使い始めると、次の実行の途中でページが読み込み直され、
* その回のテストが落ちる。先に対象へ入れておくと、読み込み直しが起きない。
*/
include: ['@base-ui/react/button', '@base-ui/react/dialog'],
},
test: {
/*
* テストを 3 つの project に分ける。
* extends: true は、上の plugins などをそのまま引き継ぐ指定。
*/
projects: [
{
// 1. 単体テスト: Node.js の上で jsdom (ブラウザの DOM を模したもの) を使って速く回す
extends: true,
test: {
name: 'unit',
environment: 'jsdom',
include: ['src/**/*.test.{ts,tsx}'],
// 実ブラウザ用のテストは 3 つ目の project に任せる
exclude: ['src/**/*.browser.test.tsx'],
setupFiles: ['./vitest.setup.ts'],
},
},
{
// 2. Storybook: story を 1 つずつテストとして実行する (実体は Chromium の上で動く)
extends: true,
plugins: [storybookTest({ configDir: path.join(import.meta.dirname, '.storybook') })],
test: {
name: 'storybook',
browser: {
enabled: true,
headless: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
},
{
// 3. 実ブラウザのテスト: jsdom では再現できない CSS の遷移やキー入力を確かめる
extends: true,
test: {
name: 'browser',
include: ['src/**/*.browser.test.tsx'],
browser: {
enabled: true,
headless: true,
provider: playwright(),
instances: [{ browser: 'chromium' }],
},
},
},
],
},
})
3 つめの browser という project は、*.browser.test.tsx という名前のファイルだけを対象にします。06 章の unit はこの名前を除外しているので、同じ src の下に置いても、それぞれの project が自分のファイルだけを実行します。
provider: playwright() と instances の指定は、2 つめの storybook と同じです。07 章の play 関数とこの章のテストは、同じ仕組みの上で動きます。07 章で足した optimizeDeps.include も、この project に効きます。
実ブラウザのテストを書く
テストの全体です。
import { describe, expect, it, vi } from 'vitest'
import { page, userEvent } from 'vitest/browser'
import { render } from 'vitest-browser-react'
// 実ブラウザでは CSS も本物が当たる。遷移を確かめるので、アプリと同じ CSS を読み込む
import '../index.css'
import type { Notification } from '../lib/notifications'
import { NotificationsDialog } from './notifications-dialog'
const notifications: Notification[] = [
{ id: 'n1', title: 'メンテナンスのお知らせ', read: false },
{ id: 'n2', title: '請求書を発行しました', read: true },
]
/** ダイアログを描画して開き、開く遷移が終わってフォーカスが中へ移るまで待つ。 */
async function openDialog() {
await render(
<>
<NotificationsDialog notifications={notifications} onMarkAllRead={vi.fn()} />
{/* ダイアログの外にあるリンク。フォーカスがここへ抜けないことを確かめるために置く */}
<a href="https://example.com/">ダイアログの外のリンク</a>
</>,
)
await page.getByRole('button', { name: /お知らせ/ }).click()
const dialog = page.getByRole('dialog', { name: 'お知らせ' })
await expect.element(dialog).toBeVisible()
// 不透明度が 1 になったら、開く遷移は終わっている
await expect.poll(() => getComputedStyle(dialog.element()).opacity).toBe('1')
await expect.element(page.getByRole('button', { name: 'すべて既読にする' })).toHaveFocus()
return dialog
}
describe('NotificationsDialog (実ブラウザ)', () => {
it('Tab キーを押し続けても、フォーカスがダイアログの外へ出ない', async () => {
const dialog = await openDialog()
// 中で Tab で止まれるのは 2 つ。それより多く押して、一巡しても外へ出ないことを見る
for (let i = 0; i < 5; i++) {
// userEvent.keyboard は、ブラウザに本物のキー入力を送る
await userEvent.keyboard('{Tab}')
// 端から反対の端への送り直しは次の描画のタイミングで起きるので、条件を満たすまで待つ形で確かめる
await expect.poll(() => dialog.element().contains(document.activeElement)).toBe(true)
}
})
it('Shift + Tab で逆向きに回っても、外へ出ない', async () => {
const dialog = await openDialog()
for (let i = 0; i < 5; i++) {
await userEvent.keyboard('{Shift>}{Tab}{/Shift}')
await expect.poll(() => dialog.element().contains(document.activeElement)).toBe(true)
}
})
it('閉じる遷移が終わるまで要素が残り、終わると消える', async () => {
const dialog = await openDialog()
const element = dialog.element()
/*
* 閉じる遷移が最後まで再生されたことを、transitionend イベントで受け取る。
* 遷移の途中で要素が DOM から外れると、このイベントは起きない。
* data-ending-style を見ているのは、開く遷移の終わりと区別するため。
*/
let closingTransitionEnded = false
element.addEventListener('transitionend', () => {
if (element.hasAttribute('data-ending-style')) closingTransitionEnded = true
})
await userEvent.keyboard('{Escape}')
await expect.poll(() => closingTransitionEnded).toBe(true)
await expect.element(dialog).not.toBeInTheDocument()
})
it('閉じると、フォーカスが開いたボタンへ戻る', async () => {
const dialog = await openDialog()
await userEvent.keyboard('{Escape}')
await expect.element(dialog).not.toBeInTheDocument()
await expect.element(page.getByRole('button', { name: /お知らせ/ })).toHaveFocus()
})
})
06 章のテストと見比べると、形はよく似ています。違いは道具の出どころです。
| 第 1 層 (06 章) | 第 3 層 (この章) | |
|---|---|---|
| 描画 | @testing-library/react の render | vitest-browser-react の render。Promise を返すので await します |
| 要素を探す | screen.getByRole(…) が要素を返す | page.getByRole(…) がロケーターを返す |
| 操作 | @testing-library/user-event | vitest/browser の userEvent と、ロケーターの click() |
| 検証 | expect(要素) | expect.element(ロケーター)。条件を満たすまで自動で繰り返します |
ロケーターは、要素そのものではなく「この条件で探す」という指定です。操作や検証のたびに、その時点の DOM から探し直します。expect.element は、検証が通るまで短い間隔で繰り返すので、06 章のように waitFor で包む必要がありません。要素以外の値を待ちたいときは、expect.poll に関数を渡します。
役割と名前で探す点は、どの層でも変わりません。getByRole('dialog', { name: 'お知らせ' }) という手がかりを、3 つの層で使い回しています。
CSS を読み込む
テストファイルの先頭で、CSS を読み込んでいます。
// 実ブラウザでは CSS も本物が当たる。遷移を確かめるので、アプリと同じ CSS を読み込む
import '../index.css'
Browser Mode では、読み込んだ CSS が実際に当たります。逆に言えば、読み込まなければ当たりません。遷移を確かめるテストなので、アプリと同じ CSS をテストファイルで読み込んでいます。04 章で Storybook の preview.tsx に書いた import と、役目は同じです。
フォーカスの閉じ込めを確かめる
最初の 2 本が、閉じ込めの検査です。
it('Tab キーを押し続けても、フォーカスがダイアログの外へ出ない', async () => {
const dialog = await openDialog()
// 中で Tab で止まれるのは 2 つ。それより多く押して、一巡しても外へ出ないことを見る
for (let i = 0; i < 5; i++) {
// userEvent.keyboard は、ブラウザに本物のキー入力を送る
await userEvent.keyboard('{Tab}')
// 端から反対の端への送り直しは次の描画のタイミングで起きるので、条件を満たすまで待つ形で確かめる
await expect.poll(() => dialog.element().contains(document.activeElement)).toBe(true)
}
})
it('Shift + Tab で逆向きに回っても、外へ出ない', async () => {
const dialog = await openDialog()
for (let i = 0; i < 5; i++) {
await userEvent.keyboard('{Shift>}{Tab}{/Shift}')
await expect.poll(() => dialog.element().contains(document.activeElement)).toBe(true)
}
})
userEvent.keyboard('{Tab}') は、Playwright を通じてブラウザにキー入力を送ります。フォーカスをどこへ動かすかは、ブラウザが決めます。押すたびに、フォーカスを持つ要素がダイアログの中にあることを、expect.poll で待つ形で確かめています。
openDialog の中で、ダイアログの外にリンクを 1 つ置いています。開くボタンのほかにも、Tab キーで抜け出せる先をはっきり用意しておくためです。
このテストが閉じ込めを確かめていることは、実装を壊して確かめました。Dialog.Root に modal={false} を渡して閉じ込めを切ると、2 本とも落ちます。
閉じる遷移を確かめる
3 本めは、03 章で書いた遷移の検査です。
it('閉じる遷移が終わるまで要素が残り、終わると消える', async () => {
const dialog = await openDialog()
const element = dialog.element()
/*
* 閉じる遷移が最後まで再生されたことを、transitionend イベントで受け取る。
* 遷移の途中で要素が DOM から外れると、このイベントは起きない。
* data-ending-style を見ているのは、開く遷移の終わりと区別するため。
*/
let closingTransitionEnded = false
element.addEventListener('transitionend', () => {
if (element.hasAttribute('data-ending-style')) closingTransitionEnded = true
})
await userEvent.keyboard('{Escape}')
await expect.poll(() => closingTransitionEnded).toBe(true)
await expect.element(dialog).not.toBeInTheDocument()
})
確かめたいのは、閉じる操作をしてもすぐには DOM から外れず、遷移が最後まで再生されることです。
素直に書くなら、Esc キーを押した直後に「まだ DOM にあり、data-ending-style が付いている」ことを確かめる形になります。ただし、その状態が続くのは 200 ミリ秒だけです。テストを実行する機械が混んでいて、最初の確認がそれより遅れると、正しく動いていても落ちます。
そこで、過ぎ去る状態を捕まえる代わりに、ブラウザが出すイベントを受け取っています。transitionend は、CSS の遷移が最後まで再生されたときに、その要素で起きるイベントです。遷移が無いときや、途中で打ち切られたときには起きません2。Esc キーを押す前に待ち受けておくので、確認が遅れても取りこぼしません。
リスナーの中で data-ending-style を見ているのは、開く遷移の終わりと区別するためです。transitionend は、開くときの遷移が終わったときにも起きます。
このテストも、壊して確かめています。ダイアログ本体から transition-[opacity,scale] duration-200 を外すと、遷移が無くなり、transitionend が起きないので落ちます。「遷移のクラスを誤って消した」という変更を、この 1 本が検知します。
実行する
npx vitest run --project=browser
✓ browser (chromium) src/components/notifications-dialog.browser.test.tsx (4 tests) 1983ms
✓ NotificationsDialog (実ブラウザ) (4)
✓ Tab キーを押し続けても、フォーカスがダイアログの外へ出ない 509ms
✓ Shift + Tab で逆向きに回っても、外へ出ない 399ms
✓ 閉じる遷移が終わるまで要素が残り、終わると消える 536ms
✓ 閉じると、フォーカスが開いたボタンへ戻る 537ms
Test Files 1 passed (1)
Tests 4 passed (4)
Duration 3.01s
--project を付けずに npx vitest run とすると、3 つの project がまとめて実行されます。
Playwright でページを通しで確かめる
ここまでのテストは、どれもコンポーネントを単体で描画していました。最後に、アプリのページに組み込んだ状態を確かめます。道具は Playwright のテストランナーです。
npm install -D @playwright/test
@playwright/test は、同じ版の playwright を必要とします。Playwright は版ごとに使うブラウザのビルドが決まっているので、04 章で入った playwright と版がずれると、対応する Chromium が入っていない状態になります3。そのときは npx playwright install chromium を実行します。
設定ファイルを置きます。
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
// Vitest のテスト (src/ の下) と混ざらないよう、E2E は e2e/ に分けて置く
testDir: './e2e',
use: {
// page.goto('/') の '/' がこの URL になる
baseURL: 'http://localhost:5173',
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
// テストの前に開発サーバーを起動し、終わったら止める
webServer: {
command: 'npm run dev -- --port 5173 --strictPort',
url: 'http://localhost:5173',
// 手元ではすでに動いている開発サーバーを使い回し、CI では必ず新しく起動する
reuseExistingServer: !process.env.CI,
},
})
webServer が要点です。テストの前に npm run dev で開発サーバーを起動し、応答が返るのを待ってからテストを始め、終わったら止めます。サーバーを手で起動しておく必要がありません。
設定ファイルとテストのディレクトリは、tsconfig.node.json の include に足しておきます。04 章の .storybook と同じで、足さないと npx tsc -b の型検査の対象になりません。
"include": ["vite.config.ts", "playwright.config.ts", "e2e"]
テストは 1 本です。
import { expect, test } from '@playwright/test'
test('お知らせを開いて既読にし、ダイアログの外を押して閉じる', async ({ page }) => {
// アプリのページをそのまま開く。コンポーネント単体ではなく、組み込まれた状態を確かめる
await page.goto('/')
const trigger = page.getByRole('button', { name: /お知らせ/ })
await trigger.click()
const dialog = page.getByRole('dialog', { name: 'お知らせ' })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText('未読のお知らせが 2 件あります。')
// 親 (App) が持つデータが書き換わり、表示が変わるところまでを通しで見る
const markAllRead = dialog.getByRole('button', { name: 'すべて既読にする' })
// 開いた時点で、フォーカスはこのボタンにある。キーボードで操作する人と同じように Enter キーで押す
await expect(markAllRead).toBeFocused()
await page.keyboard.press('Enter')
await expect(dialog).toContainText('すべて確認済みです。')
// 未読が 0 件になると、このボタンは無効になる。それでもフォーカスはボタンに残る
await expect(markAllRead).toBeFocused()
await expect(markAllRead).toHaveAttribute('aria-disabled', 'true')
// 画面の左上 (ダイアログの外) を押すと閉じる
await page.mouse.click(5, 5)
await expect(dialog).toBeHidden()
// 閉じたあと、フォーカスは開いたボタンへ戻る
await expect(trigger).toBeFocused()
})
このテストは、コンポーネント単体のテストでは通らない経路を通っています。
| 確かめていること | 単体のテストとの違い |
|---|---|
| 「すべて既読にする」を Enter キーで押すと、説明文が切り替わる | 03 章の App が持つデータが実際に書き換わり、ダイアログに渡し直されるところまでを通します。06 章のテストでは、関数が呼ばれたことしか見ていません |
| 押したあとも、フォーカスが「すべて既読にする」に残り、ボタンは無効になっている | 03 章で focusableWhenDisabled を付けた理由の場面です。キーボードで押した場合を通すために、開いた時点でフォーカスを持っているボタンを、そのまま Enter キーで押しています |
| ダイアログの外を押すと閉じる | page.mouse.click(5, 5) で、画面の左上の座標を押しています。要素を指定せず、利用者と同じように「幕のどこか」を押します |
| 閉じたあと、フォーカスが開くボタンへ戻る | アプリの実際のページ構成の中で確かめます |
2 行めのフォーカスの検査は、jsdom の上では書けません。focusableWhenDisabled を外したコンポーネントで「すべて既読にする」を押すと、Chromium ではフォーカスが body へ移りました。jsdom では、無効になったボタンがフォーカスを持ち続けました。jsdom の上でフォーカスの位置を確かめても、この不具合は見つかりません。
focusableWhenDisabled を外して 06 章のテストを実行すると、落ちるのは 8 本めだけです。落ちる理由は aria-disabled が付かなくなることで、フォーカスが外へ出たことを見て落ちるわけではありません。Playwright のこのテストは、同じ変更を、フォーカスの検査で検知します。
実行します。
npx playwright test
Running 1 test using 1 worker
✓ 1 [chromium] › e2e/notifications.spec.ts:3:1 › お知らせを開いて既読にし、ダイアログの外を押して閉じる (425ms)
1 passed (1.8s)
この層のテストは、数を増やしません。開閉やフォーカスの細かな場合分けは、下の層がすでに確かめています。ここで確かめるのは、部品がアプリの中で正しくつながっていることです。
Playwright には、コンポーネントを単体で実ブラウザに描画してテストする機能もあります。以前は @playwright/experimental-ct-react という実験的なパッケージで提供されていました。Playwright 1.62 で新しい方式が入り、1.63 からは以前のパッケージが更新されなくなっています4。
新しい方式では、story を書いたファイル (*.story.tsx) と、それを描画する gallery というページを自分のプロジェクトに用意します。テストは、@playwright/test に組み込みの mount に story の id を渡して開きます。テストの中に JSX を書いて mount へ渡す以前の書き方は、この方式にはありません。
本ガイドが、コンポーネント単体の実ブラウザのテストに Vitest Browser Mode を使ったのは、Storybook の story と Vitest の設定をそのまま使えるためです。Playwright の方式を採ると、Storybook とは別系統の story をもう 1 組持つことになります。
どの検証をどの層に置くか
3 つの層を書き終えたので、持ち場を整理します。Tab キーの行は、この章の最初に見た、3 つの押し方で測った結果です。そのほかの行の「向く」「書ける」「向かない」は、本ガイドで書いたテストと各層の仕組みから決めた判断です。仕組みで見たのは、CSS が当たるか、入力を誰が作るか、検証の単位が何かです。すべての組み合わせを試したものではありません。
| 確かめたいこと | 第 1 層 (jsdom) | 第 2 層 (Storybook) | 第 3 層 (実ブラウザ) | 本ガイドでの置き場 |
|---|---|---|---|---|
| 表示用の計算 (件数、文言) | 向く。純粋関数を直接呼ぶ | 向かない | 向かない | 第 1 層 |
| 開く、閉じる (ボタン、Esc キー) | 向く | 書ける | 書ける | 第 1 層。story にも 1 本 |
| 役割と名前が付いていること | 向く。getByRole で探すだけで確かめられる | 向く。a11y の検査が全 story を見る | 書ける | 第 1 層と第 2 層 |
| 開いたときのフォーカスの移動、閉じたときの復帰 | 向く。移動を待ってから確かめる | 書ける | 書ける | 第 1 層。復帰は第 3 層にも |
| Tab キーによるフォーカスの閉じ込め | 書ける。押すたびに送り直しを待つ。移動先は user-event が決める | 書ける。第 1 層と同じ待ち方が要る | 向く。移動先をブラウザが決める | 第 1 層と第 3 層 |
| 押すと無効になるボタンに、フォーカスが残ること | できない。jsdom では、無効になってもフォーカスが外れない | 向かない。story は props を固定するので、押しても無効にならない | 向く | 第 3 層 (Playwright) |
| 色のコントラスト | できない。CSS が当たらない | 向く。a11y の検査 | 本ガイドでは書いていない | 第 2 層 |
| 開閉の遷移 | できない。遷移が起きない | 書ける。ただし待ち方に注意が要る | 向く | 第 3 層 |
| 見た目の崩れ | できない。CSS が当たらない | 向く。Chromatic で画像を比べる | 本ガイドでは扱っていない | 第 2 層 |
| ページに組み込んだ状態での一連の操作 | 向かない。App を描画すれば流れは追えるが、実際のページと CSS は通らない | 向かない。story はコンポーネントの 1 つの状態が単位 | 向く。Playwright | 第 3 層に 1 本 |
置き場を決めるときの基準は 3 つです。
- 下の層で確かめられるものは、下の層に置きます。速く、落ちたときに原因が近いからです
- 上の層には、その層でしか確かめられないものと、ブラウザ自身の動きで裏付けを取りたいものを置きます。遷移、色、見た目、無効になったボタンのフォーカスが前者に、Tab キーの閉じ込めが後者に当たります
- 状態を story にしておきます。第 2 層の a11y の検査と画像の比較は、story にした状態だけを見ます
本ガイドのテストの本数は、第 1 層が 19 本、第 2 層が 5 本、第 3 層が 5 本 (Browser Mode 4 本と Playwright 1 本) です。09 章で足すものを除いた数です。
まとめ
- Tab キーのテストは、押した直後ではなく、Base UI がフォーカスを送り直すのを待ってから確かめます。待てば、jsdom でも実ブラウザでも同じ結果になります
- 第 3 層では、Tab キーの移動先をブラウザが決めます。CSS で隠した要素があると、CSS を読み込まない第 1 層の結果は実際の Tab 順と食い違います。ブラウザ自身の Tab 順での裏付けは、この層で取ります
- Vitest Browser Mode は、Storybook の Vitest アドオンと同じ仕組みで動きます。
vitest-browser-reactで描画し、ロケーターとexpect.elementで確かめます - 閉じる遷移は、過ぎ去る状態を捕まえるのではなく、
transitionendイベントを受け取って確かめます - 押すと無効になるボタンにフォーカスが残ることは、jsdom の上では確かめられません。Chromium は無効になった要素からフォーカスを外しますが、jsdom は外さないためです
- ページに組み込んだ状態は、Playwright で少数だけ確かめます
- 検証は、確かめられる中でいちばん下の層に置きます
次に読む
- 09 まとめと次の一歩 — 同じ分け方を、フォームとデータ取得に広げます
- Component Testing — Vitest の公式ドキュメントによる、コンポーネントのテストの指針