react-testing について
実装の内部ではなく、アクセシブルでユーザーが認識できる挙動を通じて React コンポーネントをテストするスキルです。Vitest Browser Mode と既存の jsdom 環境に対応し、フック、フォーム、コンテキスト、非同期 UI を扱います。
主な機能
- 01アクセシブルネームで要素を検索
- 02内部実装ではなく描画結果をテスト
- 03Vitest Browser Mode で React をテスト
- 04フック、フォーム、コンテキストを検証
- 05MSW でネットワーク挙動をモック
活用シーン
- 01入力と送信を通じてフォームをテスト
- 02フックやプロバイダーの描画状態を検証
- 03ブラウザまたは jsdom のテスト環境を選択
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ citypaul/ .dotfiles --skill react-testingインストール後、Claude Code で/react-testingを入力して読み込まれたか確認します。
同梱ファイルresources/testing-library-react-legacy.md
詳細
React のテスト
一般的な UI テストのパターン(クエリ、イベント、非同期処理、アクセシビリティ、MSW)については、front-end-testing スキルを読み込んでください。TDD のワークフローについては、tdd スキルを読み込んでください。
すべての要素は、人が認識する方法で見つけてください。data-testid、CSS クラス、セレクターでは決して検索しないでください。コンポーネントにすでに付いているからという理由で使うのは、もってのほかです。 コントロールは、アクセシブルな名前を指定した getByRole で見つけます(フィールドには getByLabelText を使います)。ロールのない要素(件数の行、ステータスメッセージ、説明文、テキストだけの <span> など)は、表示されている言葉で見つけます。例:getByText(/3 of 7 items/)。次に、その同じロケーターでアサーションします(toBeVisible()、toHaveAttribute(...))。内容をアサートするためだけに、まずテスト ID でハンドルを取得することは決してしないでください。クエリできるほど要素を認識できる手掛かりが何もない場合、それはコンポーネントに関する問題です。その目的にふさわしいロール、ラベル、アクセシブルな名前を付けてから、クエリしてください。
コンポーネントを動かすフローのロジックについては、xstate を読み込んでください。マシンはヘッドレスでテストし、コンポーネントのテストでは DOM のみを扱うため、コンポーネントのテストでマシンの状態をアサートしてはいけません。テスト対象のコンポーネントが useState で submitting/isLoading フラグを持っている場合、それはフローがマシンから漏れ出している合図です。その呼び出しは xstate が管理します。同じコードのパフォーマンス改善については、挙動のテストを変更せず、成功した状態に保つルールがある react-performance を読み込んでください。
tdd スキルの標準的な高速フィードバックとウォッチャーのライフサイクルに関する方針、および front-end-testing スキルのブラウザー固有の違いに従ってください。React には、Vitest のグラフに関する独立した保証はありません。リポジトリが管理するウォッチャーを優先し、標準的なバージョン/設定の検証ができる場合に限り、差分で選択したウォッチを使用してください。また、ルートグラフを通じて、影響を受けるすべてのアプリ/パッケージ利用側が対象に含まれるようにしてください。対象ファイルだけの実行は、RED/デバッグ時に限ります。PR の準備ができたら、ウォッチャーを停止し、対象リポジトリのミューテーション方針に従って、ウォッチなしの UI/プロジェクト全体のゲートを完了してください。
詳細リソースは resources/ ディレクトリにあります。必要に応じて読み込んでください。
| リソース | 読み込むタイミング |
|---|---|
resources/testing-library-react-legacy.md | @testing-library/react と jsdom を使うコードベースで作業するとき — 同期的な render、screen クエリ、インポートした act、render ヘルパー、従来のフォーム/フック/コンテキストの例 |
React での Vitest Browser Mode
実際のレンダリング、イベント、フォーカス、CSS、アクセシビリティ、ブラウザー API に依存する検証では、リポジトリがハーネスをサポートしているか、追加コストに見合う場合、vitest-browser-react を優先してください。純粋なフック、プロバイダー、コンポーネントのロジックをすでに検証できている場合は、既存の安定した @testing-library/react/jsdom のハーネスを維持するか、より軽量な環境を使用してください。
セットアップ
front-end-testing スキルの Browser Mode 設定を、React プラグインと vitest-browser-react で拡張してください。そのスキルにある、リポジトリのパッケージマネージャー、正確なバージョン、認可、ローカルバイナリのセットアップ方針を適用してください。
<repo-pm> add --save-dev vitest@<reviewed-version> @vitest/browser-playwright@<reviewed-version> vitest-browser-react@<reviewed-version> @vitejs/plugin-react@<reviewed-version>
// vitest.config.ts — same as front-end-testing Browser Mode config, plus:
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
browser: { /* unchanged from front-end-testing setup */ },
},
})
コンポーネントのテスト
import { render } from 'vitest-browser-react'
import { expect, test } from 'vitest'
test('should display user name when provided', async () => {
const screen = await render(<UserProfile name="Alice" email="[email protected]" />)
await expect.element(screen.getByText(/alice/i)).toBeVisible()
await expect.element(screen.getByText(/[email protected]/i)).toBeVisible()
})
@testing-library/react との主な違い:
render()とrenderHook()は非同期です。awaitを使ってください- レンダリングされたコンポーネントにスコープされた
screenが返されます - 自動再試行するアサーションには
expect.element()を使ってください - ロケーターを使ったコンポーネントの操作では、
act()のラッパーは不要です。CDP イベントと再試行がタイミングを処理します。ただし、renderHookの状態更新には引き続きactが必要です(renderHookが返すものを使用します。後述) - 自動クリーンアップは各テストの前に実行されます(後ではありません)。そのため、デバッグ中もコンポーネントは表示されたままです
Props とコールバックのテスト
test('should call onSubmit when form submitted', async () => {
const handleSubmit = vi.fn()
const screen = await render(<LoginForm onSubmit={handleSubmit} />)
await screen.getByLabelText(/email/i).fill('[email protected]')
await screen.getByRole('button', { name: /submit/i }).click()
expect(handleSubmit).toHaveBeenCalledWith({
email: '[email protected]',
})
})
条件付きレンダリングのテスト(MSW を使用)
Browser Mode のテストは実際のブラウザーで実行されるため、MSW の setupServer ではなく、setupWorker(msw/browser)を使ってください。セットアップファイルでワーカーを起動し、テストごとに worker.use() でオーバーライドします。セットアップ全体については、front-end-testing スキルと resources/msw.md を参照してください。
import { http, HttpResponse } from 'msw'
import { worker } from '../vitest.browser.setup'
test('should show error message when login fails', async () => {
worker.use(
http.post('/api/login', () => {
return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 })
})
)
const screen = await render(<LoginForm />)
await screen.getByLabelText(/email/i).fill('[email protected]')
await screen.getByRole('button', { name: /submit/i }).click()
await expect.element(screen.getByText(/invalid credentials/i)).toBeVisible()
})
renderHook を使ったフックのテスト
renderHook() は非同期で、result とともに act を返します。フックの状態を更新するときは、その act を使ってください。
import { renderHook } from 'vitest-browser-react'
test('should toggle value', async () => {
const { result, act } = await renderHook(() => useToggle(false))
expect(result.current.value).toBe(false)
await act(() => {
result.current.toggle()
})
expect(result.current.value).toBe(true)
})
コンテキストプロバイダーのテスト
test('should show user menu when authenticated', async () => {
const screen = await render(
<AuthProvider initialUser={{ name: 'Alice', role: 'admin' }}>
<Dashboard />
</AuthProvider>
)
await expect.element(screen.getByRole('button', { name: /user menu/i })).toBeVisible()
})
コンテキストを必要とするフックの場合:
const { result } = await renderHook(() => useAuth(), {
wrapper: ({ children }) => (
<AuthProvider>{children}</AuthProvider>
),
})
フォームのテスト
test('should submit form with user input', async () => {
const handleSubmit = vi.fn()
const screen = await render(<RegistrationForm onSubmit={handleSubmit} />)
await screen.getByLabelText(/name/i).fill('Alice')
await screen.getByLabelText(/email/i).fill('[email protected]')
await screen.getByLabelText(/password/i).fill('password123')
await screen.getByRole('button', { name: /sign up/i }).click()
expect(handleSubmit).toHaveBeenCalledWith({
name: 'Alice',
email: '[email protected]',
password: 'password123',
})
})
test('should show validation errors for invalid input', async () => {
const screen = await render(<RegistrationForm />)
// Submit empty form
await screen.getByRole('button', { name: /sign up/i }).click()
// Validation errors appear
await expect.element(screen.getByText(/name is required/i)).toBeVisible()
await expect.element(screen.getByText(/email is required/i)).toBeVisible()
await expect.element(screen.getByText(/password is required/i)).toBeVisible()
})
読み込み状態のテスト
test('should show loading then data', async () => {
const screen = await render(<UserList />)
await expect.element(screen.getByText(/loading/i)).toBeVisible()
await expect.element(screen.getByText(/alice/i)).toBeVisible()
await expect.element(screen.getByText(/loading/i)).not.toBeInTheDocument()
})
エラーバウンダリーのテスト
test('should catch errors with error boundary', async () => {
// Suppress console.error noise for this test
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const screen = await render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ThrowsError />
</ErrorBoundary>
)
await expect.element(screen.getByText(/something went wrong/i)).toBeVisible()
} finally {
spy.mockRestore()
}
})
ポータルのテスト
import { page } from 'vitest/browser'
test('should render modal in portal', async () => {
const screen = await render(<Modal isOpen={true}>Modal content</Modal>)
// Portal renders outside the component root; query the page instead
await expect.element(page.getByText(/modal content/i)).toBeVisible()
})
返される screen はレンダリングされたコンポーネントにスコープされます。ポータルのコンテンツには、vitest/browser のドキュメント全体を対象とする page を使用してください。
Suspense のテスト
test('should show fallback then content', async () => {
const screen = await render(
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
)
await expect.element(screen.getByText(/loading/i)).toBeVisible()
await expect.element(screen.getByText(/lazy content/i)).toBeVisible()
})
React Server Components
RSC は Browser Mode のコンポーネントテストではテストできません。ブラウザーではなくサーバーで実行されるためです。e2e テスト(実行中のアプリに対する Playwright)か、コンポーネントから切り出したロジックのユニットテストでテストしてください。クライアントコンポーネント('use client')は、vitest-browser-react で通常どおりテストできます。
React 固有のアンチパターン
1. 不要な act() によるラップ
❌ 誤り — レンダリングや操作を手動で act() によってラップする
await act(async () => {
await screen.getByRole('button').click()
})
✅ 正しい — ロケーターイベントがタイミングを処理する
await screen.getByRole('button').click()
act() が必要な場合: renderHook によるフックの状態更新(返される act を使用してください)。@testing-library/react では、RTL が render/userEvent/waitFor を自動的にラップします。詳しくは resources/testing-library-react-legacy.md を参照してください。
2. コンポーネントの内部のテスト
❌ 誤り — コンポーネントの内部にアクセスする
const wrapper = shallow(<MyComponent />);
expect(wrapper.state('isOpen')).toBe(true); // Internal state
expect(wrapper.instance().handleClick).toBeDefined(); // Internal method
✅ 正しい — レンダリングされた出力をテストする
const screen = await render(<MyComponent />)
await expect.element(screen.getByRole('dialog')).toBeVisible() // What user sees
3. シャローレンダリング — そしてその現代的な呼び方である、子コンポーネントのモック
テスト対象のアプリが所有するコンポーネントを vi.mock でモックしてはいけません。子を代役に置き換えるのは、呼び方を変えただけのシャローレンダリングであり、今ではこちらのほうがはるかによく見られる形です。あるリクエストで、子のマークアップは他者の問題だから代役を立てたいと言われた場合は、その手段ではなく懸念に応えてください。ツリー全体をレンダリングし、親自身について観察可能な事実(画面上にある項目、件数の表示、空の状態など)だけを検証し、子のマークアップには一切依存しないでください。モックするのは、ネットワーク(MSW 経由)、時計、乱数といった実際の境界をまたぐ場合に限り、自分のコンポーネントツリーの内部ではモックしないでください。
❌ 誤り — 子を代役に置き換える、またはシャローレンダリングする
vi.mock('./ItemRow', () => ({ ItemRow: ({ item }) => <li>{item.title}</li> }))
const wrapper = shallow(<MyComponent />);
// Child components not rendered - incomplete test
✅ 正しい — ツリー全体をレンダリングする
await render(<MyComponent />)
// Full component tree rendered - realistic test
理由: シャローレンダリングでは、親コンポーネントと子コンポーネント間の統合バグが隠れてしまいます。
4. レンダリングの共有とクリーンアップの所有者
共有された可変レンダリング状態が問題であり、ライフサイクルフックではありません。独立した
beforeEach で、並行実行しない各テストに新しい状態を用意できます。セットアップの繰り返しやネストがわかりやすくなる場合に限り、ヘルパーを使用してください。Testing Library のクリーンアップは、ハーネスが想定するグローバルな afterEach を提供している場合にのみ自動で行われます。それ以外の場合は、テストセットアップで afterEach(() => cleanup()) を明示的に登録してください。
要約チェックリスト
React 固有のチェック:
- ブラウザーで観測可能な動作が必要で、リポジトリのサポート状況やコストが適している場合は、
vitest-browser-reactを使用する - 安定した軽量なハーネスでコンポーネントやフックの契約を確認できる場合は、
@testing-library/reactを引き続き使用する(resources/testing-library-react-legacy.mdを参照) - Playwright/Browser Mode のテストはすべて冪等である(テスト間で状態を共有しない)
-
render()/renderHook()はawaitする(vitest-browser-reactでは非同期) - カスタムフックには
renderHook()を使用し、状態更新には返されたactを使用する - コンテキストプロバイダーには
wrapperオプションを使用する - レンダリングやロケーターの操作を手動で
act()で囲まない - このハーネスでクリーンアップが自動的に行われることを確認するか、テストセットアップで一度だけ登録する
- Browser Mode では
setupWorker/worker.use()を介して MSW を使用する(setupServerは使用しない) - 内部状態ではなく、コンポーネントの出力をテストする
- 要素はすべてロール、ラベル、または表示テキストで取得する。コンポーネントに用意されている場合も含め、
getByTestId、data-testid、セレクター、クラスは使用しない - アプリが所有するコンポーネントを
vi.mockしない。ツリー全体をレンダリングし、親のテストでは親自身に関する事実だけを検証する - セットアップはテストごとに分離する。ライフサイクルフックでは新しい状態を作成してもよく、ヘルパーはセットアップの反復やネスト化が明確になる場合にのみ使用する
- 自動再試行アサーションには
expect.element()を使用する(Browser Mode) - RSC は Browser Mode のコンポーネントテストではなく、e2e または抽出したロジックを通じてテストする
- TDD ワークフローに従う(
tddスキルを参照) - GREEN/REFACTOR では、手作業で選んだテストファイルではなく、ランナー、ワークスペースオーケストレーター、またはリポジトリマッピングから導出した、影響を受ける範囲全体のフィードバックを使用する。信頼できるグラフがない場合は、文書化された範囲を広げたフォールバックを使用する。また、リポジトリ全体の PR テストゲートが最新で、設定されている UI テストスイート全体を含んでいることを確認する
- 一般的な UI テストのパターンに従う(
front-end-testingスキルを参照) - データの反復やネスト化が分かりやすくなる場合はファクトリーを使用し、単純な単発の値はインラインに記述する(
testingスキルを参照)