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

11 KiB
Raw Permalink Blame History

測試策略文檔

🎯 測試目標

  • 代碼覆蓋率:核心功能 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