# 測試策略文檔

## 🎯 測試目標

- **代碼覆蓋率**：核心功能 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
```