11 KiB
11 KiB
測試策略文檔
🎯 測試目標
- 代碼覆蓋率:核心功能 80% 以上
- 關鍵路徑:100% 覆蓋
- 自動化程度:CI/CD 自動執行所有測試
- 測試速度:單元測試 < 5 秒,整合測試 < 30 秒
🏗️ 測試架構
測試金字塔
╱╲
╱E2E╲ (10%) - Playwright
╱ 測試 ╲
╱────────╲
╱ 整合測試 ╲ (30%) - React Testing Library
╱────────────╲
╱ 單元測試 ╲ (60%) - Jest + React Testing Library
────────────────
📦 測試工具配置
1. 安裝測試依賴
# 測試框架
npm install --save-dev jest @testing-library/react @testing-library/jest-dom @testing-library/user-event
# TypeScript 支援
npm install --save-dev @types/jest ts-jest
# E2E 測試
npm install --save-dev @playwright/test
# 測試覆蓋率
npm install --save-dev @vitest/coverage-v8
2. Jest 配置
創建 jest.config.js:
const nextJest = require('next/jest')
const createJestConfig = nextJest({
dir: './',
})
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
testEnvironment: 'jest-environment-jsdom',
testPathIgnorePatterns: ['/node_modules/', '/.next/'],
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.{js,jsx,ts,tsx}',
'!src/**/_*.{js,jsx,ts,tsx}',
],
coverageThreshold: {
global: {
branches: 70,
functions: 70,
lines: 80,
statements: 80,
},
},
}
module.exports = createJestConfig(customJestConfig)
3. 測試設置文件
創建 jest.setup.js:
import '@testing-library/jest-dom'
// Mock 環境變數
process.env.NEXT_PUBLIC_SUPABASE_URL = 'https://test.supabase.co'
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'test-key'
// Mock Next.js router
jest.mock('next/navigation', () => ({
useRouter() {
return {
push: jest.fn(),
replace: jest.fn(),
prefetch: jest.fn(),
}
},
useSearchParams() {
return new URLSearchParams()
},
usePathname() {
return '/'
},
}))
🧪 測試類型與範例
1. 單元測試
組件測試範例
// src/components/FlashCard.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import FlashCard from './FlashCard'
describe('FlashCard Component', () => {
const mockCard = {
id: '1',
word: 'Hello',
translation: '你好',
example: 'Hello, world!',
}
it('should render word correctly', () => {
render(<FlashCard card={mockCard} />)
expect(screen.getByText('Hello')).toBeInTheDocument()
})
it('should show translation on flip', () => {
render(<FlashCard card={mockCard} />)
const card = screen.getByTestId('flashcard')
fireEvent.click(card)
expect(screen.getByText('你好')).toBeInTheDocument()
})
it('should call onMemorized when marked as memorized', () => {
const onMemorized = jest.fn()
render(<FlashCard card={mockCard} onMemorized={onMemorized} />)
const memorizeButton = screen.getByRole('button', { name: /memorize/i })
fireEvent.click(memorizeButton)
expect(onMemorized).toHaveBeenCalledWith('1')
})
})
Hook 測試範例
// src/hooks/useFlashcards.test.ts
import { renderHook, act, waitFor } from '@testing-library/react'
import { useFlashcards } from './useFlashcards'
describe('useFlashcards Hook', () => {
it('should fetch flashcards on mount', async () => {
const { result } = renderHook(() => useFlashcards())
expect(result.current.loading).toBe(true)
await waitFor(() => {
expect(result.current.loading).toBe(false)
expect(result.current.flashcards).toHaveLength(10)
})
})
it('should handle errors gracefully', async () => {
// Mock API error
global.fetch = jest.fn().mockRejectedValue(new Error('API Error'))
const { result } = renderHook(() => useFlashcards())
await waitFor(() => {
expect(result.current.error).toBe('Failed to fetch flashcards')
expect(result.current.flashcards).toEqual([])
})
})
})
2. 整合測試
API 路由測試
// src/app/api/flashcards/route.test.ts
import { GET, POST } from './route'
import { createMocks } from 'node-mocks-http'
describe('/api/flashcards', () => {
describe('GET', () => {
it('should return flashcards for authenticated user', async () => {
const { req, res } = createMocks({
method: 'GET',
headers: {
authorization: 'Bearer valid-token',
},
})
await GET(req)
expect(res._getStatusCode()).toBe(200)
const json = JSON.parse(res._getData())
expect(json.flashcards).toBeDefined()
})
it('should return 401 for unauthenticated request', async () => {
const { req, res } = createMocks({
method: 'GET',
})
await GET(req)
expect(res._getStatusCode()).toBe(401)
})
})
describe('POST', () => {
it('should create new flashcard', async () => {
const { req, res } = createMocks({
method: 'POST',
headers: {
authorization: 'Bearer valid-token',
},
body: {
word: 'Test',
translation: '測試',
},
})
await POST(req)
expect(res._getStatusCode()).toBe(201)
const json = JSON.parse(res._getData())
expect(json.flashcard.word).toBe('Test')
})
})
})
3. E2E 測試
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',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
E2E 測試範例
// e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Authentication Flow', () => {
test('user can sign up, login, and logout', async ({ page }) => {
// 註冊
await page.goto('/signup')
await page.fill('[name="email"]', 'test@example.com')
await page.fill('[name="password"]', 'Password123!')
await page.click('[type="submit"]')
await expect(page).toHaveURL('/dashboard')
// 登出
await page.click('[data-testid="user-menu"]')
await page.click('[data-testid="logout-button"]')
await expect(page).toHaveURL('/login')
// 登入
await page.fill('[name="email"]', 'test@example.com')
await page.fill('[name="password"]', 'Password123!')
await page.click('[type="submit"]')
await expect(page).toHaveURL('/dashboard')
})
})
test.describe('Flashcard Learning', () => {
test.beforeEach(async ({ page }) => {
// 登入
await page.goto('/login')
await page.fill('[name="email"]', 'test@example.com')
await page.fill('[name="password"]', 'Password123!')
await page.click('[type="submit"]')
})
test('user can create and study flashcards', async ({ page }) => {
// 創建詞卡
await page.goto('/flashcards/new')
await page.fill('[name="text"]', 'Hello world from drama series')
await page.click('[data-testid="generate-button"]')
await expect(page.locator('[data-testid="flashcard"]')).toHaveCount(5)
// 學習詞卡
await page.click('[data-testid="start-learning"]')
const card = page.locator('[data-testid="flashcard"]').first()
await card.click() // 翻轉
await expect(card).toHaveAttribute('data-flipped', 'true')
await page.click('[data-testid="mark-memorized"]')
await expect(page.locator('[data-testid="progress"]')).toContainText('1/5')
})
})
📝 測試腳本
在 package.json 中添加:
{
"scripts": {
"test": "jest",
"test:watch": "jest --watch",
"test:coverage": "jest --coverage",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug",
"test:all": "npm run test && npm run test:e2e"
}
}
🔄 CI/CD 測試流程
創建 .github/workflows/test.yml:
name: Test
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
test:
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: Run linter
run: npm run lint
- name: Run type check
run: npm run type-check
- name: Run unit tests
run: npm run test:coverage
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
file: ./coverage/coverage-final.json
- name: Install Playwright
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: test-results
path: |
coverage/
playwright-report/
📊 測試覆蓋率目標
| 類型 | 目標覆蓋率 | 優先級 |
|---|---|---|
| 業務邏輯 | 90% | 高 |
| API 路由 | 85% | 高 |
| UI 組件 | 80% | 中 |
| 工具函數 | 95% | 高 |
| Hook | 85% | 中 |
| 頁面組件 | 70% | 低 |
✅ 測試檢查清單
開發階段
- 為新功能編寫單元測試
- 為 API 端點編寫整合測試
- 為關鍵用戶流程編寫 E2E 測試
- 確保測試覆蓋率達標
- 執行
npm run test:all確認所有測試通過
Code Review
- 檢查是否有對應的測試
- 測試是否覆蓋邊界情況
- 測試命名是否清晰
- 是否有適當的測試數據
部署前
- CI/CD 所有測試通過
- 覆蓋率報告符合標準
- E2E 測試在 staging 環境通過
🐛 測試調試技巧
單一測試執行
# 執行特定測試文件
npm test -- FlashCard.test.tsx
# 執行匹配的測試
npm test -- --testNamePattern="should render"
# 調試模式
node --inspect-brk ./node_modules/.bin/jest --runInBand
查看覆蓋率詳情
# 生成 HTML 報告
npm run test:coverage
# 打開報告
open coverage/lcov-report/index.html
Playwright 調試
# 調試模式
npx playwright test --debug
# 只執行失敗的測試
npx playwright test --last-failed
# 生成測試代碼
npx playwright codegen localhost:3000