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 測試跟壓力測試——那是金字塔的上層,寫得少但寫對了很關鍵。

系列文章:

延伸閱讀:

「好的測試是你半年後重構時的救命稻草,壞的測試是你每次重構時的絆腳石。」