DT 技術解説 フロントエンドテスト完全ガイド2025 -

フロントエンドテスト完全ガイド2025 - 単体テストからE2Eまで包括的テスト戦略

モダンフロントエンド開発における包括的なテスト戦略を解説。Jest、Vitest、Testing Library、Playwright、Cypressを使った実践的なテスト手法とベストプラクティス

約5分で読めます
技術記事
実践的

この記事のポイント

モダンフロントエンド開発における包括的なテスト戦略を解説。Jest、Vitest、Testing Library、Playwright、Cypressを使った実践的なテスト手法とベストプラクティス

この記事では、実践的なアプローチで技術的な課題を解決する方法を詳しく解説します。具体的なコード例とともに、ベストプラクティスを学ぶことができます。

フロントエンドテストは、アプリケーションの品質と信頼性を保証する重要な要素です。2025年現在、テストツールとフレームワークは大きく進化し、より効率的で包括的なテスト戦略が可能になっています。本記事では、最新のツールとベストプラクティスを詳しく解説します。

フロントエンドテストの全体像

graph TD
    A[フロントエンドテスト] --> B[単体テスト]
    A --> C[統合テスト]
    A --> D[E2Eテスト]
    A --> E[視覚的回帰テスト]
    A --> F[パフォーマンステスト]
    B --> G[Vitest/Jest]
    C --> H[Testing Library]
    D --> I[Playwright/Cypress]
    E --> J[Percy/Chromatic]
    F --> K[Lighthouse CI]

テストピラミッドとテスト戦略

モダンなテストピラミッド

graph TD
    A[E2Eテスト - 10%] --> B[統合テスト - 30%]
    B --> C[単体テスト - 60%]
    D[高速・安定] --> C
    E[バランス] --> B
    F[実環境に近い] --> A

テストの種類と目的

// テスト戦略の定義
interface TestStrategy {
  unit: {
    coverage: 80; // カバレッジ目標
    tools: ['Vitest', 'Jest'];
    focus: 'ビジネスロジック、ユーティリティ関数';
  };
  integration: {
    coverage: 60;
    tools: ['Testing Library'];
    focus: 'コンポーネント間の連携、ユーザーインタラクション';
  };
  e2e: {
    coverage: 'クリティカルパス';
    tools: ['Playwright', 'Cypress'];
    focus: 'ユーザージャーニー、重要なフロー';
  };
}

1. 単体テスト - Vitest/Jest

Vitestのセットアップ(推奨)

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

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
    coverage: {
      provider: 'v8',
      reporter: ['text', 'json', 'html'],
      exclude: [
        'node_modules/',
        'src/test/',
        '**/*.d.ts',
        '**/*.config.*',
        '**/mockData/**'
      ]
    },
    // パフォーマンス向上のための並列実行
    threads: true,
    maxThreads: 4
  },
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  }
})

実践的な単体テストの例

// utils/validation.test.ts
import { describe, it, expect } from 'vitest'
import { validateEmail, validatePassword, validateForm } from './validation'

describe('Validation Utils', () => {
  describe('validateEmail', () => {
    it.each([
      ['user@example.com', true],
      ['user+tag@example.co.jp', true],
      ['invalid-email', false],
      ['@example.com', false],
      ['user@', false],
      ['', false],
      [null, false],
    ])('validates %s as %s', (email, expected) => {
      expect(validateEmail(email)).toBe(expected)
    })
  })

  describe('validatePassword', () => {
    it('should enforce minimum length', () => {
      expect(validatePassword('short')).toEqual({
        valid: false,
        errors: ['Password must be at least 8 characters']
      })
    })

    it('should require special characters', () => {
      expect(validatePassword('LongEnough123')).toEqual({
        valid: false,
        errors: ['Password must contain at least one special character']
      })
    })

    it('should validate strong passwords', () => {
      expect(validatePassword('StrongP@ss123')).toEqual({
        valid: true,
        errors: []
      })
    })
  })
})

// hooks/useDebounce.test.ts
import { renderHook, act } from '@testing-library/react'
import { useDebounce } from './useDebounce'
import { vi } from 'vitest'

describe('useDebounce', () => {
  beforeEach(() => {
    vi.useFakeTimers()
  })

  afterEach(() => {
    vi.runOnlyPendingTimers()
    vi.useRealTimers()
  })

  it('should debounce value updates', () => {
    const { result, rerender } = renderHook(
      ({ value, delay }) => useDebounce(value, delay),
      { initialProps: { value: 'initial', delay: 500 } }
    )

    expect(result.current).toBe('initial')

    // 値を更新
    rerender({ value: 'updated', delay: 500 })
    expect(result.current).toBe('initial')

    // 500ms経過
    act(() => {
      vi.advanceTimersByTime(500)
    })

    expect(result.current).toBe('updated')
  })
})

2. コンポーネントテスト - Testing Library

セットアップと基本的なテスト

// test/setup.ts
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'

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

// components/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserProfile } from './UserProfile'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { vi } from 'vitest'

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
    },
  })
  
  return ({ children }: { children: React.ReactNode }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  )
}

describe('UserProfile', () => {
  it('should display user information', async () => {
    const mockUser = {
      id: 1,
      name: 'John Doe',
      email: 'john@example.com',
      avatar: 'https://example.com/avatar.jpg'
    }

    // APIモック
    vi.spyOn(window, 'fetch').mockResolvedValueOnce({
      ok: true,
      json: async () => mockUser,
    } as Response)

    render(<UserProfile userId={1} />, { wrapper: createWrapper() })

    // ローディング状態の確認
    expect(screen.getByText(/loading/i)).toBeInTheDocument()

    // データ表示の確認
    await waitFor(() => {
      expect(screen.getByText(mockUser.name)).toBeInTheDocument()
      expect(screen.getByText(mockUser.email)).toBeInTheDocument()
      expect(screen.getByRole('img', { name: /avatar/i })).toHaveAttribute(
        'src',
        mockUser.avatar
      )
    })
  })

  it('should handle user interactions', async () => {
    const user = userEvent.setup()
    const onEdit = vi.fn()

    render(
      <UserProfile userId={1} onEdit={onEdit} />,
      { wrapper: createWrapper() }
    )

    // ユーザーインタラクション
    const editButton = await screen.findByRole('button', { name: /edit/i })
    await user.click(editButton)

    expect(onEdit).toHaveBeenCalledWith(1)
  })
})

複雑なインタラクションテスト

// components/TodoList.test.tsx
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { TodoList } from './TodoList'

describe('TodoList', () => {
  it('should manage todo items', async () => {
    const user = userEvent.setup()
    render(<TodoList />)

    // 新しいTodo追加
    const input = screen.getByPlaceholderText(/add new todo/i)
    const addButton = screen.getByRole('button', { name: /add/i })

    await user.type(input, 'Learn Testing')
    await user.click(addButton)

    // Todo項目の確認
    const todoItem = screen.getByRole('listitem')
    expect(within(todoItem).getByText('Learn Testing')).toBeInTheDocument()

    // 完了状態の切り替え
    const checkbox = within(todoItem).getByRole('checkbox')
    await user.click(checkbox)
    
    expect(checkbox).toBeChecked()
    expect(todoItem).toHaveClass('completed')

    // 削除
    const deleteButton = within(todoItem).getByRole('button', { name: /delete/i })
    await user.click(deleteButton)

    expect(screen.queryByText('Learn Testing')).not.toBeInTheDocument()
  })

  it('should filter todos', async () => {
    const user = userEvent.setup()
    const initialTodos = [
      { id: 1, text: 'Todo 1', completed: false },
      { id: 2, text: 'Todo 2', completed: true },
      { id: 3, text: 'Todo 3', completed: false }
    ]

    render(<TodoList initialTodos={initialTodos} />)

    // すべて表示
    expect(screen.getAllByRole('listitem')).toHaveLength(3)

    // アクティブのみ
    await user.click(screen.getByRole('button', { name: /active/i }))
    expect(screen.getAllByRole('listitem')).toHaveLength(2)

    // 完了のみ
    await user.click(screen.getByRole('button', { name: /completed/i }))
    expect(screen.getAllByRole('listitem')).toHaveLength(1)
  })
})

3. E2Eテスト - Playwright

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'],
    ['junit', { outputFile: 'test-results/junit.xml' }],
    ['json', { outputFile: 'test-results/results.json' }]
  ],
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
  },
  projects: [
    {
      name: 'chromium',
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'webkit',
      use: { ...devices['Desktop Safari'] },
    },
    {
      name: 'Mobile Chrome',
      use: { ...devices['Pixel 5'] },
    },
    {
      name: 'Mobile Safari',
      use: { ...devices['iPhone 12'] },
    },
  ],
  webServer: {
    command: 'npm run dev',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
})

E2Eテストの実装

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from './pages/LoginPage'
import { DashboardPage } from './pages/DashboardPage'

test.describe('Authentication Flow', () => {
  test('should login successfully', async ({ page }) => {
    const loginPage = new LoginPage(page)
    const dashboardPage = new DashboardPage(page)

    await loginPage.goto()
    await loginPage.login('user@example.com', 'password123')

    await expect(dashboardPage.welcomeMessage).toBeVisible()
    await expect(dashboardPage.welcomeMessage).toContainText('Welcome back')
  })

  test('should handle login errors', async ({ page }) => {
    const loginPage = new LoginPage(page)

    await loginPage.goto()
    await loginPage.login('invalid@example.com', 'wrongpassword')

    await expect(loginPage.errorMessage).toBeVisible()
    await expect(loginPage.errorMessage).toContainText('Invalid credentials')
  })

  test('should logout successfully', async ({ page, context }) => {
    // セッション設定
    await context.addCookies([{
      name: 'auth-token',
      value: 'valid-token',
      domain: 'localhost',
      path: '/'
    }])

    const dashboardPage = new DashboardPage(page)
    await dashboardPage.goto()
    await dashboardPage.logout()

    await expect(page).toHaveURL('/login')
  })
})

// e2e/pages/LoginPage.ts
import { Page, Locator } from '@playwright/test'

export class LoginPage {
  readonly page: Page
  readonly emailInput: Locator
  readonly passwordInput: Locator
  readonly loginButton: Locator
  readonly errorMessage: Locator

  constructor(page: Page) {
    this.page = page
    this.emailInput = page.getByLabel('Email')
    this.passwordInput = page.getByLabel('Password')
    this.loginButton = page.getByRole('button', { name: 'Login' })
    this.errorMessage = page.getByRole('alert')
  }

  async goto() {
    await this.page.goto('/login')
  }

  async login(email: string, password: string) {
    await this.emailInput.fill(email)
    await this.passwordInput.fill(password)
    await this.loginButton.click()
  }
}

高度なE2Eテストパターン

// e2e/shopping-cart.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Shopping Cart', () => {
  test.beforeEach(async ({ page }) => {
    // APIモック
    await page.route('**/api/products', async route => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify([
          { id: 1, name: 'Product 1', price: 100 },
          { id: 2, name: 'Product 2', price: 200 }
        ])
      })
    })
  })

  test('complete purchase flow', async ({ page }) => {
    // 商品一覧ページ
    await page.goto('/products')
    
    // 商品追加
    await page.getByTestId('product-1').getByRole('button', { name: 'Add to Cart' }).click()
    await page.getByTestId('product-2').getByRole('button', { name: 'Add to Cart' }).click()

    // カート確認
    await page.getByRole('link', { name: 'Cart (2)' }).click()
    await expect(page.getByTestId('cart-total')).toContainText('$300')

    // チェックアウト
    await page.getByRole('button', { name: 'Checkout' }).click()

    // 配送情報入力
    await page.getByLabel('Name').fill('John Doe')
    await page.getByLabel('Address').fill('123 Main St')
    await page.getByLabel('City').fill('New York')
    await page.getByLabel('Zip').fill('10001')

    // 支払い情報
    await page.getByLabel('Card Number').fill('4111111111111111')
    await page.getByLabel('Expiry').fill('12/25')
    await page.getByLabel('CVV').fill('123')

    // 注文確定
    await page.getByRole('button', { name: 'Place Order' }).click()

    // 確認ページ
    await expect(page.getByRole('heading', { name: 'Order Confirmed' })).toBeVisible()
    await expect(page.getByTestId('order-number')).toBeVisible()
  })

  test('should handle network errors gracefully', async ({ page }) => {
    // ネットワークエラーのシミュレーション
    await page.route('**/api/checkout', route => route.abort('failed'))

    await page.goto('/checkout')
    await page.getByRole('button', { name: 'Place Order' }).click()

    await expect(page.getByRole('alert')).toContainText('Network error')
    await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible()
  })
})

4. 視覚的回帰テスト

Playwrightでのスクリーンショットテスト

// e2e/visual-regression.spec.ts
import { test, expect } from '@playwright/test'

test.describe('Visual Regression', () => {
  test('homepage visual test', async ({ page }) => {
    await page.goto('/')
    await expect(page).toHaveScreenshot('homepage.png', {
      fullPage: true,
      animations: 'disabled',
      mask: [page.locator('.dynamic-content')]
    })
  })

  test('component states', async ({ page }) => {
    await page.goto('/components')

    // ボタンの各状態
    const button = page.getByTestId('primary-button')
    await expect(button).toHaveScreenshot('button-normal.png')
    
    await button.hover()
    await expect(button).toHaveScreenshot('button-hover.png')
    
    await button.focus()
    await expect(button).toHaveScreenshot('button-focus.png')
  })

  test('responsive design', async ({ page }) => {
    const viewports = [
      { width: 375, height: 667, name: 'mobile' },
      { width: 768, height: 1024, name: 'tablet' },
      { width: 1920, height: 1080, name: 'desktop' }
    ]

    for (const viewport of viewports) {
      await page.setViewportSize({ width: viewport.width, height: viewport.height })
      await page.goto('/')
      await expect(page).toHaveScreenshot(`homepage-${viewport.name}.png`)
    }
  })
})

5. パフォーマンステスト

Lighthouse CIの設定

# .github/workflows/lighthouse.yml
name: Lighthouse CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-node@v3
        with:
          node-version: 18
      
      - name: Install dependencies
        run: npm ci
      
      - name: Build application
        run: npm run build
      
      - name: Run Lighthouse CI
        run: |
          npm install -g @lhci/cli
          lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}
// lighthouserc.js
module.exports = {
  ci: {
    collect: {
      startServerCommand: 'npm run preview',
      url: ['http://localhost:3000', 'http://localhost:3000/products'],
      numberOfRuns: 3,
    },
    assert: {
      assertions: {
        'categories:performance': ['error', { minScore: 0.9 }],
        'categories:accessibility': ['error', { minScore: 0.95 }],
        'categories:best-practices': ['error', { minScore: 0.9 }],
        'categories:seo': ['error', { minScore: 0.9 }],
        'first-contentful-paint': ['error', { maxNumericValue: 2000 }],
        'interactive': ['error', { maxNumericValue: 3500 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
      },
    },
    upload: {
      target: 'temporary-public-storage',
    },
  },
}

テスト自動化とCI/CD

GitHub Actionsでの統合

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

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

jobs:
  unit-integration:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version: [18, 20]
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Run unit and integration tests
        run: npm run test:ci
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage/coverage-final.json

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Install Playwright browsers
        run: npx playwright install --with-deps
      
      - name: Run E2E tests
        run: npm run test:e2e
      
      - name: Upload test results
        if: always()
        uses: actions/upload-artifact@v3
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 30

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

1. テストの構造化

// テストの命名規則とグループ化
describe('Feature: Shopping Cart', () => {
  describe('When adding items', () => {
    it('should update the cart count', () => {})
    it('should calculate the total price', () => {})
    it('should handle out of stock items', () => {})
  })
  
  describe('When removing items', () => {
    it('should decrease the cart count', () => {})
    it('should update the total price', () => {})
  })
})

2. テストデータの管理

// test/fixtures/users.ts
export const createMockUser = (overrides = {}) => ({
  id: faker.datatype.uuid(),
  name: faker.name.fullName(),
  email: faker.internet.email(),
  createdAt: faker.date.past(),
  ...overrides
})

// test/builders/product.builder.ts
export class ProductBuilder {
  private product = {
    id: 1,
    name: 'Default Product',
    price: 100,
    stock: 10
  }

  withName(name: string) {
    this.product.name = name
    return this
  }

  withPrice(price: number) {
    this.product.price = price
    return this
  }

  outOfStock() {
    this.product.stock = 0
    return this
  }

  build() {
    return { ...this.product }
  }
}

3. アクセシビリティテスト

import { axe, toHaveNoViolations } from 'jest-axe'

expect.extend(toHaveNoViolations)

test('should be accessible', async () => {
  const { container } = render(<MyComponent />)
  const results = await axe(container)
  expect(results).toHaveNoViolations()
})

まとめ

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

  1. 適切なテストレベルの選択 - テストピラミッドに従った戦略
  2. ツールの使い分け - 各レベルに適したツールの選択
  3. 継続的な実行 - CI/CDパイプラインでの自動化
  4. 保守性の確保 - Page Object Model、テストビルダーの活用
  5. パフォーマンス監視 - Lighthouse CIによる継続的な監視

これらを組み合わせることで、高品質で信頼性の高いフロントエンドアプリケーションを構築できます。