Unit Test 跟 Integration Test 到底差在哪?
一句話總結:Unit Test 測單一函式,隔離所有外部依賴;Integration Test 測元件互動,連接真實的外部服務。
結論先講:最常見的錯誤不是「不寫測試」,而是「在錯誤的層級寫測試」——用 Unit Test 測資料庫查詢、用 Integration Test 測純邏輯,兩邊都吃力不討好。
Unit Test:測試金字塔的地基
Unit Test 的核心原則就一個字:隔離。
你測的是一個函式的行為,跟資料庫無關、跟 API 無關、跟檔案系統無關。所有外部依賴都用 Mock 或 Stub 替代。
怎麼判斷一個測試是不是好的 Unit Test?
- 跑起來毫秒級結束(整個 suite 幾秒內跑完)
- 不需要任何外部服務就能跑
- 一個測試只驗證一個行為
- 測試之間互相獨立,順序打亂也不會壞
什麼東西適合用 Unit Test 測?
- 純業務邏輯:折扣計算、資料驗證、格式轉換
- 工具函式:日期處理、字串操作、數學運算
- 狀態機:訂單狀態轉換、權限判斷
什麼東西不適合用 Unit Test 測?
- 資料庫查詢的正確性(那是 Integration Test 的工作)
- UI 的呈現效果(那是 E2E Test 的工作)
- 第三方 API 的回應格式(那是 Contract Test 的工作)
實戰範例:電商折扣計算
來看一個具體的例子——電商平台的折扣計算:
// discount.js
function calculateDiscount(originalPrice, discountType, memberLevel) {
if (originalPrice <= 0) {
throw new Error('Price must be positive');
}
let discountRate = 0;
switch (discountType) {
case 'seasonal': discountRate = 0.1; break;
case 'clearance': discountRate = 0.3; break;
case 'none': discountRate = 0; break;
default: throw new Error(`Unknown discount type: ${discountType}`);
}
if (memberLevel === 'vip') discountRate += 0.05;
else if (memberLevel === 'svip') discountRate += 0.1;
discountRate = Math.min(discountRate, 0.5); // 上限 50%
const finalPrice = originalPrice * (1 - discountRate);
return Math.round(finalPrice * 100) / 100;
}測試怎麼寫?注意測試的結構——每個 describe 是一個關注面,每個 it 是一個具體行為:
describe('calculateDiscount', () => {
describe('基本折扣', () => {
it('季節折扣打九折', () => {
expect(calculateDiscount(1000, 'seasonal', 'normal')).toBe(900);
});
it('清倉折扣打七折', () => {
expect(calculateDiscount(1000, 'clearance', 'normal')).toBe(700);
});
it('無折扣回傳原價', () => {
expect(calculateDiscount(1000, 'none', 'normal')).toBe(1000);
});
});
describe('會員加碼', () => {
it('VIP 額外減 5%', () => {
expect(calculateDiscount(1000, 'seasonal', 'vip')).toBe(850);
});
it('SVIP 額外減 10%', () => {
expect(calculateDiscount(1000, 'clearance', 'svip')).toBe(600);
});
});
describe('邊界條件', () => {
it('價格為零拋出錯誤', () => {
expect(() => calculateDiscount(0, 'seasonal', 'normal'))
.toThrow('Price must be positive');
});
it('未知折扣類型拋出錯誤', () => {
expect(() => calculateDiscount(1000, 'unknown', 'normal'))
.toThrow('Unknown discount type');
});
it('浮點數精度處理', () => {
expect(calculateDiscount(99.99, 'seasonal', 'normal')).toBe(89.99);
});
});
});看到了嗎?每個測試都很短、意圖很明確、跑起來很快。這就是好的 Unit Test。
常用工具一覽
- JavaScript / TypeScript:Jest(零配置王者)、Vitest(Vite 生態系首選)
- Python:pytest(fixture 機制超好用)
- Java:JUnit 5(業界標準)
- Go:內建
testing套件 + testify
Integration Test:跨越隔離邊界
Integration Test 跟 Unit Test 的根本差異是:它連接真實的外部服務。
你不再 Mock 資料庫——你真的起一個 PostgreSQL 來測。你不再 Stub Redis——你真的連上去寫讀看看。
什麼東西適合用 Integration Test 測?
- API 端點的 request / response 格式
- 資料庫的 CRUD 操作和查詢邏輯
- 訊息佇列的發送與消費
- 微服務之間的呼叫鏈
實戰範例:Supertest API 測試
用 Supertest 測 Express API——注意 beforeAll 起測試資料庫、beforeEach 重設資料、afterAll 清理:
const request = require('supertest');
const app = require('./app');
const { setupTestDB, teardownTestDB, seedTestData } = require('./test-helpers');
describe('Orders API', () => {
let testUserId;
beforeAll(async () => { await setupTestDB(); });
afterAll(async () => { await teardownTestDB(); });
beforeEach(async () => {
const seed = await seedTestData();
testUserId = seed.userId;
});
describe('POST /api/v1/orders', () => {
it('成功建立訂單回傳 201', async () => {
const res = await request(app)
.post('/api/v1/orders')
.send({
userId: testUserId,
items: [{ productId: 'prod-001', quantity: 2, price: 299 }],
})
.expect(201);
expect(res.body.data).toMatchObject({
userId: testUserId,
status: 'pending',
});
expect(res.body.data.id).toBeDefined();
});
it('缺少必要欄位回傳 400', async () => {
await request(app)
.post('/api/v1/orders')
.send({ userId: testUserId })
.expect(400);
});
});
describe('GET /api/v1/orders/:id', () => {
it('查詢不存在的訂單回傳 404', async () => {
await request(app)
.get('/api/v1/orders/non-existent-id')
.expect(404);
});
});
});注意這裡沒有 Mock——orderService 真的連到測試資料庫。這就是 Integration Test 的價值:它驗證的是「元件串起來之後到底能不能用」。
管理測試依賴的利器:Testcontainers
手動管理測試用的資料庫很麻煩——要裝、要跑、要清資料、CI 環境也要設一份。Testcontainers 解決了這個問題:它用 Docker 在測試開始時自動起一個臨時的資料庫,測試結束自動砍掉。
支援 Java、Node.js、Python、Go,基本上主流語言都有。
一個常見的陷阱:測試實作細節
很多人的 Unit Test 長這樣:
// 不好:測試「怎麼做的」
it('should call database.save with correct params', () => {
createOrder({ userId: '123', items: [] });
expect(database.save).toHaveBeenCalledWith({
table: 'orders',
data: { userId: '123', items: [], status: 'pending' },
});
});問題在哪?這個測試綁死了內部實作。如果你重構了 createOrder(比如改用 ORM 而不是直接呼叫 database.save),測試就會壞掉——即使外部行為完全沒變。
正確的做法:
// 好的:測試「做了什麼」
it('should create an order with pending status', async () => {
const order = await createOrder({ userId: '123', items: [] });
expect(order.status).toBe('pending');
expect(order.userId).toBe('123');
});測試應該驗證 What(做了什麼),不是 How(怎麼做的)。 這樣重構的時候,只要行為沒變,測試就不用動。
這篇的重點回顧
Unit Test 隔離一切、只測邏輯,用 Mock 把外部依賴換掉。Integration Test 連接真實服務、測元件互動。兩種測試不要搞混——搞混了就是在錯的層級花力氣。
下一篇聊 E2E 測試跟壓力測試——那是金字塔的上層,寫得少但寫對了很關鍵。
系列文章:
- 測試策略(一):測試金字塔
- 你在這裡 → 測試策略(二):Unit Test 與 Integration Test
- 測試策略(三):E2E 測試與壓力測試
- CD 整合與常見陷阱
延伸閱讀:
- Proto 規劃方法論(測試基線章節)
「好的測試是你半年後重構時的救命稻草,壞的測試是你每次重構時的絆腳石。」