# 測試策略文檔 ## 🎯 測試目標 - **代碼覆蓋率**:核心功能 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: ['/jest.setup.js'], moduleNameMapper: { '^@/(.*)$': '/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() expect(screen.getByText('Hello')).toBeInTheDocument() }) it('should show translation on flip', () => { render() 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() 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 ```