昨天凌晨三點,手機響了。是值班同事:「購物車結帳功能掛了!」明明單元測試都通過,為什麼還是出問題?因為我們測試了每個零件,卻忘了測試它們組裝起來是否正常運作。這就是整合測試要解決的問題。
前置基礎(Day 1-10)
├── 測試框架設置 ✅
├── 斷言與匹配器 ✅
├── TDD 循環 ✅
└── 測試組織 ✅
Kata 實戰(Day 11-17)
├── Roman Numerals ✅
├── 重構技巧 ✅
└── 進階 TDD ✅
框架特化(Day 18-27)
├── React 測試基礎 ✅
├── 元件測試 ✅
├── Hook 測試 ✅
├── 狀態管理測試 ✅
├── 路由測試 ✅
├── API 整合測試 ✅
└── 整合測試 📍 <-- 我們在這裡!
想像你正在組裝一台電腦。每個零件(CPU、記憶體、硬碟)單獨測試都沒問題,但裝在一起卻開不了機。這就是只做單元測試的盲點。
特性 | 單元測試 | 整合測試 |
---|---|---|
範圍 | 單一函數/元件 | 多個元件協作 |
速度 | 快速 ⚡ | 較慢 🐢 |
隔離性 | 完全隔離 | 部分真實環境 |
維護成本 | 低 | 中等 |
信心程度 | 局部信心 | 整體信心 |
讓我們為 Todo 應用寫一個完整的整合測試,測試從輸入到顯示的完整流程。
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import TodoApp from '../../src/todo/TodoApp'
const server = setupServer(
http.get('/api/todos', () => {
return HttpResponse.json([
{ id: 1, title: '學習 React', completed: false },
{ id: 2, title: '寫測試', completed: true }
])
}),
http.post('/api/todos', async ({ request }) => {
const body = await request.json()
return HttpResponse.json({
id: 3,
title: body.title,
completed: false
}, { status: 201 })
}),
http.put('/api/todos/:id', async ({ params, request }) => {
const body = await request.json()
return HttpResponse.json({
id: Number(params.id),
...body
})
}),
http.delete('/api/todos/:id', ({ params }) => {
return HttpResponse.json(
{ message: `Todo ${params.id} deleted` },
{ status: 200 }
)
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('TodoApp Integration', () => {
let queryClient
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false }
}
})
})
it('displaysInitialTodosFromServer', async () => {
render(
<QueryClientProvider client={queryClient}>
<TodoApp />
</QueryClientProvider>
)
await waitFor(() => {
expect(screen.getByText('學習 React')).toBeInTheDocument()
expect(screen.getByText('寫測試')).toBeInTheDocument()
})
})
it('addsNewTodoAndDisplaysIt', async () => {
const user = userEvent.setup()
render(
<QueryClientProvider client={queryClient}>
<TodoApp />
</QueryClientProvider>
)
// 等待初始載入
await waitFor(() => {
expect(screen.getByText('學習 React')).toBeInTheDocument()
})
// 輸入新 todo
const input = screen.getByPlaceholderText('新增待辦事項...')
await user.type(input, '整合測試')
await user.keyboard('{Enter}')
// 驗證新增成功
await waitFor(() => {
expect(screen.getByText('整合測試')).toBeInTheDocument()
})
// 輸入欄位應該被清空
expect(input).toHaveValue('')
})
it('togglesTodoCompletionStatus', async () => {
const user = userEvent.setup()
render(
<QueryClientProvider client={queryClient}>
<TodoApp />
</QueryClientProvider>
)
await waitFor(() => {
expect(screen.getByText('學習 React')).toBeInTheDocument()
})
// 找到未完成的 todo 並點擊 checkbox
const checkbox = screen.getByRole('checkbox', { name: /學習 React/i })
expect(checkbox).not.toBeChecked()
await user.click(checkbox)
await waitFor(() => {
expect(checkbox).toBeChecked()
})
})
it('deletesTodoFromList', async () => {
const user = userEvent.setup()
render(
<QueryClientProvider client={queryClient}>
<TodoApp />
</QueryClientProvider>
)
await waitFor(() => {
expect(screen.getByText('學習 React')).toBeInTheDocument()
})
// 找到刪除按鈕並點擊
const deleteButtons = screen.getAllByRole('button', { name: /刪除/i })
await user.click(deleteButtons[0])
// 確認 todo 被移除
await waitFor(() => {
expect(screen.queryByText('學習 React')).not.toBeInTheDocument()
})
})
it('filtersTodosByStatus', async () => {
const user = userEvent.setup()
render(
<QueryClientProvider client={queryClient}>
<TodoApp />
</QueryClientProvider>
)
await waitFor(() => {
expect(screen.getByText('學習 React')).toBeInTheDocument()
expect(screen.getByText('寫測試')).toBeInTheDocument()
})
// 點擊「已完成」篩選
const completedFilter = screen.getByRole('button', { name: /已完成/i })
await user.click(completedFilter)
// 只顯示已完成項目
expect(screen.queryByText('學習 React')).not.toBeInTheDocument()
expect(screen.getByText('寫測試')).toBeInTheDocument()
// 點擊「未完成」篩選
const activeFilter = screen.getByRole('button', { name: /未完成/i })
await user.click(activeFilter)
// 只顯示未完成項目
expect(screen.getByText('學習 React')).toBeInTheDocument()
expect(screen.queryByText('寫測試')).not.toBeInTheDocument()
})
})
整合測試也要考慮異常情況:
import { describe, it, expect } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import TodoApp from '../../src/todo/TodoApp'
const server = setupServer()
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('Error Handling', () => {
it('showsErrorWhenServerDown', async () => {
server.use(
http.get('/api/todos', () => {
return HttpResponse.error()
})
)
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false }
}
})
render(
<QueryClientProvider client={queryClient}>
<TodoApp />
</QueryClientProvider>
)
await waitFor(() => {
expect(screen.getByText(/載入失敗/i)).toBeInTheDocument()
})
})
it('handlesNetworkTimeout', async () => {
server.use(
http.get('/api/todos', async () => {
await new Promise(resolve => setTimeout(resolve, 5000))
return HttpResponse.json([])
})
)
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
gcTime: 0,
staleTime: 0
}
}
})
render(
<QueryClientProvider client={queryClient}>
<TodoApp />
</QueryClientProvider>
)
expect(screen.getByText(/載入中/i)).toBeInTheDocument()
})
it('retriesFailedRequests', async () => {
let attemptCount = 0
server.use(
http.get('/api/todos', () => {
attemptCount++
if (attemptCount < 3) {
return HttpResponse.error()
}
return HttpResponse.json([
{ id: 1, title: '成功載入', completed: false }
])
})
)
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
retryDelay: 0
}
}
})
render(
<QueryClientProvider client={queryClient}>
<TodoApp />
</QueryClientProvider>
)
await waitFor(() => {
expect(screen.getByText('成功載入')).toBeInTheDocument()
}, { timeout: 3000 })
expect(attemptCount).toBe(3)
})
})
import { describe, it, expect } from 'vitest'
describe('Testing Pyramid', () => {
it('understandsDifferentTestLevels', () => {
const testPyramid = {
unit: {
count: 100,
speed: 'fast',
scope: 'single function/component',
confidence: 60
},
integration: {
count: 20,
speed: 'medium',
scope: 'multiple components',
confidence: 80
},
e2e: {
count: 5,
speed: 'slow',
scope: 'full user journey',
confidence: 95
}
}
// 單元測試最多
expect(testPyramid.unit.count).toBeGreaterThan(
testPyramid.integration.count
)
// 整合測試居中
expect(testPyramid.integration.count).toBeGreaterThan(
testPyramid.e2e.count
)
// 信心程度遞增
expect(testPyramid.e2e.confidence).toBeGreaterThan(
testPyramid.integration.confidence
)
})
})
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { setupServer } from 'msw/node'
import { http, HttpResponse } from 'msw'
import ShoppingCart from '../../src/cart/ShoppingCart'
const mockProducts = [
{ id: 1, name: 'TypeScript 書', price: 500 },
{ id: 2, name: '測試課程', price: 1200 }
]
const server = setupServer(
http.get('/api/products', () => {
return HttpResponse.json(mockProducts)
}),
http.post('/api/cart', async ({ request }) => {
const body = await request.json()
return HttpResponse.json({
...body,
addedAt: new Date().toISOString()
})
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('Shopping Cart Integration', () => {
it('completesPurchaseFlow', async () => {
const user = userEvent.setup()
render(<ShoppingCart />)
// 等待產品載入
await waitFor(() => {
expect(screen.getByText('TypeScript 書')).toBeInTheDocument()
})
// 加入購物車
const addButtons = screen.getAllByRole('button', { name: /加入購物車/i })
await user.click(addButtons[0])
// 檢查購物車數量
await waitFor(() => {
expect(screen.getByText(/購物車 \(1\)/i)).toBeInTheDocument()
})
// 檢查總金額
expect(screen.getByText(/總計: 500/i)).toBeInTheDocument()
})
})
請為你的 Todo App 加入以下整合測試:
提示:整合測試要測試完整的用戶操作流程!
今天我們學習了整合測試的重要概念:
整合測試就像品管的最後一道關卡,確保所有零件組合後能正常運作。記住:好的整合測試能抓到單元測試漏掉的問題!
明天我們將探討「效能測試」,學習如何確保應用的回應速度!
記住:整合測試是信心的來源,它證明你的程式真的能用! 💪