479 lines
11 KiB
Markdown
479 lines
11 KiB
Markdown
# 測試策略文檔
|
||
|
||
## 🎯 測試目標
|
||
|
||
- **代碼覆蓋率**:核心功能 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
|
||
``` |