iT邦幫忙

2025 iThome 鐵人賽

DAY 28
0

「明天就要上線了,但測試都是用 Mock 資料跑的...」團隊會議室裡瀰漫著緊張的氣氛。過去 27 天,我們建立了完整的測試體系,從單元測試到 MSW 模擬,一切都運作得很順利。但現在,是時候讓我們的應用面對真實世界了。

旅程回顧與定位 🗺️

基礎測試 ✅ → Kata 實戰 ✅ → 框架測試 ✅ → 【整合準備 📍】
Day 1-10      Day 11-17     Day 18-27      Day 28 ← 我們在這裡

過去 27 天的累積:

  • ✅ TDD 完整流程掌握(Day 1-10)
  • ✅ Roman Numeral 實戰經驗(Day 11-17)
  • ✅ React Testing Library 精通(Day 18-27)
  • ✅ MSW 模擬 API 測試完成(Day 19-27)

今天的任務清單 📋

  • [ ] 配置環境變數系統
  • [ ] 建立 API 客戶端
  • [ ] 實作環境切換機制
  • [ ] 處理 CORS 問題
  • [ ] 錯誤處理與重試機制
  • [ ] 整合測試策略

環境變數管理 🔧

建立環境配置檔案

// 建立 .env.development
VITE_API_URL=http://localhost:8000
VITE_USE_MSW=true
VITE_API_TIMEOUT=5000
// 建立 .env.production
VITE_API_URL=https://api.todoapp.com
VITE_USE_MSW=false
VITE_API_TIMEOUT=10000
// 建立 .env.test
VITE_API_URL=http://localhost:3001
VITE_USE_MSW=true
VITE_API_TIMEOUT=3000

類型安全的環境變數

// 建立 src/config/env.ts
interface EnvConfig {
  apiUrl: string
  useMsw: boolean
  apiTimeout: number
  isDevelopment: boolean
  isProduction: boolean
  isTest: boolean
}

export const env: EnvConfig = {
  apiUrl: import.meta.env.VITE_API_URL || 'http://localhost:8000',
  useMsw: import.meta.env.VITE_USE_MSW === 'true',
  apiTimeout: Number(import.meta.env.VITE_API_TIMEOUT) || 5000,
  isDevelopment: import.meta.env.DEV,
  isProduction: import.meta.env.PROD,
  isTest: import.meta.env.MODE === 'test'
}

API 客戶端實作 🌐

基礎 HTTP 客戶端

// 建立 src/services/http-client.ts
import { env } from '../config/env'

interface RequestConfig extends RequestInit {
  timeout?: number
  retry?: number
}

class HttpClient {
  private baseUrl: string
  private defaultHeaders: HeadersInit

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl
    this.defaultHeaders = {
      'Content-Type': 'application/json',
      'Accept': 'application/json'
    }
  }

  async request<T>(endpoint: string, config: RequestConfig = {}): Promise<T> {
    const url = `${this.baseUrl}${endpoint}`
    const { timeout = env.apiTimeout, retry = 3, ...fetchConfig } = config
    
    for (let attempt = 0; attempt <= retry; attempt++) {
      try {
        const controller = new AbortController()
        const timeoutId = setTimeout(() => controller.abort(), timeout)
        
        const response = await fetch(url, {
          ...fetchConfig,
          signal: controller.signal,
          headers: { ...this.defaultHeaders, ...fetchConfig.headers }
        })
        
        clearTimeout(timeoutId)
        
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`)
        }
        
        return await response.json()
      } catch (error) {
        if (attempt < retry) {
          await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000))
        } else {
          throw error
        }
      }
    }
    
    throw new Error('Request failed after retries')
  }

  get<T>(endpoint: string, config?: RequestConfig): Promise<T> {
    return this.request<T>(endpoint, {
      ...config,
      method: 'GET'
    })
  }

  post<T>(endpoint: string, data?: unknown, config?: RequestConfig): Promise<T> {
    return this.request<T>(endpoint, {
      ...config,
      method: 'POST',
      body: JSON.stringify(data)
    })
  }

  patch<T>(endpoint: string, data?: unknown, config?: RequestConfig): Promise<T> {
    return this.request<T>(endpoint, {
      ...config,
      method: 'PATCH',
      body: JSON.stringify(data)
    })
  }

  delete<T>(endpoint: string, config?: RequestConfig): Promise<T> {
    return this.request<T>(endpoint, {
      ...config,
      method: 'DELETE'
    })
  }
}

export const apiClient = new HttpClient(env.apiUrl)

Todo Service 重構 🔄

從 MSW 到真實 API

// 更新 src/services/todo-service.ts
import { apiClient } from './http-client'
import { env } from '../config/env'

export interface Todo {
  id: string
  title: string
  completed: boolean
  createdAt: string
  updatedAt: string
}

interface TodosResponse {
  data: Todo[]
  meta: {
    total: number
    page: number
    perPage: number
  }
}

class TodoService {
  async getTodos(page = 1, perPage = 10): Promise<TodosResponse> {
    return apiClient.get<TodosResponse>('/api/todos', {
      // 將參數轉換為 query string
      headers: {
        'Content-Type': 'application/json'
      }
    })
  }

  async getTodo(id: string): Promise<Todo> {
    return apiClient.get<Todo>(`/api/todos/${id}`)
  }

  async createTodo(data: { title: string }): Promise<Todo> {
    return apiClient.post<Todo>('/api/todos', data)
  }

  async updateTodo(id: string, data: Partial<Todo>): Promise<Todo> {
    return apiClient.patch<Todo>(`/api/todos/${id}`, data)
  }

  async deleteTodo(id: string): Promise<void> {
    await apiClient.delete(`/api/todos/${id}`)
  }
}

export const todoService = new TodoService()

MSW 條件載入 🎭

智慧型 MSW 初始化

// 更新 src/main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { env } from './config/env'
import './index.css'

async function prepare() {
  if (env.useMsw) {
    const { worker } = await import('./mocks/browser')
    await worker.start({
      onUnhandledRequest: 'bypass'
    })
    console.log('🔶 MSW Enabled')
  }
  return Promise.resolve()
}

prepare().then(() => {
  ReactDOM.createRoot(document.getElementById('root')!).render(
    <React.StrictMode>
      <App />
    </React.StrictMode>
  )
})

CORS 處理策略 🛡️

前端 CORS 配置

// 更新 vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8000',
        changeOrigin: true,
        secure: false,
        rewrite: (path) => path.replace(/^\/api/, '')
      }
    }
  },
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test-setup.ts'
  }
})

錯誤處理與用戶反饋 🚨

全局錯誤邊界

// 建立 src/components/ErrorBoundary.tsx
import { Component, ReactNode, ErrorInfo } from 'react'

interface Props {
  children: ReactNode
  fallback?: ReactNode
}

interface State {
  hasError: boolean
  error: Error | null
}

export class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props)
    this.state = { hasError: false, error: null }
  }

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: ErrorInfo) {
    console.error('Error caught by boundary:', error, errorInfo)
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || (
        <div className="error-container">
          <h2>出現錯誤</h2>
          <p>{this.state.error?.message}</p>
          <button onClick={() => this.setState({ hasError: false, error: null })}>
            重試
          </button>
        </div>
      )
    }

    return this.props.children
  }
}

整合測試策略 🧪

測試環境切換

// 建立 tests/day28/integration.test.tsx
import { describe, test, expect, beforeEach, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { todoService } from '../../src/services/todo-service'
import { TodoList } from '../../src/components/TodoList'

describe('Integration Tests', () => {
  describe('with MSW', () => {
    beforeEach(() => {
      vi.stubEnv('VITE_USE_MSW', 'true')
      vi.spyOn(global, 'fetch').mockResolvedValue({
        ok: true,
        json: async () => ({
          data: [
            { id: '1', title: 'Learn TDD', completed: false, createdAt: '2025-01-01', updatedAt: '2025-01-01' }
          ],
          meta: { total: 1, page: 1, perPage: 10 }
        })
      } as Response)
    })

    test('fetchesTodosFromMswHandlers', async () => {
      const todos = await todoService.getTodos()
      expect(todos.data).toHaveLength(1)
      expect(todos.data[0].title).toBe('Learn TDD')
    })
  })

  describe('with Real API Mock', () => {
    beforeEach(() => {
      vi.stubEnv('VITE_USE_MSW', 'false')
      vi.spyOn(global, 'fetch').mockResolvedValue({
        ok: true,
        json: async () => ({
          data: [
            { id: '1', title: 'Real API Todo', completed: false }
          ]
        })
      } as Response)
    })

    test('fetchesTodosFromApi', async () => {
      const todos = await todoService.getTodos()
      expect(todos.data).toHaveLength(1)
      expect(todos.data[0].title).toBe('Real API Todo')
    })
  })
})

效能監控 📊

請求計時器

// 建立 src/utils/performance.ts
interface PerformanceMetrics {
  requestCount: number
  averageResponseTime: number
  slowRequests: number
}

class PerformanceMonitor {
  private metrics: Map<string, number[]> = new Map()
  
  startTimer(key: string): () => void {
    const startTime = performance.now()
    
    return () => {
      const endTime = performance.now()
      const duration = endTime - startTime
      
      if (!this.metrics.has(key)) {
        this.metrics.set(key, [])
      }
      
      this.metrics.get(key)!.push(duration)
      
      if (duration > 1000) {
        console.warn(`Slow request detected: ${key} took ${duration}ms`)
      }
    }
  }
  
  getMetrics(): PerformanceMetrics {
    let totalCount = 0
    let totalTime = 0
    let slowCount = 0
    
    this.metrics.forEach(times => {
      totalCount += times.length
      totalTime += times.reduce((sum, time) => sum + time, 0)
      slowCount += times.filter(time => time > 1000).length
    })
    
    return {
      requestCount: totalCount,
      averageResponseTime: totalCount > 0 ? totalTime / totalCount : 0,
      slowRequests: slowCount
    }
  }
}

export const performanceMonitor = new PerformanceMonitor()

小挑戰 🎯

在整合之前,試著回答這些問題:

  1. 你的 API 回應時間是否在可接受範圍內?
  2. 錯誤訊息是否對前端開發者友善?
  3. 資料驗證是否涵蓋所有邊界情況?

今天的成就總結 🎉

✅ 建立了完整的環境變數管理系統
✅ 實作了健壯的 API 客戶端
✅ 完成 MSW 到真實 API 的切換機制
✅ 處理了 CORS 和錯誤情況
✅ 準備好整合測試策略
✅ 加入了效能監控機制

重要心得 💡

經過今天的準備,我們學到了幾個關鍵概念:

  1. 環境隔離 - 開發、測試、生產環境要明確分離
  2. 漸進切換 - 從模擬到真實要能平滑過渡
  3. 錯誤優先 - 先想失敗情況,再處理成功路徑
  4. 監控為王 - 沒有監控就沒有優化的依據

明天預告 🚀

明天是倒數第二天,我們將進行真正的整合實戰!把 React 前端與後端 API 完整串接起來,處理認證、即時更新、離線支援等進階功能。這將是我們 29 天學習成果的總驗收!

記住:好的整合不是把兩個系統接起來,而是創造一個無縫的使用體驗。準備工作越充分,整合就越順利!


上一篇
Day 27 - E2E 測試預覽 🎬
系列文
React TDD 實戰:用 Vitest 打造可靠的前端應用28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言