単体テスト(Vitest + React Testing Library)
古いコンポーネントのリファクタリング中に、トムが「うっかり」内部の状態ロジックを変更してしまい、そのコンポーネントに依存していた3つのページでUIの問題が発生した。ユニットテストが実施されていなかったため、この問題はQAテストの段階になるまで発見されず、チーム全体のイテレーション時間が無駄になってしまった。そこでトムは、変更のたびに既存の機能が破損しないことを確認するために、自動テストを導入し、VitestとReact Testing Libraryをプロジェクトに導入することを決めた。
1. 学習内容
- Vitest のインストールと設定(jsdom 環境、setupFiles)
- 3つの主要なテスト用API(render、screen、userEvent)の連携した使用
- コンポーネントのプロパティの検証とイベントコールバックのテスト
- 非同期コンポーネントの読み込み状態の確認(findBy / waitFor)
- 外部依存関係(APIリクエスト、モジュール)を模擬する
2. 概念図
次の図は、Reactコンポーネント開発におけるユニットテストの役割と関係性を示しています:
flowchart LR
A[Creating Components] --> B[Writing Tests]
B --> C{Run Test}
C -->|Through| D[Submit Code]
C -->|Failure| E[Positioning Bug]
E --> F{Error Type}
F -->|Rendering Issues| G[getByText / getByRole]
F -->|Interaction Issues| H[userEvent.click]
F -->|Asynchronous Issues| I[findByText / waitFor]
F -->|External Dependencies| J[vi.mock / vi.fn]
G --> A
H --> A
I --> A
J --> A
style B fill:#e3f2fd,stroke:#1565c0
style D fill:#e8f5e9,stroke:#2e7d32
style E fill:#fff3e0,stroke:#e65100
3. 実際の事例
トムのチームには、ユーザー情報を表示する UserCard コンポーネントがあります。このコンポーネントは、user オブジェクトと onFollow コールバックを受け取り、isFollowing の状態に基づいてボタンのスタイルを変更します。
リファクタリングの際、トムが内部状態の初期値を変更したため、isFollowing がデフォルトで true になるように設定されてしまいました。その結果、すべてのユーザーカードがデフォルトで「フォロー中」と表示されるようになりました。このバグは、プロダクトマネージャーによる受入テストが行われるまで発見されませんでした。
当時、テストが導入されていれば、このようなリグレッション問題はnpm testフェーズの間に数秒で検出されていたはずだ。トムは、すべてのコアコンポーネントに対してユニットテストを追加することにした。
(1) 環境のセットアップ — テストインフラの構築
まず、すべての依存関係をインストールします:
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
Vitest を設定する(vite.config.ts に test フィールドを追加する):
// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
// Usage jsdom Simulate a browser environment
environment: 'jsdom',
// Global Registration describe / test / expect,No manual import required
globals: true,
// Configuration files that run before the test starts
setupFiles: './src/test/setup.ts',
},
})
セットアップファイルを作成します:
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
// This line allows toBeInTheDocument()、toHaveTextContent() Assertions are available
package.json にテストスクリプトを追加する:
{
"scripts": {
"test": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest --coverage"
}
}
(2) 3つの主要なAPI
React Testing Libraryのテスト哲学:実装の詳細はテストせず、ユーザーが見たり操作したりできるものだけをテストする。
| API | 目的 | 機能 |
|---|---|---|
render(component) |
コンポーネントを仮想DOMにレンダリングする | コンテナの参照とヘルパーメソッドの返却 |
screen |
グローバル要素検索のエントリポイント | getBy、findBy、queryBy の 3 種類のメソッドを提供 |
userEvent |
ユーザーの操作をシミュレート | fireEvent よりもリアル |
基本原則: 最初に
getByRole(意味的)、次にgetByText、最後にgetByTestIdを使用する。
(1) ▶ サンプル:Counter コンポーネントのレンダリングとインタラクティブ性のテスト
まず、簡単なCounterコンポーネントを書いてみましょう:
// Counter.tsx
import { useState } from 'react'
interface CounterProps {
initialCount?: number
step?: number
label?: string
}
export function Counter({
initialCount = 0,
step = 1,
label = 'Count',
}: CounterProps) {
const [count, setCount] = useState(initialCount)
return (
<div>
<p>
{label}:{count}
</p>
<button onClick={() => setCount(c => c + step)}>+{step}</button>
<button onClick={() => setCount(c => c - step)} disabled={count <= 0}>
-{step}
</button>
{count >= 10 && (
<p role="alert">Maximum Value Reached Alert</p>
)}
</div>
)
}
テストの作成:
// Counter.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Counter } from './Counter'
describe('Counter Components', () => {
// Test 1:Initial Rendering
test('Display the initial counter value', () => {
render(<Counter initialCount={5} />)
// getByText — Search by text content
expect(screen.getByText('Count:5')).toBeInTheDocument()
})
// Test 2:Click the "Add" button
test('Click +1 The count increases after the button is pressed', async () => {
const user = userEvent.setup()
render(<Counter initialCount={0} step={1} />)
const incrementBtn = screen.getByRole('button', { name: '+1' })
await user.click(incrementBtn)
expect(screen.getByText('Count:1')).toBeInTheDocument()
})
// Test 3:The count is 0 "Time Decrease" Button Disabled
test('The count is 0 "Time Decrease" Button Disabled', () => {
render(<Counter initialCount={0} />)
const decrementBtn = screen.getByRole('button', { name: '-1' })
expect(decrementBtn).toBeDisabled()
})
// Test 4:Display a reminder when the threshold is reached
test('Count reached 10 Display reminders', async () => {
const user = userEvent.setup()
render(<Counter initialCount={9} step={1} />)
// No reminder at the beginning
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
// Click to add 1
await user.click(screen.getByRole('button', { name: '+1' }))
// Now there's a reminder
expect(screen.getByRole('alert')).toHaveTextContent('Maximum Value Reached Alert')
})
// Test 5:Custom label and step
test('Supports customization label and step', async () => {
const user = userEvent.setup()
render(<Counter initialCount={0} step={5} label="Number of steps" />)
expect(screen.getByText('Number of steps:0')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '+5' }))
expect(screen.getByText('Number of steps:5')).toBeInTheDocument()
})
})
このテストでは、4つのモードが紹介されています:
getByText— 指定されたテキストを含む要素を検索する(最も簡単で直接的な方法)getByRole— ARIAロールによる検索。オプションnameを指定すると、ボタンのテキストと完全に一致する結果が得られます。queryByRole— 存在しない可能性のある要素を検索し、例外をスローする代わりにnullを返すtoBeDisabled()/toHaveTextContent()— jest-dom が提供する意味論的アサーション
(3) プロパティとイベントコールバックのテスト
コンポーネントは通常、props を通じてデータやコールバック関数を受け取ります。テストの目的は、コールバックが正しく呼び出され、パラメータが正しいことを確認することです。
(2) ▶ サンプル:TodoItem コンポーネントのプロパティとイベントのテスト
// TodoItem.tsx
interface Todo {
id: number
text: string
completed: boolean
}
interface TodoItemProps {
todo: Todo
onToggle: (id: number) => void
onDelete: (id: number) => void
}
export function TodoItem({ todo, onToggle, onDelete }: TodoItemProps) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 12px',
background: todo.completed ? '#f6ffed' : '#fff',
borderRadius: 6,
border: '1px solid #f0f0f0',
}}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
aria-label={`Mark ${todo.text}`}
/>
<span
style={{
flex: 1,
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#999' : '#333',
}}
>
{todo.text}
</span>
<button
onClick={() => onDelete(todo.id)}
aria-label={`Delete ${todo.text}`}
style={{
border: 'none',
background: '#ff4d4f',
color: '#fff',
borderRadius: 4,
padding: '2px 8px',
cursor: 'pointer',
fontSize: 12,
}}
>
Delete
</button>
</div>
)
}
// TodoItem.test.tsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TodoItem } from './TodoItem'
describe('TodoItem Components', () => {
const mockTodo = {
id: 42,
text: 'Study React Test',
completed: false,
}
test('Format the to-do list text', () => {
render(
<TodoItem
todo={mockTodo}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
)
expect(screen.getByText('Study React Test')).toBeInTheDocument()
})
test('Completed items are displayed with a strikethrough', () => {
render(
<TodoItem
todo={{ ...mockTodo, completed: true }}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
)
const text = screen.getByText('Study React Test')
expect(text).toHaveStyle('text-decoration: line-through')
})
test('Triggered by clicking the checkbox onToggle', async () => {
const onToggle = vi.fn()
const user = userEvent.setup()
render(
<TodoItem
todo={mockTodo}
onToggle={onToggle}
onDelete={vi.fn()}
/>
)
await user.click(screen.getByRole('checkbox'))
expect(onToggle).toHaveBeenCalledTimes(1)
expect(onToggle).toHaveBeenCalledWith(42) // The validation parameters are todo.id
})
test('Triggered by clicking the Delete button onDelete', async () => {
const onDelete = vi.fn()
const user = userEvent.setup()
render(
<TodoItem
todo={mockTodo}
onToggle={vi.fn()}
onDelete={onDelete}
/>
)
await user.click(screen.getByRole('button', { name: /Delete/ }))
expect(onDelete).toHaveBeenCalledWith(42)
})
test('Unfinished items are not struck through', () => {
render(
<TodoItem
todo={mockTodo}
onToggle={vi.fn()}
onDelete={vi.fn()}
/>
)
const text = screen.getByText('Study React Test')
// Note:Inline styles text-decoration as 'none',rather than not having this property
expect(text).not.toHaveStyle('text-decoration: line-through')
})
})
Mock関数 vi.fn() の主な用途:
| API | 機能 |
|---|---|
vi.fn() |
空のモック関数を作成する |
toHaveBeenCalledTimes(n) |
n回認証済み |
toHaveBeenCalledWith(...) |
呼び出し中にパラメータを確認する |
vi.fn().mockResolvedValue(x) |
非同期の成功レスポンスを模擬する |
vi.fn().mockRejectedValue(e) |
非同期の失敗レスポンスの模擬 |
(4) 非同期コンポーネントのテスト
多くのコンポーネントは、読み込み中にまず「読み込み中...」と表示され、データが到着するとコンテンツが表示されます。このようなシナリオをテストするには、findBy(非同期待機)を使用する必要があります。
(3) ▶ サンプル:非同期データ読み込みコンポーネントのテスト
// UserProfile.tsx
interface UserProfileProps {
userId: number
}
interface UserData {
id: number
name: string
email: string
}
// Simulation API Call
async function fetchUser(id: number): Promise<UserData> {
const res = await fetch(`/api/users/${id}`)
if (!res.ok) throw new Error('Failed to load')
return res.json()
}
export function UserProfile({ userId }: UserProfileProps) {
const [user, setUser] = useState<UserData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
setError(null)
try {
const data = await fetchUser(userId)
if (!cancelled) setUser(data)
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Unknown error')
}
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => { cancelled = true }
}, [userId])
if (loading) return <div aria-label="Loading...">Loading......</div>
if (error) return <div role="alert">Error:{error}</div>
if (!user) return <div>No data</div>
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
)
}
// UserProfile.test.tsx
import { render, screen } from '@testing-library/react'
import { UserProfile } from './UserProfile'
// Mock out fetchUser module
vi.mock('./UserProfile', async (importOriginal) => {
const actual = await importOriginal()
return {
...actual,
// Rewrite fetchUser Implementation
fetchUser: vi.fn(),
}
})
// A Better Approach:Separately mock API Module
// vi.mock('../api', () => ({
// fetchUser: vi.fn()
// }))
describe('UserProfile Asynchronous Components', () => {
beforeEach(() => {
vi.clearAllMocks()
})
test('Display "Loading" while loading', () => {
// let fetch All along pending
vi.spyOn(global, 'fetch').mockImplementation(
() => new Promise(() => {}) // Never resolve
)
render(<UserProfile userId={1} />)
expect(screen.getByLabelText('Loading...')).toBeInTheDocument()
})
test('Display user information after successful loading', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }
// Mock fetch Return successful data
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => mockUser,
} as Response)
render(<UserProfile userId={1} />)
// findByText — Waiting Asynchronously for an Element to Appear(Default Timeout 1000ms)
expect(await screen.findByText('Alice')).toBeInTheDocument()
expect(screen.getByText('alice@example.com')).toBeInTheDocument()
})
test('Display an error message when loading fails', async () => {
// Mock fetch Back 500
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 500,
statusText: 'Internal Server Error',
} as Response)
render(<UserProfile userId={1} />)
// Wait for an error message to appear
expect(await screen.findByRole('alert')).toHaveTextContent('Error:')
})
test('The state is not updated when the component is unmounted(Preventing Memory Leaks)', async () => {
const mockUser = { id: 1, name: 'Alice', email: 'alice@example.com' }
let resolvePromise!: (value: any) => void
vi.spyOn(global, 'fetch').mockReturnValue(
new Promise((resolve) => {
resolvePromise = resolve
})
)
const { unmount } = render(<UserProfile userId={1} />)
// in fetch Uninstall Components Before Completion
unmount()
// At this moment resolve,But the component has been uninstalled,Probably not. setState
resolvePromise({
ok: true,
json: async () => mockUser,
} as Response)
// I didn't make a mistake = Test Passed
})
})
非同期テストの3つの主要な手法の比較:
| メソッド | 同期/非同期 | 要素が存在しない場合 | デフォルトのタイムアウト | ユースケース |
|---|---|---|---|---|
getByText |
同期 | 直ちにエラーを発生させる | - | 要素が存在しなければならない |
queryByText |
同期 | null に戻る |
- | 要素が存在しません |
findByText |
非同期 (Promise) | タイムアウト後にエラーをスロー | 1000ms | 非同期レンダリングを待機 |
(5) 外部依存関係のモックに関するベストプラクティス
実際のプロジェクトでは、コンポーネントはAPIやルーティング、状態管理に依存していることがよくあります。これらの依存関係をモック化することは、テストを行う上で極めて重要です。
方法 1: グローバル変数 fetch をモックする
// Replace the global variable before each test fetch
beforeEach(() => {
vi.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: async () => ({ data: 'mock' }),
} as Response)
})
afterEach(() => {
vi.restoreAllMocks() // Restore to Original fetch
})
方法 2:モックモジュール
// api.ts — Real Module
export async function fetchUsers() {
const res = await fetch('/api/users')
return res.json()
}
// Testing... Mock
vi.mock('../api', () => ({
fetchUsers: vi.fn().mockResolvedValue([
{ id: 1, name: 'Mock User' },
])
}))
方法 3: React Router をモックする
import { MemoryRouter } from 'react-router-dom'
test('Rendering in the route context', () => {
render(
<MemoryRouter initialEntries={['/users/1']}>
<UserDetailPage />
</MemoryRouter>
)
})
(4) ▶ サンプル:カスタムフックのテスト
import { renderHook, act } from '@testing-library/react'
import { useState, useCallback } from 'react'
function useCounter(initial = 0) {
const [count, setCount] = useState(initial)
const increment = useCallback(() => setCount(c => c + 1), [])
const decrement = useCallback(() => setCount(c => c - 1), [])
const reset = useCallback(() => setCount(initial), [initial])
return { count, increment, decrement, reset }
}
describe('useCounter', () => {
test('initializes with default value', () => {
const { result } = renderHook(() => useCounter())
expect(result.current.count).toBe(0)
})
test('initializes with custom value', () => {
const { result } = renderHook(() => useCounter(10))
expect(result.current.count).toBe(10)
})
test('increments counter', () => {
const { result } = renderHook(() => useCounter())
act(() => result.current.increment())
expect(result.current.count).toBe(1)
})
test('decrements counter', () => {
const { result } = renderHook(() => useCounter(5))
act(() => result.current.decrement())
expect(result.current.count).toBe(4)
})
test('resets to initial value', () => {
const { result } = renderHook(() => useCounter(10))
act(() => result.current.increment())
act(() => result.current.reset())
expect(result.current.count).toBe(10)
})
})
(5) ▶ サンプル:統合テスト—フォーム送信プロセス
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
function LoginForm({ onSubmit }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
async function handleSubmit(e) {
e.preventDefault()
if (!email.includes('@')) { setError('Invalid email'); return }
if (password.length < 6) { setError('Password too short'); return }
setError('')
await onSubmit({ email, password })
}
return (
<form onSubmit={handleSubmit}>
<input value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" data-testid="email" />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Password" data-testid="password" />
{error && <p data-testid="error">{error}</p>}
<button type="submit">Login</button>
</form>
)
}
describe('LoginForm integration', () => {
test('shows error for invalid email', async () => {
render(<LoginForm onSubmit={jest.fn()} />)
await userEvent.type(screen.getByTestId('email'), 'invalid')
await userEvent.type(screen.getByTestId('password'), 'password123')
await userEvent.click(screen.getByRole('button', { name: /login/i }))
expect(screen.getByTestId('error')).toHaveTextContent('Invalid email')
})
test('calls onSubmit with valid data', async () => {
const onSubmit = jest.fn().mockResolvedValue(undefined)
render(<LoginForm onSubmit={onSubmit} />)
await userEvent.type(screen.getByTestId('email'), 'alice@test.com')
await userEvent.type(screen.getByTestId('password'), 'secure123')
await userEvent.click(screen.getByRole('button', { name: /login/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({ email: 'alice@test.com', password: 'secure123' })
})
})
})
❓ よくある質問
getBy を使用します(見つからない場合はエラーがスローされます)。要素が存在しない可能性がある場合は queryBy を使用します(null を返します)。要素が非同期でレンダリングされる場合は findBy を使用します(Promise を返し、タイムアウト後にエラーがスローされます)。各メソッドには、getByRole、findByText、queryByTestId など、対応する Role/Text/TestId のバリエーションがあります。userEvent と fireEvent、どちらを使うべきですか?userEvent を使用してください。fireEvent は、DOM イベントを直接トリガーする低レベル API です。userEventは、fireEventの上にユーザー操作の完全なシーケンスをシミュレートします(例えば、「クリック」にはmousedown → mouseup → clickが含まれます)。これにより、実際のブラウザの挙動により忠実に再現されます。userEventでサポートされていない操作の場合にのみ、fireEventに切り替えてください。vi.mock('./ExpensiveChart', () => () => <div>Mock Chart</div>)に置き換えるだけで十分です。beforeEach でテスト環境をクリーンアップすべきですか?afterEach(() => { vi.clearAllMocks() })の使用をお勧めします。Vitestは各レンダリング後にコンポーネントを自動的にアンロードしますが、モックの状態は手動でクリーンアップする必要があります。グローバルなフェッチモックを使用している場合は、vi.restoreAllMocks()を使用して元の実装を復元してください。--coverageパラメータを使用すると、Istanbulのカバレッジレポートを生成できます。📖 まとめ
- Vitest と React Testing Library の組み合わせは、React のユニットテストにおける標準的な組み合わせです
- 3つの主要なAPI、すなわち「render」、「screen」、「userEvent」は、コンポーネントのレンダリング、検索、およびインタラクションの全プロセスを網羅しています。
- 3つのクエリメソッド、すなわち getBy(同期、存在が保証される)、queryBy(同期、存在しない場合もある)、および findBy(非同期、処理中)は、それぞれ特定の目的を果たします。
- vi.fn() は、コールバックの呼び出しを検証するためのモック関数を作成します。vi.spyOn() は、グローバル関数をモック化します。
- 非同期コンポーネントをテストする際は、
findByまたはwaitForを使用して、UI が非同期でレンダリングされるのを待ちます - 優れたテストは実装の詳細に焦点を当てず、ユーザーが認識できる動作のみを検証する。
📝 練習問題
Buttonコンポーネントの包括的なテストを作成してください。クリックするとonClickおよびdisabledがトリガーされ、クリック不可状態になり、正しいテキストが表示され、カスタム className が適用されることを確認してください。少なくとも 4 つのテストケースを網羅してください。UserProfileコンポーネントのテストを作成してください。このテストでは、読み込み中に「読み込み中」と表示され、成功時にはユーザー情報が表示され(非同期処理を含む)、失敗時にはエラーメッセージが表示されることを確認してください。vi.spyOnを使用して、フェッチ API をモックしてください。TodoAppコンポーネント(TodoList、TodoItem、および AddTodo フォームを含む)の統合テストを作成する:新しい ToDo の追加、ToDo の完了マーク、ToDo の削除という 3 つの機能をテストし、リストの UI が正しく更新されることを確認する。



