404 Not Found

404 Not Found


nginx

単体テスト(Vitest + React Testing Library)

古いコンポーネントのリファクタリング中に、トムが「うっかり」内部の状態ロジックを変更してしまい、そのコンポーネントに依存していた3つのページでUIの問題が発生した。ユニットテストが実施されていなかったため、この問題はQAテストの段階になるまで発見されず、チーム全体のイテレーション時間が無駄になってしまった。そこでトムは、変更のたびに既存の機能が破損しないことを確認するために、自動テストを導入し、VitestとReact Testing Libraryをプロジェクトに導入することを決めた。


1. 学習内容


2. 概念図

次の図は、Reactコンポーネント開発におけるユニットテストの役割と関係性を示しています:

100%
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) 環境のセットアップ — テストインフラの構築

まず、すべての依存関係をインストールします:

BASH
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

Vitest を設定する(vite.config.tstest フィールドを追加する):

TS
// 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',
  },
})

セットアップファイルを作成します:

TS
// src/test/setup.ts
import '@testing-library/jest-dom/vitest'
// This line allows toBeInTheDocument()、toHaveTextContent() Assertions are available

package.json にテストスクリプトを追加する:

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コンポーネントを書いてみましょう:

TSX
// 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>
  )
}
▶ 試してみよう

テストの作成:

TSX
// 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つのモードが紹介されています:

  1. getByText — 指定されたテキストを含む要素を検索する(最も簡単で直接的な方法)
  2. getByRole — ARIAロールによる検索。オプション name を指定すると、ボタンのテキストと完全に一致する結果が得られます。
  3. queryByRole — 存在しない可能性のある要素を検索し、例外をスローする代わりに null を返す
  4. toBeDisabled() / toHaveTextContent() — jest-dom が提供する意味論的アサーション

(3) プロパティとイベントコールバックのテスト

コンポーネントは通常、props を通じてデータやコールバック関数を受け取ります。テストの目的は、コールバックが正しく呼び出され、パラメータが正しいことを確認することです。

(2) ▶ サンプル:TodoItem コンポーネントのプロパティとイベントのテスト

TSX
// 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>
  )
}
▶ 試してみよう
TSX
// 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) ▶ サンプル:非同期データ読み込みコンポーネントのテスト

TSX
// 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>
  )
}
▶ 試してみよう
TSX
// 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 をモックする

TS
// 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:モックモジュール

TS
// 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 をモックする

TSX
import { MemoryRouter } from 'react-router-dom'

test('Rendering in the route context', () => {
  render(
    <MemoryRouter initialEntries={['/users/1']}>
      <UserDetailPage />
    </MemoryRouter>
  )
})

(4) ▶ サンプル:カスタムフックのテスト

JSX
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) ▶ サンプル:統合テスト—フォーム送信プロセス

JSX
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' })
    })
  })
})
▶ 試してみよう

❓ よくある質問

Q getBy、findBy、queryBy の違いは具体的に何ですか?
A 簡単な目安として、要素の存在が確実な場合は getBy を使用します(見つからない場合はエラーがスローされます)。要素が存在しない可能性がある場合は queryBy を使用します(null を返します)。要素が非同期でレンダリングされる場合は findBy を使用します(Promise を返し、タイムアウト後にエラーがスローされます)。各メソッドには、getByRolefindByTextqueryByTestId など、対応する Role/Text/TestId のバリエーションがあります。
Q userEventfireEvent、どちらを使うべきですか?
A 可能な限り userEvent を使用してください。fireEvent は、DOM イベントを直接トリガーする低レベル API です。userEventは、fireEventの上にユーザー操作の完全なシーケンスをシミュレートします(例えば、「クリック」にはmousedown → mouseup → clickが含まれます)。これにより、実際のブラウザの挙動により忠実に再現されます。userEventでサポートされていない操作の場合にのみ、fireEventに切り替えてください。
Q テストで子コンポーネントをモックすべきですか?
A React Testing Libraryの理念は「子コンポーネントをモックしない」ことです。なぜなら、テストではユーザーの視点、つまりユーザーが目にしている完全なコンポーネントツリーを再現すべきだからです。子コンポーネントをモックすることを検討すべきなのは、それらが重大な副作用を持つ場合(複雑なアニメーションライブラリやサードパーティ製のチャートコンポーネントなど)に限られます。それらは単にvi.mock('./ExpensiveChart', () => () => <div>Mock Chart</div>)に置き換えるだけで十分です。
Q beforeEach でテスト環境をクリーンアップすべきですか?
A はい。各テスト終了後は、レンダリングされたDOMとモックをクリーンアップする必要があります。afterEach(() => { vi.clearAllMocks() })の使用をお勧めします。Vitestは各レンダリング後にコンポーネントを自動的にアンロードしますが、モックの状態は手動でクリーンアップする必要があります。グローバルなフェッチモックを使用している場合は、vi.restoreAllMocks()を使用して元の実装を復元してください。
Q どの程度のテストカバレッジがあれば十分ですか?
A 画一的な基準はありませんが、業界のベンチマークでは、中核となるビジネスロジックで80%以上、汎用ユーティリティ関数で90%以上、UIコンポーネントで60%以上が目安とされています。100%のカバレッジを目指す必要はありません。CSSスタイルや単純なプロパティの受け渡しなど、テストのROIが非常に低いコードもあるからです。重要なのは、「バグが発生した場合に最も大きな影響を与える」コードパスをカバーすることです。Vitestの--coverageパラメータを使用すると、Istanbulのカバレッジレポートを生成できます。

📖 まとめ


📝 練習問題

  1. Button コンポーネントの包括的なテストを作成してください。クリックすると onClick および disabled がトリガーされ、クリック不可状態になり、正しいテキストが表示され、カスタム className が適用されることを確認してください。少なくとも 4 つのテストケースを網羅してください。
  2. UserProfile コンポーネントのテストを作成してください。このテストでは、読み込み中に「読み込み中」と表示され、成功時にはユーザー情報が表示され(非同期処理を含む)、失敗時にはエラーメッセージが表示されることを確認してください。vi.spyOn を使用して、フェッチ API をモックしてください。
  3. TodoApp コンポーネント(TodoList、TodoItem、および AddTodo フォームを含む)の統合テストを作成する:新しい ToDo の追加、ToDo の完了マーク、ToDo の削除という 3 つの機能をテストし、リストの UI が正しく更新されることを確認する。
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%