2026年のReactテスト: Vitest、React Testing Libraryとベストプラクティス

2026年最新のReactテスト手法を徹底解説。Vitest、React Testing Libraryの活用法、コンポーネントテスト、統合テスト、技術面接対策まで網羅的に紹介します。

2026年のReactテスト: Vitest、React Testing Libraryとベストプラクティス

2026年現在、Reactアプリケーションのテストは開発プロセスにおいて不可欠な要素となっています。Vitestの登場により、高速で設定が簡単なテスト環境が実現し、React Testing Library(RTL)との組み合わせによってユーザー視点でのテストが標準的な手法として確立されました。本記事では、最新のReactテスト技術、ベストプラクティス、そして技術面接でよく問われる質問について詳しく解説します。

なぜVitestなのか

Vitestは、Viteベースの高速テストフレームワークです。Jestと互換性のあるAPIを提供しながら、ESモジュールネイティブサポート、ホットモジュールリプレースメント(HMR)によるウォッチモードの高速化、TypeScriptの設定不要なサポートなど、現代的な開発体験を提供します。2026年のReactプロジェクトでは、Vitestが事実上の標準テストフレームワークとなっています。

Vitestの環境構築とReact Testing Libraryの統合

VitestとReact Testing Libraryを組み合わせることで、高速かつ信頼性の高いテスト環境を構築できます。まず、必要なパッケージをインストールします。

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

Vitestの設定ファイルでは、React環境とグローバルなテストユーティリティを設定します。

vitest.config.tstypescript
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    environment: 'jsdom',
    globals: true,
    setupFiles: ['./src/test/setup.ts'],
    include: ['**/*.{test,spec}.{ts,tsx}'],
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['node_modules/', 'src/test/'],
    },
  },
})

セットアップファイルでは、jest-domのマッチャーを拡張し、テスト間のクリーンアップを設定します。

src/test/setup.tstypescript
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'

afterEach(() => {
  cleanup()
})

// Mock window.matchMedia for responsive components
Object.defineProperty(window, 'matchMedia', {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
})

この設定により、各テストファイルでdescribeitexpectなどのグローバル関数を使用でき、各テスト後に自動的にDOMがクリーンアップされます。

コンポーネントテストの基本パターン

React Testing Libraryの核心的な考え方は、「ユーザーがコンポーネントを使用する方法でテストする」ことです。実装の詳細ではなく、ユーザーの視点からコンポーネントの動作を検証します。

基本的なコンポーネントテストの例を見てみましょう。

src/components/Button.test.tsxtypescript
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { Button } from './Button'

describe('Button', () => {
  it('renders with correct text', () => {
    render(<Button>Click me</Button>)
    expect(screen.getByRole('button', { name: /click me/i })).toBeInTheDocument()
  })

  it('calls onClick handler when clicked', async () => {
    const user = userEvent.setup()
    const handleClick = vi.fn()

    render(<Button onClick={handleClick}>Submit</Button>)
    await user.click(screen.getByRole('button', { name: /submit/i }))

    expect(handleClick).toHaveBeenCalledTimes(1)
  })

  it('is disabled when loading', () => {
    render(<Button isLoading>Loading...</Button>)
    expect(screen.getByRole('button')).toBeDisabled()
  })

  it('shows spinner when loading', () => {
    render(<Button isLoading>Submit</Button>)
    expect(screen.getByRole('button')).toContainElement(
      screen.getByTestId('spinner')
    )
  })
})

screen.getByRoleを使用することで、アクセシビリティの観点からも正しいマークアップを促進します。userEventはブラウザでの実際のユーザー操作をより正確にシミュレートするため、fireEventよりも推奨されます。

非同期処理とデータフェッチのテスト

Reactアプリケーションでは、APIからのデータフェッチなど非同期処理が頻繁に発生します。これらのテストには、waitForfindByクエリを使用します。

src/components/UserProfile.test.tsxtypescript
import { render, screen, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { UserProfile } from './UserProfile'

// Mock the fetch API
const mockUser = {
  id: '1',
  name: 'John Doe',
  email: 'john@example.com',
}

beforeEach(() => {
  vi.spyOn(global, 'fetch').mockResolvedValue({
    ok: true,
    json: async () => mockUser,
  } as Response)
})

describe('UserProfile', () => {
  it('displays loading state initially', () => {
    render(<UserProfile userId="1" />)
    expect(screen.getByText(/loading/i)).toBeInTheDocument()
  })

  it('displays user data after fetch', async () => {
    render(<UserProfile userId="1" />)

    // Wait for the user name to appear
    await waitFor(() => {
      expect(screen.getByText('John Doe')).toBeInTheDocument()
    })

    expect(screen.getByText('john@example.com')).toBeInTheDocument()
  })

  it('displays error message on fetch failure', async () => {
    vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network error'))

    render(<UserProfile userId="1" />)

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent(/error/i)
    })
  })
})

findByクエリは内部的にwaitForを使用するため、非同期要素の待機に便利です。

typescript
it('displays user data after fetch', async () => {
  render(<UserProfile userId="1" />)

  // findBy automatically waits for the element
  const userName = await screen.findByText('John Doe')
  expect(userName).toBeInTheDocument()
})

フォームテストとユーザーインタラクション

フォームのテストは、バリデーション、送信、エラー表示など複数の側面を検証する必要があります。

src/components/LoginForm.test.tsxtypescript
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { LoginForm } from './LoginForm'

describe('LoginForm', () => {
  it('submits form with valid data', async () => {
    const user = userEvent.setup()
    const handleSubmit = vi.fn()

    render(<LoginForm onSubmit={handleSubmit} />)

    await user.type(screen.getByLabelText(/email/i), 'test@example.com')
    await user.type(screen.getByLabelText(/password/i), 'password123')
    await user.click(screen.getByRole('button', { name: /sign in/i }))

    await waitFor(() => {
      expect(handleSubmit).toHaveBeenCalledWith({
        email: 'test@example.com',
        password: 'password123',
      })
    })
  })

  it('displays validation errors for invalid email', async () => {
    const user = userEvent.setup()

    render(<LoginForm onSubmit={vi.fn()} />)

    await user.type(screen.getByLabelText(/email/i), 'invalid-email')
    await user.click(screen.getByRole('button', { name: /sign in/i }))

    await waitFor(() => {
      expect(screen.getByText(/valid email/i)).toBeInTheDocument()
    })
  })

  it('disables submit button while submitting', async () => {
    const user = userEvent.setup()
    const handleSubmit = vi.fn(() => new Promise((r) => setTimeout(r, 100)))

    render(<LoginForm onSubmit={handleSubmit} />)

    await user.type(screen.getByLabelText(/email/i), 'test@example.com')
    await user.type(screen.getByLabelText(/password/i), 'password123')
    await user.click(screen.getByRole('button', { name: /sign in/i }))

    expect(screen.getByRole('button', { name: /sign in/i })).toBeDisabled()
  })
})

userEvent.typeは実際のキーボード入力をシミュレートし、各キーストロークでイベントを発火させます。これにより、入力中のバリデーションやデバウンス処理も正しくテストできます。

React / Next.jsの面接対策はできていますか?

インタラクティブなシミュレーター、flashcards、技術テストで練習しましょう。

カスタムフックのテスト

カスタムフックのテストには、@testing-library/reactrenderHookを使用します。

src/hooks/useCounter.test.tstypescript
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { useCounter } from './useCounter'

describe('useCounter', () => {
  it('initializes with default value', () => {
    const { result } = renderHook(() => useCounter())
    expect(result.current.count).toBe(0)
  })

  it('initializes with provided value', () => {
    const { result } = renderHook(() => useCounter(10))
    expect(result.current.count).toBe(10)
  })

  it('increments counter', () => {
    const { result } = renderHook(() => useCounter())

    act(() => {
      result.current.increment()
    })

    expect(result.current.count).toBe(1)
  })

  it('decrements counter', () => {
    const { result } = renderHook(() => useCounter(5))

    act(() => {
      result.current.decrement()
    })

    expect(result.current.count).toBe(4)
  })

  it('resets counter to initial value', () => {
    const { result } = renderHook(() => useCounter(10))

    act(() => {
      result.current.increment()
      result.current.increment()
      result.current.reset()
    })

    expect(result.current.count).toBe(10)
  })
})

状態を変更する操作は必ずactでラップします。これにより、Reactの状態更新が完了してからアサーションが実行されます。

コンテキストとプロバイダーのテスト

コンテキストを使用するコンポーネントをテストするには、テスト用のラッパーを作成します。

src/test/utils.tsxtypescript
import { ReactNode } from 'react'
import { render, RenderOptions } from '@testing-library/react'
import { ThemeProvider } from '@/contexts/ThemeContext'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const createTestQueryClient = () =>
  new QueryClient({
    defaultOptions: {
      queries: {
        retry: false,
        gcTime: 0,
      },
    },
  })

interface WrapperProps {
  children: ReactNode
}

function AllProviders({ children }: WrapperProps) {
  const queryClient = createTestQueryClient()

  return (
    <QueryClientProvider client={queryClient}>
      <ThemeProvider defaultTheme="light">{children}</ThemeProvider>
    </QueryClientProvider>
  )
}

const customRender = (
  ui: React.ReactElement,
  options?: Omit<RenderOptions, 'wrapper'>
) => render(ui, { wrapper: AllProviders, ...options })

export * from '@testing-library/react'
export { customRender as render }

このカスタムrender関数を使用することで、すべてのテストで一貫したプロバイダー構成を維持できます。

モックとスパイの効果的な活用

Vitestでは、vi.mockvi.spyOnを使用してモジュールや関数をモック化できます。

src/services/api.test.tstypescript
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { fetchUsers, createUser } from './api'

// Mock the entire module
vi.mock('./http-client', () => ({
  httpClient: {
    get: vi.fn(),
    post: vi.fn(),
  },
}))

import { httpClient } from './http-client'

describe('API Service', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('fetches users successfully', async () => {
    const mockUsers = [{ id: '1', name: 'John' }]
    vi.mocked(httpClient.get).mockResolvedValue({ data: mockUsers })

    const users = await fetchUsers()

    expect(httpClient.get).toHaveBeenCalledWith('/users')
    expect(users).toEqual(mockUsers)
  })

  it('creates user with correct payload', async () => {
    const newUser = { name: 'Jane', email: 'jane@example.com' }
    vi.mocked(httpClient.post).mockResolvedValue({ data: { id: '2', ...newUser } })

    await createUser(newUser)

    expect(httpClient.post).toHaveBeenCalledWith('/users', newUser)
  })
})

vi.mockedは型安全なモックを提供し、TypeScriptの補完機能を活用できます。

React Testing面接でよくある質問

技術面接では、Reactテストに関する深い理解が求められます。以下は、よく出題される質問とその回答です。

Q1: getByqueryByfindByの違いを説明してください。

getByは要素が存在することを前提とし、見つからない場合はエラーをスローします。同期的に動作し、要素が確実に存在する場合に使用します。queryByは要素が見つからない場合にnullを返します。要素が存在しないことをアサートする場合に使用します。findByは非同期で動作し、要素が現れるまで待機します。デフォルトで1000msのタイムアウトがあり、非同期レンダリングやデータフェッチ後の要素に使用します。

Q2: なぜfireEventよりuserEventが推奨されるのですか。

userEventは実際のブラウザでのユーザー操作をより正確にシミュレートします。例えば、userEvent.typeはキーボードイベント(keydown、keypress、keyup)と入力イベントの両方を発火させ、フォーカス管理も行います。一方、fireEventは単一のイベントのみを発火させるため、実際のユーザー操作と異なる動作になる可能性があります。userEventを使用することで、より現実的なテストシナリオを作成できます。

Q3: スナップショットテストの利点と欠点は何ですか。

利点として、UIの予期しない変更を検出でき、設定が簡単で大きなコンポーネントツリーも素早くテストできます。欠点として、頻繁に変更されるコンポーネントではメンテナンスコストが高く、実際の動作ではなく出力構造のみをテストするため、重要なバグを見逃す可能性があります。また、開発者がスナップショットを無批判に更新してしまう傾向があります。ベストプラクティスとして、スナップショットテストは静的なUIコンポーネントに限定し、インタラクティブな機能はユーザーイベントベースのテストで検証することが推奨されます。

Q4: テストカバレッジ100%を目指すべきですか。

100%のカバレッジは必ずしも高品質なテストを意味しません。重要なのは、ビジネスクリティカルなコードパスが十分にテストされていることです。カバレッジは指標の一つに過ぎず、テストの品質や意味のあるアサーションの存在を保証しません。一般的に、80%程度のカバレッジを目標とし、残りの20%は複雑性やリスクに基づいて判断します。ユーティリティ関数やビジネスロジックには高いカバレッジを維持し、UIコンポーネントはユーザーインタラクションに焦点を当てたテストを優先することが効果的です。

Q5: テストピラミッドとテストトロフィーの違いは何ですか。

テストピラミッドは、ユニットテストを多く、統合テストを中程度、E2Eテストを少なく配置する従来のアプローチです。テストトロフィーは、Reactアプリケーションに適した現代的なアプローチで、統合テストを最も重視します。これは、コンポーネント間の相互作用をテストすることで、ユニットテストよりも高い信頼性を得られるためです。React Testing Libraryの作者であるKent C. Doddsが提唱し、「テストがソフトウェアの使用方法に似ているほど、より多くの信頼を得られる」という原則に基づいています。

統合テストのベストプラクティス

統合テストでは、複数のコンポーネントが連携して動作することを検証します。

src/features/checkout/Checkout.integration.test.tsxtypescript
import { render, screen, waitFor } from '@/test/utils'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi } from 'vitest'
import { CheckoutPage } from './CheckoutPage'
import { server } from '@/test/mocks/server'
import { http, HttpResponse } from 'msw'

describe('Checkout Flow', () => {
  it('completes checkout successfully', async () => {
    const user = userEvent.setup()

    render(<CheckoutPage />)

    // Fill shipping information
    await user.type(screen.getByLabelText(/full name/i), 'John Doe')
    await user.type(screen.getByLabelText(/address/i), '123 Main St')
    await user.type(screen.getByLabelText(/city/i), 'New York')
    await user.click(screen.getByRole('button', { name: /continue to payment/i }))

    // Fill payment information
    await user.type(screen.getByLabelText(/card number/i), '4242424242424242')
    await user.type(screen.getByLabelText(/expiry/i), '12/28')
    await user.type(screen.getByLabelText(/cvc/i), '123')
    await user.click(screen.getByRole('button', { name: /place order/i }))

    // Verify success state
    await waitFor(() => {
      expect(screen.getByText(/order confirmed/i)).toBeInTheDocument()
    })
  })

  it('handles payment failure gracefully', async () => {
    // Override the default handler for this test
    server.use(
      http.post('/api/payments', () => {
        return HttpResponse.json(
          { error: 'Payment declined' },
          { status: 400 }
        )
      })
    )

    const user = userEvent.setup()
    render(<CheckoutPage />)

    // ... fill form and submit ...

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent(/payment declined/i)
    })
  })
})

MSW(Mock Service Worker)を使用することで、ネットワークレベルでAPIをモック化し、より現実的な統合テストを実現できます。

テストのパフォーマンス最適化

大規模なテストスイートでは、実行時間の最適化が重要です。

vitest.config.tstypescript
export default defineConfig({
  test: {
    // Run tests in parallel
    pool: 'threads',
    poolOptions: {
      threads: {
        singleThread: false,
      },
    },
    // Only run affected tests in watch mode
    watch: {
      include: ['src/**/*.{ts,tsx}'],
    },
    // Isolate test files for reliability
    isolate: true,
  },
})

また、重いセットアップはbeforeAllで一度だけ実行し、beforeEachでの再初期化を最小限に抑えます。

今すぐ練習を始めましょう!

面接シミュレーターと技術テストで知識をテストしましょう。

まとめ: 信頼性の高いReactテストを書くために

2026年のReactテストにおいて、VitestとReact Testing Libraryの組み合わせは、高速で信頼性の高いテスト環境を提供します。

本記事で学んだ主要なポイント:

  • ユーザー視点でテストする: 実装の詳細ではなく、ユーザーがコンポーネントをどのように使用するかに焦点を当てる
  • 適切なクエリを選択する: getByqueryByfindByを状況に応じて使い分ける
  • userEventを優先する: より現実的なユーザーインタラクションをシミュレートする
  • 統合テストを重視する: テストトロフィーの考え方に基づき、コンポーネント間の連携をテストする
  • モックを適切に使用する: 外部依存はモック化し、内部実装はできるだけ実際のコードを使用する

Reactテストのスキルは、コードの品質を保証するだけでなく、リファクタリングの安全性を高め、チーム全体の開発速度を向上させます。技術面接においても、テストに関する深い理解は、候補者の実践的な開発能力を示す重要な指標となります。

タグ

#react
#testing
#vitest
#react-testing-library
#frontend

共有

関連記事