フロントエンドテスト戦略2026 — テストピラミッドの実践


フロントエンドテストの課題

フロントエンドテストには、以下のような課題があります。

  • UIの変更に脆弱なテスト
  • 遅いE2Eテストによる開発速度の低下
  • APIへの依存によるテストの不安定性
  • モックの複雑さ
  • テストコードのメンテナンスコスト

これらを解決するには、適切なテスト戦略とツールの選択が不可欠です。

テストピラミッド

        /\
       /  \
      / E2E \        少ない(重要なフロー)
     /--------\
    /          \
   / Integration \   中程度(コンポーネント統合)
  /--------------\
 /                \
/  Unit Tests      \  多い(ロジック、ユーティリティ)
--------------------

理想的な比率

  • Unit Tests: 70% - 高速、安定、メンテナンス容易
  • Integration Tests: 20% - コンポーネント間の連携
  • E2E Tests: 10% - クリティカルなユーザーフロー

セットアップ

Vitest + Testing Library

pnpm add -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './vitest.setup.ts',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: ['**/*.config.*', '**/node_modules/**'],
    },
  },
})
// vitest.setup.ts
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'

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

Playwright

pnpm create playwright
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
  ],
  webServer: {
    command: 'pnpm dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

Unit Testing

ユーティリティ関数のテスト

// src/lib/format.ts
export function formatCurrency(amount: number): string {
  return new Intl.NumberFormat('ja-JP', {
    style: 'currency',
    currency: 'JPY',
  }).format(amount)
}

// src/lib/format.test.ts
import { describe, it, expect } from 'vitest'
import { formatCurrency } from './format'

describe('formatCurrency', () => {
  it('正の数値を正しくフォーマットする', () => {
    expect(formatCurrency(1000)).toBe('¥1,000')
  })

  it('0を正しくフォーマットする', () => {
    expect(formatCurrency(0)).toBe('¥0')
  })

  it('小数点以下を正しく処理する', () => {
    expect(formatCurrency(1234.56)).toBe('¥1,235')
  })
})

カスタムHooksのテスト

// src/hooks/use-toggle.ts
import { useState, useCallback } from 'react'

export function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue)
  const toggle = useCallback(() => setValue(v => !v), [])
  const setTrue = useCallback(() => setValue(true), [])
  const setFalse = useCallback(() => setValue(false), [])

  return { value, toggle, setTrue, setFalse }
}

// src/hooks/use-toggle.test.ts
import { renderHook, act } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { useToggle } from './use-toggle'

describe('useToggle', () => {
  it('初期値がfalseの場合', () => {
    const { result } = renderHook(() => useToggle())
    expect(result.current.value).toBe(false)
  })

  it('toggleで値が反転する', () => {
    const { result } = renderHook(() => useToggle())

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

    expect(result.current.value).toBe(true)

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

    expect(result.current.value).toBe(false)
  })
})

コンポーネントテスト

基本的なコンポーネント

// src/components/button.tsx
interface ButtonProps {
  children: React.ReactNode
  onClick?: () => void
  variant?: 'primary' | 'secondary'
  disabled?: boolean
}

export function Button({
  children,
  onClick,
  variant = 'primary',
  disabled = false
}: ButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      className={`btn btn-${variant}`}
    >
      {children}
    </button>
  )
}

// src/components/button.test.tsx
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('子要素をレンダリングする', () => {
    render(<Button>Click me</Button>)
    expect(screen.getByRole('button')).toHaveTextContent('Click me')
  })

  it('クリックイベントを処理する', async () => {
    const handleClick = vi.fn()
    const user = userEvent.setup()

    render(<Button onClick={handleClick}>Click</Button>)

    await user.click(screen.getByRole('button'))

    expect(handleClick).toHaveBeenCalledTimes(1)
  })

  it('disabledの時はクリックできない', async () => {
    const handleClick = vi.fn()
    const user = userEvent.setup()

    render(<Button onClick={handleClick} disabled>Click</Button>)

    const button = screen.getByRole('button')
    expect(button).toBeDisabled()

    await user.click(button)
    expect(handleClick).not.toHaveBeenCalled()
  })

  it('variantに応じたclassNameを持つ', () => {
    const { rerender } = render(<Button variant="primary">Primary</Button>)
    expect(screen.getByRole('button')).toHaveClass('btn-primary')

    rerender(<Button variant="secondary">Secondary</Button>)
    expect(screen.getByRole('button')).toHaveClass('btn-secondary')
  })
})

フォームのテスト

// src/components/login-form.tsx
'use client'

import { useState } from 'react'

interface LoginFormProps {
  onSubmit: (data: { email: string; password: string }) => Promise<void>
}

export function LoginForm({ onSubmit }: LoginFormProps) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [error, setError] = useState('')
  const [loading, setLoading] = useState(false)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setError('')
    setLoading(true)

    try {
      await onSubmit({ email, password })
    } catch (err) {
      setError(err instanceof Error ? err.message : 'エラーが発生しました')
    } finally {
      setLoading(false)
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        placeholder="Email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        required
      />
      <input
        type="password"
        placeholder="Password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        required
      />
      {error && <p role="alert">{error}</p>}
      <button type="submit" disabled={loading}>
        {loading ? 'ログイン中...' : 'ログイン'}
      </button>
    </form>
  )
}

// src/components/login-form.test.tsx
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 './login-form'

describe('LoginForm', () => {
  it('フォーム入力とサブミット', async () => {
    const handleSubmit = vi.fn().mockResolvedValue(undefined)
    const user = userEvent.setup()

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

    await user.type(screen.getByPlaceholderText('Email'), 'test@example.com')
    await user.type(screen.getByPlaceholderText('Password'), 'password123')
    await user.click(screen.getByRole('button', { name: 'ログイン' }))

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

  it('エラーメッセージを表示する', async () => {
    const handleSubmit = vi.fn().mockRejectedValue(new Error('認証に失敗しました'))
    const user = userEvent.setup()

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

    await user.type(screen.getByPlaceholderText('Email'), 'test@example.com')
    await user.type(screen.getByPlaceholderText('Password'), 'wrong')
    await user.click(screen.getByRole('button', { name: 'ログイン' }))

    await waitFor(() => {
      expect(screen.getByRole('alert')).toHaveTextContent('認証に失敗しました')
    })
  })

  it('送信中はボタンが無効化される', async () => {
    const handleSubmit = vi.fn(() => new Promise(resolve => setTimeout(resolve, 100)))
    const user = userEvent.setup()

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

    await user.type(screen.getByPlaceholderText('Email'), 'test@example.com')
    await user.type(screen.getByPlaceholderText('Password'), 'password123')

    const button = screen.getByRole('button')
    await user.click(button)

    expect(button).toBeDisabled()
    expect(button).toHaveTextContent('ログイン中...')

    await waitFor(() => {
      expect(button).not.toBeDisabled()
    })
  })
})

MSWによるAPIモック

セットアップ

pnpm add -D msw
// src/mocks/handlers.ts
import { http, HttpResponse } from 'msw'

export const handlers = [
  http.get('/api/user', () => {
    return HttpResponse.json({
      id: '1',
      name: 'Test User',
      email: 'test@example.com',
    })
  }),

  http.post('/api/login', async ({ request }) => {
    const { email, password } = await request.json()

    if (email === 'test@example.com' && password === 'password123') {
      return HttpResponse.json({
        token: 'mock-token',
        user: { id: '1', name: 'Test User' },
      })
    }

    return HttpResponse.json(
      { message: '認証に失敗しました' },
      { status: 401 }
    )
  }),
]

// src/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'

export const server = setupServer(...handlers)

// vitest.setup.ts に追加
import { beforeAll, afterEach, afterAll } from 'vitest'
import { server } from './mocks/server'

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

テストでの使用

// src/lib/api.test.ts
import { describe, it, expect } from 'vitest'
import { server } from '@/mocks/server'
import { http, HttpResponse } from 'msw'
import { fetchUser } from './api'

describe('fetchUser', () => {
  it('ユーザー情報を取得する', async () => {
    const user = await fetchUser('1')

    expect(user).toEqual({
      id: '1',
      name: 'Test User',
      email: 'test@example.com',
    })
  })

  it('エラー時に例外を投げる', async () => {
    server.use(
      http.get('/api/user', () => {
        return HttpResponse.json(
          { message: 'Not found' },
          { status: 404 }
        )
      })
    )

    await expect(fetchUser('999')).rejects.toThrow('Not found')
  })
})

E2Eテスト(Playwright)

// e2e/login.spec.ts
import { test, expect } from '@playwright/test'

test.describe('ログインフロー', () => {
  test('正常なログイン', async ({ page }) => {
    await page.goto('/login')

    await page.fill('input[type="email"]', 'test@example.com')
    await page.fill('input[type="password"]', 'password123')
    await page.click('button[type="submit"]')

    await expect(page).toHaveURL('/dashboard')
    await expect(page.locator('h1')).toContainText('Dashboard')
  })

  test('不正な認証情報', async ({ page }) => {
    await page.goto('/login')

    await page.fill('input[type="email"]', 'test@example.com')
    await page.fill('input[type="password"]', 'wrong-password')
    await page.click('button[type="submit"]')

    await expect(page.locator('[role="alert"]')).toContainText('認証に失敗しました')
    await expect(page).toHaveURL('/login')
  })
})

// e2e/checkout.spec.ts
test.describe('購入フロー', () => {
  test('商品の購入', async ({ page }) => {
    await page.goto('/products')

    await page.click('text=商品1')
    await page.click('button:has-text("カートに追加")')
    await page.click('a:has-text("カート")')

    await expect(page.locator('.cart-item')).toHaveCount(1)

    await page.click('button:has-text("購入手続き")')

    await page.fill('input[name="name"]', 'Test User')
    await page.fill('input[name="email"]', 'test@example.com')
    await page.fill('input[name="address"]', '東京都渋谷区...')

    await page.click('button[type="submit"]')

    await expect(page).toHaveURL(/\/order\/\d+/)
    await expect(page.locator('h1')).toContainText('注文完了')
  })
})

CI/CDでの実行

# .github/workflows/test.yml
name: Test

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v3
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'pnpm'

      - run: pnpm install
      - run: pnpm test:unit
      - run: pnpm test:e2e

      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: playwright-report
          path: playwright-report/

まとめ

効果的なフロントエンドテスト戦略には、以下が重要です。

  • テストピラミッドに従った適切なバランス
  • Vitestによる高速なUnit/Integration Tests
  • MSWによる安定したAPIモック
  • PlaywrightによるクリティカルパスのE2Eテスト
  • CI/CDへの統合

これらを組み合わせることで、高品質なフロントエンドアプリケーションを効率的に開発できます。