dramaling-vocab-learning/docs/04_testing/test-strategy.md

479 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 測試策略文檔
## 🎯 測試目標
- **代碼覆蓋率**:核心功能 80% 以上
- **關鍵路徑**100% 覆蓋
- **自動化程度**CI/CD 自動執行所有測試
- **測試速度**:單元測試 < 5 整合測試 < 30
## 🏗️ 測試架構
```
測試金字塔
╱╲
E2E╲ (10%) - Playwright
測試 ╲
╱────────╲
整合測試 ╲ (30%) - React Testing Library
╱────────────╲
單元測試 ╲ (60%) - Jest + React Testing Library
────────────────
```
## 📦 測試工具配置
### 1. 安裝測試依賴
```bash
# 測試框架
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`:
```javascript
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`:
```javascript
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. 單元測試
#### 組件測試範例
```typescript
// 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 測試範例
```typescript
// 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 路由測試
```typescript
// 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`:
```typescript
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 測試範例
```typescript
// 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` 中添加
```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`:
```yaml
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 環境通過
## 🐛 測試調試技巧
### 單一測試執行
```bash
# 執行特定測試文件
npm test -- FlashCard.test.tsx
# 執行匹配的測試
npm test -- --testNamePattern="should render"
# 調試模式
node --inspect-brk ./node_modules/.bin/jest --runInBand
```
### 查看覆蓋率詳情
```bash
# 生成 HTML 報告
npm run test:coverage
# 打開報告
open coverage/lcov-report/index.html
```
### Playwright 調試
```bash
# 調試模式
npx playwright test --debug
# 只執行失敗的測試
npx playwright test --last-failed
# 生成測試代碼
npx playwright codegen localhost:3000
```