「沒有測試的程式碼不是完成了,只是還不知道哪裡壞了。 測試的真正價值不是驗證現在能跑,而是保證未來修改時不會悄悄壞掉。」
測試不是讓你的程式碼沒有 bug——而是讓你有勇氣重構、有信心部署。 這篇從測試金字塔出發,教你分辨哪些地方該寫什麼層次的測試, 以及如何用 Jest + React Testing Library 寫出真正有價值的測試。
測試金字塔:三層測試的職責分工
測試金字塔描述了三種測試的理想比例:下層數量多但快,上層數量少但真實。 顛倒金字塔(只寫 E2E)是常見反模式——慢、不穩定、難以定位問題。
E2E 測試
Playwright / Cypress
少量(~10%)
模擬真實使用者操作,從瀏覽器到資料庫的完整流程
Integration 測試
Jest + Supertest / MSW
中量(~30%)
測試多個模組組合後的行為(API 路由、DB 查詢)
Unit 測試
Jest + RTL
大量(~60%)
測試單一函數或組件的邏輯,完全隔離外部依賴
速度
慢 ← → 快
維護成本
高 ← → 低
錯誤定位
模糊 ← → 精確
Unit Test:測試最小可測單元
Unit Test 針對單一函數或組件,隔離所有外部依賴(DB、API、時間)。 執行快(毫秒級),失敗時能精確定位問題在哪行。
RTL 的設計哲學:測試行為,不測實作
React Testing Library 刻意不提供選取 class 名稱、測試組件內部 state 的 API。 它強迫你從「使用者視角」測試:getByText、getByRole、getByLabelText。 這樣的測試在你重構實作細節時不會壞掉——只要行為沒變,測試就通過。
Mock:隔離外部依賴的藝術
純函數的 Unit Test 很直觀,但實際的組件幾乎都需要呼叫 API、讀 localStorage、或依賴目前時間。 如果測試真的打 API,測試就會慢、不穩定、依賴外部服務的狀態。Mock 是用「假的實作」替換真實依賴,讓測試快速、可預測、不依賴環境。
Integration Test:測試模組的組合
Integration Test 測試多個模組組合後的行為——例如 API 路由 + 資料庫查詢, 或多個 React 組件的互動。比 Unit Test 慢,但能發現模組間的整合問題。
Integration Test 的資料庫設置
Integration Test 通常需要連接一個真實但隔離的測試資料庫(而非 Mock DB), 這樣才能發現真實的 SQL 錯誤、constraint violation、join 邏輯問題等。
# .env.test — 測試環境用獨立的 DB
DATABASE_URL="postgresql://localhost/myapp_test"
# package.json 測試指令帶入 .env.test
"test": "dotenv -e .env.test -- jest"
CI 環境通常用 GitHub Actions 的 service containers(Docker PostgreSQL)提供乾淨的測試 DB。
E2E Test:模擬真實使用者
E2E(End-to-End)測試用真實瀏覽器操作真實的 App,測試從前端到後端的完整流程。 最接近真實使用情境,但慢(秒級)且容易因網路或 UI 變動而失敗。只寫最關鍵的使用者旅程(Happy Path)。
TDD(Test-Driven Development)簡單定位
TDD 是「先寫測試再寫功能」的開發節奏:紅(寫失敗的測試)→ 綠(寫最小可過的程式碼)→ 重構(清理)。 它的核心價值不是測試覆蓋率,而是強迫你在實作前思清楚 API 介面與邊界條件。 不是每個情境都適合 TDD,但它在「需求明確的純函數邏輯」上效果最好。
測試反模式與最佳實踐
❌ 反模式:測試實作細節
不好的寫法
// ❌ 測試內部 state(重構後馬上壞掉) expect(component.state.isLoading).toBe(false);
✅ 改善後
// ✅ 測試使用者看到的結果
expect(screen.queryByText('載入中...')).not.toBeInTheDocument();❌ 反模式:不獨立的測試(順序依賴)
不好的寫法
// ❌ 測試 B 依賴測試 A 建立的資料
test('A', () => db.create(...));
test('B', () => { /* 假設 A 的資料還在 */ });✅ 改善後
// ✅ 每個測試獨立設置自己需要的資料
beforeEach(async () => { await db.deleteMany(); });
test('B', async () => { await db.create(...); /* 自己的資料 */ });❌ 反模式:斷言太模糊
不好的寫法
// ❌ 只驗證有回應,不驗證內容 expect(response.status).toBe(200);
✅ 改善後
// ✅ 驗證具體的業務邏輯結果
expect(response.status).toBe(200);
expect(data.name).toBe('Joseph');
expect(data.email).toBe('joseph@test.com');