「明天就要上線了,但測試都是用 Mock 資料跑的...」團隊會議室裡瀰漫著緊張的氣氛。過去 27 天,我們建立了完整的測試體系,從單元測試到 MSW 模擬,一切都運作得很順利。但現在,是時候讓我們的應用面對真實世界了。
基礎測試 ✅ → Kata 實戰 ✅ → 框架測試 ✅ → 【整合準備 📍】
Day 1-10 Day 11-17 Day 18-27 Day 28 ← 我們在這裡
過去 27 天的累積:
// 建立 .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'
}
// 建立 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)
// 更新 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()
// 更新 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>
)
})
// 更新 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()
在整合之前,試著回答這些問題:
✅ 建立了完整的環境變數管理系統
✅ 實作了健壯的 API 客戶端
✅ 完成 MSW 到真實 API 的切換機制
✅ 處理了 CORS 和錯誤情況
✅ 準備好整合測試策略
✅ 加入了效能監控機制
經過今天的準備,我們學到了幾個關鍵概念:
明天是倒數第二天,我們將進行真正的整合實戰!把 React 前端與後端 API 完整串接起來,處理認證、即時更新、離線支援等進階功能。這將是我們 29 天學習成果的總驗收!
記住:好的整合不是把兩個系統接起來,而是創造一個無縫的使用體驗。準備工作越充分,整合就越順利!