フロントエンドテスト完全ガイド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()
})
まとめ
効果的なフロントエンドテスト戦略には以下が重要です:
- 適切なテストレベルの選択 - テストピラミッドに従った戦略
- ツールの使い分け - 各レベルに適したツールの選択
- 継続的な実行 - CI/CDパイプラインでの自動化
- 保守性の確保 - Page Object Model、テストビルダーの活用
- パフォーマンス監視 - Lighthouse CIによる継続的な監視
これらを組み合わせることで、高品質で信頼性の高いフロントエンドアプリケーションを構築できます。