単体テスト: Vitest と React Testing Library
第 1 層のテストは Node.js の上で動きます。ブラウザを起動しないので速く、書いて保存するたびに回せます。
この章では、03 章のダイアログに対して、開閉とフォーカスのテストを書きます。フォーカスのテストには、待ってから確かめないと結果が安定しない箇所があります。どこで、なぜ待つ必要があるのかを、計測と合わせて見ます。
この章で学ぶこと
- Vitest に jsdom 用の project を足す設定
- user-event でダイアログを操作し、役割と名前で確かめるテスト
- 待ってから確かめる必要がある箇所と、その理由
- クラス名を選択子に使わないための決まり
- jsdom の上では確かめられないこと
この章で扱わないこと
| 観点 | 参照先 |
|---|---|
| Vitest の導入、マッチャー、カバレッジ | 開発ツールチェーン |
| クエリの種類と優先順位 | 振る舞いをテストする |
| 通信を伴うコンポーネントの待ち方 | 非同期とネットワーク境界 |
道具を入れる
Vitest は 04 章の導入コマンドで入っています。Storybook を入れずにこの章から始める場合は、先に npm install -D vitest を実行してください。
足すのは、DOM を用意するものと、コンポーネントを描画して操作するものです。
npm install -D jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom
| パッケージ | 役割 |
|---|---|
jsdom | Node.js の上で、ブラウザの DOM を模したものを提供します |
@testing-library/react | コンポーネントを描画し、役割や名前で要素を探します。@testing-library/dom はその土台です |
@testing-library/user-event | クリックやキー入力を、利用者の操作に近い形で起こします |
@testing-library/jest-dom | toBeInTheDocument や toHaveFocus など、DOM 用のマッチャーを足します |
jsdom 用の project を足す
Vitest は、設定の違うテストの集まりを project として並べて持てます。04 章の導入コマンドは、vite.config.ts に Storybook 用の project を 1 つ作っていました。そこへ、jsdom で動かす単体テスト用の project を足します。
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'],
},
},
設定の全体は 08 章に載せます。ここで足した unit という project の要点は次のとおりです。
| 設定 | 意味 |
|---|---|
extends: true | vite.config.ts の上のほうにある plugins などを引き継ぎます |
environment: 'jsdom' | テストを jsdom の上で動かします |
include と exclude | src の下の *.test.ts と *.test.tsx を対象にし、*.browser.test.tsx は外します。後者は 08 章で実ブラウザ用に使う名前です |
setupFiles | 各テストファイルの前に読み込むファイルです |
setupFiles に指定したファイルは次の内容です。
// toBeInTheDocument() や toHaveFocus() など、DOM 用のマッチャーを expect に足す
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
// テストのたびに描画結果を片付ける。前のテストのダイアログが残ると、次のテストの検索に混ざる
afterEach(() => {
cleanup()
})
1 つめの import で、toBeInTheDocument などのマッチャーが expect に足されます。afterEach の cleanup は、テストごとに描画結果を片付ける処理です。ダイアログは body の直下に描画されるので、片付けないと次のテストの画面に前のダイアログが残ります。
本ガイドのテストは、describe や expect を各ファイルで import して使います。Vitest にはこれらを import なしで使えるようにする globals という設定もありますが、どこから来た関数かがファイルの中で分かるほうを選びました。
05 章の src/lib/notifications.test.ts も、この project の対象です。
テストを書く
テストは、コンポーネントと同じディレクトリに置きます。全体は次のとおりです。
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import type { Notification } from '../lib/notifications'
import { NotificationsDialog } from './notifications-dialog'
const unreadAndRead: Notification[] = [
{ id: 'n1', title: 'メンテナンスのお知らせ', read: false },
{ id: 'n2', title: '請求書を発行しました', read: true },
]
/**
* ダイアログを描画し、テストで使う道具をまとめて返す。
*
* 開くボタンは、ダイアログが開く前に取っておく。開いている間は背景が支援技術から
* 隠されるので、getByRole では背景のボタンを取れなくなるため。
*/
function setup(notifications: Notification[] = unreadAndRead) {
const user = userEvent.setup()
const onMarkAllRead = vi.fn()
render(<NotificationsDialog notifications={notifications} onMarkAllRead={onMarkAllRead} />)
const trigger = screen.getByRole('button', { name: /お知らせ/ })
return { user, onMarkAllRead, trigger }
}
/**
* フォーカスがダイアログの中へ移るのを待つ。
*
* Base UI がフォーカスを動かすのは、開いた瞬間ではなく、その少しあと。
* 待たずに次の操作や検証へ進むと、通ったり落ちたりするテストになる。
*/
async function waitForFocusInside() {
const dialog = screen.getByRole('dialog', { name: 'お知らせ' })
await waitFor(() => {
expect(dialog.contains(document.activeElement)).toBe(true)
})
return dialog
}
/**
* ダイアログが DOM から外れるのを待つ。
*
* Base UI は、閉じるアニメーションの終わりを待ってから DOM から外す。いまの jsdom には
* 終わりを調べる API (getAnimations) が無いので、閉じる操作をした時点で外れる。
* その動きには頼らず、外れるまで待つ。
*/
async function waitForClosed() {
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
}
describe('NotificationsDialog', () => {
it('ボタンを押すと、名前の付いたダイアログが開く', async () => {
const { user, trigger } = setup()
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await user.click(trigger)
// role と名前で探す。クラス名や要素の入れ子には触らない
expect(screen.getByRole('dialog', { name: 'お知らせ' })).toBeInTheDocument()
})
it('説明文がダイアログに結びついている', async () => {
const { user, trigger } = setup()
await user.click(trigger)
expect(screen.getByRole('dialog', { name: 'お知らせ' })).toHaveAccessibleDescription(
'未読のお知らせが 1 件あります。',
)
})
it('開くと、フォーカスがダイアログの中へ移る', async () => {
const { user, trigger } = setup()
await user.click(trigger)
// 移る先は、中で最初にフォーカスできる要素 (ここでは「すべて既読にする」)
await waitFor(() => {
expect(screen.getByRole('button', { name: 'すべて既読にする' })).toHaveFocus()
})
})
it('Tab キーを押し続けても、フォーカスがダイアログの外へ出ない', async () => {
const { user, trigger } = setup()
await user.click(trigger)
const dialog = await waitForFocusInside()
// 中で Tab で止まれるのは 2 つ。それより多く押して、一巡しても外へ出ないことを見る
for (let i = 0; i < 5; i++) {
await user.tab()
/*
* 端のボタンから Tab を押すと、フォーカスはいったん Base UI の内部の要素へ移り、
* 次の描画のタイミングで反対の端へ送り直される。送り直しを待ってから確かめる。
*/
await waitFor(() => {
expect(dialog.contains(document.activeElement)).toBe(true)
})
}
})
it('Esc キーで閉じ、フォーカスが開いたボタンへ戻る', async () => {
const { user, trigger } = setup()
await user.click(trigger)
/*
* フォーカスが中へ移る前に閉じると、フォーカスは開くボタンから動かないままになり、
* 「戻った」ことを確かめられない。
*/
await waitForFocusInside()
await user.keyboard('{Escape}')
await waitForClosed()
expect(trigger).toHaveFocus()
})
it('閉じるボタンで閉じる', async () => {
const { user, trigger } = setup()
await user.click(trigger)
await user.click(screen.getByRole('button', { name: '閉じる' }))
await waitForClosed()
})
it('「すべて既読にする」を押すと、親へ知らせる', async () => {
const { user, onMarkAllRead, trigger } = setup()
await user.click(trigger)
await user.click(screen.getByRole('button', { name: 'すべて既読にする' }))
expect(onMarkAllRead).toHaveBeenCalledTimes(1)
})
it('未読が無いとき、「すべて既読にする」は無効で、押しても親へ知らせない', async () => {
const { user, onMarkAllRead, trigger } = setup([
{ id: 'n1', title: 'メンテナンスのお知らせ', read: true },
])
await user.click(trigger)
const markAllRead = screen.getByRole('button', { name: 'すべて既読にする' })
// フォーカスを受けられるまま無効にしているので、disabled 属性ではなく aria-disabled が付く
expect(markAllRead).toHaveAttribute('aria-disabled', 'true')
await user.click(markAllRead)
expect(onMarkAllRead).not.toHaveBeenCalled()
})
})
8 本のテストが確かめていることは次のとおりです。前半の 5 本は、01 章で挙げたキーボードとフォーカスの要点と、役割と名前の付与に対応しています。
| テスト | 確かめていること |
|---|---|
| ボタンを押すと、名前の付いたダイアログが開く | 開くこと。dialog の役割と名前が付いていること |
| 説明文がダイアログに結びついている | Dialog.Description が説明として結ばれていること |
| 開くと、フォーカスがダイアログの中へ移る | フォーカスの移動 |
| Tab キーを押し続けても、フォーカスがダイアログの外へ出ない | フォーカスの閉じ込め |
| Esc キーで閉じ、フォーカスが開いたボタンへ戻る | Esc キーで閉じること。フォーカスの復帰 |
| 閉じるボタンで閉じる | Dialog.Close が効いていること |
| 「すべて既読にする」を押すと、親へ知らせる | 操作が props の関数で親へ伝わること |
| 未読が無いとき、「すべて既読にする」は無効で、押しても親へ知らせない | 無効にする条件と、無効の間は親へ知らせないこと |
書き方について、補足が要る点を順に取り上げます。
開くボタンを先に取っておく
setup 関数は、描画の直後に開くボタンを取って返しています。各テストの中で、開いたあとに取り直してはいません。
理由は 02 章で見た DOM にあります。ダイアログが開いている間、Base UI は背景の要素に aria-hidden="true" を付けます。getByRole は支援技術から見える要素だけを探すので、開いている間は背景のボタンが見つかりません。スクリーンリーダーの利用者に起きることが、そのままテストにも起きています。
閉じたあとにフォーカスが戻ったことを確かめるには、開く前に取っておいた要素を使います。
役割と名前で確かめる
最初のテストの検証は、次の 1 行です。
// role と名前で探す。クラス名や要素の入れ子には触らない
expect(screen.getByRole('dialog', { name: 'お知らせ' })).toBeInTheDocument()
05 章で見たとおり、この 1 行は、開いたことと、ダイアログが名前を持っていることの両方を確かめます。Dialog.Title を素の h2 に置き換えると、このテストは落ちます。
2 本めの toHaveAccessibleDescription は、説明文について同じことをします。Dialog.Description を素の p に置き換えると落ちます。
フォーカスが中へ移るのを待つ
4 本めと 5 本めは、waitForFocusInside という関数を通っています。3 本めは、同じ待ち方を waitFor で直接書いています。
/**
* フォーカスがダイアログの中へ移るのを待つ。
*
* Base UI がフォーカスを動かすのは、開いた瞬間ではなく、その少しあと。
* 待たずに次の操作や検証へ進むと、通ったり落ちたりするテストになる。
*/
async function waitForFocusInside() {
const dialog = screen.getByRole('dialog', { name: 'お知らせ' })
await waitFor(() => {
expect(dialog.contains(document.activeElement)).toBe(true)
})
return dialog
}
Base UI がフォーカスを中へ移すのは、ダイアログを描画した少しあとです。await user.click(trigger) が戻った時点では、まだ移っていないことがあります。
開く操作の直後に、待たずにフォーカスの位置を見ることを 30 回繰り返すと、まだ開くボタンにフォーカスが残っている回がありました。その回数は、測るたびに大きく変わります。30 回のうち 15 回のこともあれば、30 回すべてのこともありました。待たずに確かめるテストは、落ちたり通ったりします。落ちる割合も一定しないので、気づきにくい不具合になります。waitFor は、中の検証が通るまで短い間隔で繰り返すので、移動を待ってから確かめられます。
待つ必要があるのは、検証のときだけではありません。5 本めのテストは、Esc キーを押す前にこの関数を呼んでいます。フォーカスが中へ移る前に閉じてしまうと、フォーカスは開くボタンから一度も動きません。その状態で「フォーカスが開くボタンにある」ことを確かめても通ってしまい、フォーカスが戻ったことは確かめられません。中へ移ったことを見届けてから閉じることで、戻る動きを確かめています。この形にしたうえで、Dialog.Popup に finalFocus={false} を渡して戻る動きを止めると、10 回実行して 10 回とも落ちました。
Tab キーを押すたびに、送り直しを待つ
4 本めは、フォーカスの閉じ込めフォーカストラップ (Focus Trap)モーダルなどが開いている間、Tab キーで動くフォーカスをその中に閉じ込める仕組み。背景に隠れた要素へキーボードの操作が抜けるのを防ぐ。のテストです。Tab キーを 5 回押し、そのつどフォーカスがダイアログの中にあることを確かめています。
押したあとの確認を waitFor で包んでいるのは、02 章で見た動きのためです。中の最後のボタンから Tab キーを押すと、フォーカスはいったん Base UI の内部の要素へ移り、次の描画のタイミングで最初のボタンへ送り直されます。user-event の tab() が戻った直後は、まだ内部の要素にフォーカスがあります。
待たずに続けて押すとどうなるかを、ダイアログの外にリンクを 1 つ置いた構成で、5 回ずつ実行して記録しました。
押すたびに送り直しを待つ (5 回の実行とも同じ)
閉じる → すべて既読にする → 閉じる → すべて既読にする → 閉じる
待たずに続けて押す (5 回の実行とも同じ)
閉じる → 内部の要素 → body → 閉じる → 内部の要素
待たずに続けて押すと、5 回とも、途中でフォーカスがダイアログの外の body に出ました。Shift + Tab で逆向きに押した場合は、5 回のうち 3 回、背景に置いたリンクまでフォーカスが抜けました。待たない場合の結果は、測るたびに変わります。別の回の計測では、body に出ない実行もありました。
これは Base UI の閉じ込めが壊れているのではありません。送り直しが終わる前に次のキーを押すと、user-event は、内部の要素を起点にして次の移動先を選びます。その先が、ダイアログの外になります。08 章で測るとおり、実ブラウザに本物のキー入力を続けて送った場合は、外へ出ませんでした。テストのほうを、送り直しを待つ形にします。
この検査が閉じ込めを確かめていることは、実装を壊して確かめました。Dialog.Root に modal={false} を渡して閉じ込めを切ると、このテストは落ちます。
閉じたことも、待ってから確かめる
5 本めと 6 本めは、閉じたことの確認を waitForClosed という関数で待っています。
/**
* ダイアログが DOM から外れるのを待つ。
*
* Base UI は、閉じるアニメーションの終わりを待ってから DOM から外す。いまの jsdom には
* 終わりを調べる API (getAnimations) が無いので、閉じる操作をした時点で外れる。
* その動きには頼らず、外れるまで待つ。
*/
async function waitForClosed() {
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
}
Base UI は、閉じる操作を受けても、すぐには要素を DOM から外しません。要素の getAnimations を呼び、実行中のアニメーションや遷移が終わるのを待ってから外します1。03 章で、閉じる遷移を CSS だけで書けたのは、この仕組みがあるためです。
jsdom の要素には getAnimations がありません。その場合、Base UI は待たずに外します。手元の jsdom 30.1 で、Esc キーと閉じるボタンのそれぞれについて、操作の直後に待たずに確かめることを 20 回ずつ繰り返すと、40 回とも DOM から外れていました。Esc キーの 20 回は、フォーカスもその時点で開くボタンへ戻っていました。
待たずに確かめる形でも、いまは通ります。ただし、それは jsdom にこの API が無いことの結果です。jsdom の要素に getAnimations を足して、API がある環境を模したうえで同じことを 20 回繰り返すと、Esc キーの直後に DOM から外れていた回は 1 回もありませんでした。外れるまで待ってから確かめる形は 20 回とも通り、その時点でフォーカスも開くボタンへ戻っていました。
テストは、環境に欠けている機能には頼らない形にしておきます。実ブラウザでは、閉じる遷移が終わるまで要素が DOM に残ります。07 章と 08 章のテストも、閉じたことを待ってから確かめます。
無効の状態は aria-disabled で確かめる
8 本めは、未読が無いときに「すべて既読にする」が無効になることを確かめています。03 章で見たとおり、このボタンはフォーカスを受けられるまま無効になるので、disabled 属性は付きません。付くのは aria-disabled="true" です。
jest-dom の toBeDisabled は disabled 属性を見るマッチャーなので、このボタンには使えません。支援技術へ伝わる状態そのものである aria-disabled を確かめています。続けてボタンを押し、親へ知らせる関数が呼ばれないことも確かめています。disabled に渡している式を消すと、このテストは落ちます。
クラス名を選択子に使わない
このテストファイルには、Tailwind CSS のクラス名が 1 つも出てきません。本ガイドでは、第 1 層のテストに次の決まりを置きます。
- 要素は
getByRoleで、役割と名前から探します container.querySelector('.rounded-xl')のように、クラス名で要素を探しませんtoHaveClass('opacity-0')のように、クラス名が付いていることを検証しません- 状態を確かめるときは、
aria-disabledやaria-expandedのように、支援技術へ伝わる属性で確かめます data-base-ui-で始まる属性や、要素の並び順に頼りませんdata-testidは、役割でも名前でも探せないときの最後の手段にします
2 つめと 3 つめを禁じるのは、落ちる場面が見た目の調整と重なるからだけではありません。jsdom の上では、クラス名が付いていても、そのスタイルは当たっていません。CSS を読み込んでいないからです。toHaveClass('opacity-0') が通っても、言えるのは「その文字列が属性に入っている」ことだけで、要素が透明かどうかは分かりません。見た目についての検証に見えて、見た目を何も確かめていないテストになります。
見た目を確かめたいなら、スタイルが実際に当たる場所で行います。それが次の章からの第 2 層と第 3 層です。
jsdom で確かめられないこと
ここまでのテストは、jsdom の上でも Base UI がきちんと動くことを示しています。一方で、jsdom はブラウザそのものではありません。このダイアログについて、確かめられないことが 2 つあります。
| 確かめたいこと | jsdom の上で起きること |
|---|---|
| 開閉の遷移 | CSS が無いので遷移が起きません |
| 見えているかどうか | 不透明度も配置も計算されません。toBeVisible は、開いた瞬間から通ります |
フォーカスの閉じ込めは、上で見たとおり確かめられます。ただし、知っておくべき限界が 1 つあります。Tab キーを押したときの移動先を決めているのは、ブラウザではなく user-event です。
user-event の tab() は、フォーカスできる要素を DOM から集め、tabindex の順に並べて、次の要素へフォーカスを移します。見えない要素は飛ばしますが、見えるかどうかの判定には、計算されたスタイルの display と visibility を使います。この project のテストは CSS を読み込んでいないので、Tailwind CSS のクラスで隠した要素も、Tab キーで止まる要素に数えられます。
ボタンを 3 つ並べ、真ん中に hidden クラスを付けて確かめました。1 つめのボタンから Tab キーを 1 回押すと、jsdom の上の tab() は隠したボタンへ移り、CSS を読み込んだ Chromium に送った本物の Tab キーは 3 つめのボタンへ移りました。CSS を読み込んだ Chromium の上で tab() を呼んだ場合も、3 つめのボタンへ移りました。食い違いの原因は、この層が CSS を読み込まないことにあります。
本ガイドのダイアログには、CSS で隠したフォーカス可能な要素がありません。そのため、jsdom の上の結果と実ブラウザの結果が一致します。そうした要素を持つ部品では、CSS の当たる実ブラウザでのテストが要ります。08 章で、実ブラウザに本物のキー入力を送る形でも同じ検査を書きます。
実行する
unit の project だけを実行します。
npx vitest run --project=unit
✓ unit src/lib/notifications.test.ts (11 tests) 6ms
✓ unit src/components/notifications-dialog.test.tsx (8 tests) 751ms
Test Files 2 passed (2)
Tests 19 passed (19)
Duration 1.61s
05 章の純粋関数のテストも、同じ project で一緒に走ります。既定の表示は、テストファイルごとに 1 行です。300 ミリ秒を超えたテストがあると、そのテストだけが 1 本ずつ表示されます2。run を外して npx vitest --project=unit とすると、ファイルを保存するたびに関係するテストだけが再実行されます。
まとめ
- 第 1 層は、Vitest に jsdom 用の project を足して動かします。マッチャーの追加と片付けは
setupFilesで行います - 要素は役割と名前で探します。開いている間は背景が支援技術から隠れるので、開くボタンは先に取っておきます
- Base UI がフォーカスを動かすのは、操作の少しあとです。開いた直後と、Tab キーを押した直後は、
waitForで待ってから確かめます - フォーカスが戻ることを確かめるテストは、フォーカスが中へ移ったのを見届けてから閉じます
- 閉じたことも、待ってから確かめます。いまの jsdom ではすぐに DOM から外れますが、それは jsdom に
getAnimationsが無いためで、テストはその動きに頼らない形にします - クラス名で探さず、クラス名を検証もしません。jsdom の上では、クラス名が付いていてもスタイルは当たっていません
- 遷移と、見えているかどうかは、jsdom では確かめられません。この層は CSS を読み込まないので、CSS で隠した要素も Tab キーの移動先に数えられます。そうした要素がある部品では、実ブラウザでの確認が要ります
次に読む
- 07 Storybook でテストする — 04 章の story に操作と検証を足し、実ブラウザで動かします
- 振る舞いをテストする — クエリの優先順位と、
data-testidの扱い