cover

測試策略:從單元測試到壓力測試的分層指南

流程概覽

graph TD
    subgraph 測試金字塔
        E2E["E2E 測試<br/>數量:少 | 成本:高"]
        INT["整合測試<br/>數量:適中 | 成本:中"]
        UNIT["單元測試<br/>數量:大量 | 成本:低"]
    end

    UNIT -->|覆蓋率 70%| INT
    INT -->|覆蓋率 20%| E2E

    subgraph 覆蓋策略
        CORE["核心邏輯 90%+"]
        BIZ["一般邏輯 70-80%"]
        UI["UI 元件 50-70%"]
    end

    E2E --- CORE
    INT --- BIZ
    UNIT --- UI

    style UNIT fill:#4CAF50,color:#fff
    style INT fill:#2196F3,color:#fff
    style E2E fill:#FF9800,color:#fff
    style CORE fill:#F44336,color:#fff
    style BIZ fill:#9C27B0,color:#fff
    style UI fill:#009688,color:#fff

文章概覽

在本篇文章中,您將學習到:

  • 為什麼「在我的電腦上可以跑」不是一種測試策略
  • 測試金字塔(Test Pyramid)的分層設計與各層職責
  • 單元測試、整合測試、E2E 測試與壓力測試的適用場景
  • 各層測試的工具選擇與最佳實踐
  • TDD 與 BDD 的差異及適用時機
  • 測試覆蓋率的合理目標與邊際遞減效應
  • CI/CD Pipeline 中的測試整合策略
  • 常見問題的診斷與解決方案

引言:為什麼「在我的電腦上可以跑」不是一種測試策略?

每一位軟體工程師都聽過(或說過)這句話:「It works on my machine.」

這句話之所以成為業界經典笑話,是因為它精準地揭露了一個根本問題——缺乏系統化的測試策略。當你的「測試」僅止於在本機手動點擊幾個按鈕、確認畫面看起來正常,那麼你並不是在測試,你只是在碰運氣

在實務中,缺乏測試策略的團隊會面臨以下困境:

  • 部署恐懼症:每次上線都像在拆炸彈,沒有人敢保證新版本不會炸掉既有功能
  • 重構癱瘓:因為沒有測試保護網,沒有人敢動既有程式碼,技術債越積越多
  • Bug 回歸:修了一個 bug,卻不小心引入了另外三個 bug
  • 上線延遲:QA 團隊需要花大量時間手動迴歸測試,成為釋出流程的瓶頸
  • 信心危機:團隊對自己的程式碼品質沒有客觀的量化指標

好的測試策略不是「寫更多的測試」,而是在正確的層級寫正確的測試。這就是測試金字塔(Test Pyramid)要解決的核心問題。


測試金字塔(Test Pyramid)

測試金字塔是由 Mike Cohn 在《Succeeding with Agile》中提出的概念。它描述了不同層級的測試應該有不同的數量比例:

graph TB
    subgraph Test Pyramid
        Manual["🧑 Manual / Exploratory Testing<br/>數量:最少<br/>成本:最高<br/>速度:最慢"]
        E2E["🌐 E2E Tests<br/>數量:少量<br/>成本:高<br/>速度:慢"]
        Integration["🔗 Integration Tests<br/>數量:適中<br/>成本:中<br/>速度:中"]
        Unit["⚡ Unit Tests<br/>數量:大量<br/>成本:低<br/>速度:快"]
    end

    Unit --> Integration --> E2E --> Manual

    style Manual fill:#F44336,color:#fff
    style E2E fill:#FF9800,color:#fff
    style Integration fill:#2196F3,color:#fff
    style Unit fill:#4CAF50,color:#fff

核心原則很簡單:越靠近底部的測試,數量越多、執行越快、維護成本越低;越靠近頂端的測試,數量越少、執行越慢、維護成本越高。

層級佔比建議執行時間維護成本信心程度
Unit Test70%毫秒級單一函式正確性
Integration Test20%秒級元件互動正確性
E2E Test5-8%分鐘級使用者流程正確性
Manual Test2-5%小時級最高探索性發現

核心概念詳解

1. 單元測試(Unit Test)

單元測試是測試金字塔的基石。它測試的是單一函式或方法的行為,與外部依賴完全隔離。

特徵

  • 執行速度極快(整個測試套件應在數秒內完成)
  • 不依賴外部服務(資料庫、API、檔案系統)
  • 使用 Mock / Stub 隔離外部依賴
  • 一個測試只驗證一個行為(Single Assertion Principle)
  • 測試之間互相獨立,無執行順序依賴

適用場景

  • 純業務邏輯(計算折扣、驗證輸入格式、資料轉換)
  • 工具函式(日期格式化、字串處理、數學運算)
  • 狀態機(訂單狀態轉換、權限判斷)

不適用場景

  • 資料庫查詢的正確性(這是整合測試的範疇)
  • UI 呈現效果(這是 E2E 測試的範疇)
  • 第三方 API 的回應格式(應使用 Contract Testing)

2. 整合測試(Integration Test)

整合測試驗證的是多個元件之間的互動是否正確。它跨越了單元測試的隔離邊界,實際連接外部依賴。

特徵

  • 需要啟動外部服務(資料庫、Redis、Message Queue)
  • 執行速度比單元測試慢,但比 E2E 測試快
  • 常使用 Testcontainers 或 Docker Compose 管理依賴
  • 驗證的是元件之間的「合約」是否正確

適用場景

  • API 端點的請求/回應格式
  • 資料庫的 CRUD 操作與查詢邏輯
  • Message Queue 的訊息發送與消費
  • 多個微服務之間的呼叫鏈
  • 第三方 API 整合(使用錄製/重播或沙箱環境)

3. E2E 測試(End-to-End Test)

E2E 測試模擬真實使用者的操作流程,從瀏覽器端到後端資料庫,驗證整個系統的端到端行為。

特徵

  • 使用真實的瀏覽器(或 headless browser)執行
  • 模擬使用者的點擊、輸入、導航等操作
  • 驗證跨頁面的完整業務流程
  • 執行時間最長,維護成本最高

適用場景

  • 核心業務流程(註冊 → 登入 → 下單 → 付款)
  • 關鍵的 Happy Path(正常流程)
  • 跨系統的端到端驗證

注意事項

  • 只測試最關鍵的流程,不要試圖用 E2E 測試覆蓋所有情境
  • E2E 測試天生就容易 flaky(不穩定),需要投入額外心力維護
  • 執行環境需要穩定(專用的測試環境、固定的測試資料)

4. 壓力測試與效能測試(Load / Performance Test)

壓力測試驗證的是系統在高負載下的表現。它回答的核心問題是:「我們的系統能撐住多少使用者?瓶頸在哪裡?」

測試類型

類型目的說明
Load Test驗證預期負載模擬正常流量,確認系統能穩定運作
Stress Test尋找系統極限逐步增加負載,直到系統崩潰,找出瓶頸
Spike Test驗證突發流量瞬間湧入大量請求(如秒殺活動)的表現
Soak Test驗證長時間穩定性長時間持續中等負載,檢查記憶體洩漏等問題

關鍵指標

  • 回應時間(Response Time):P50、P95、P99 的延遲分佈
  • 吞吐量(Throughput):每秒處理的請求數(RPS / QPS)
  • 錯誤率(Error Rate):非 2xx 回應的比例
  • 資源使用率:CPU、Memory、Disk I/O、Network I/O

5. 測試覆蓋率(Test Coverage)

測試覆蓋率是一個指標,而不是目標

常見的覆蓋率指標

  • 行覆蓋率(Line Coverage):被測試執行到的程式碼行數比例
  • 分支覆蓋率(Branch Coverage):被測試執行到的條件分支比例
  • 函式覆蓋率(Function Coverage):被呼叫到的函式比例

合理的覆蓋率目標

類型建議覆蓋率說明
核心業務邏輯90%+計費、權限、狀態轉換等高風險模組
一般業務邏輯70-80%CRUD、資料轉換等中等風險模組
工具函式80-90%共用的 Utility 函式
UI 元件50-70%依元件複雜度而定
整體專案70-80%作為團隊的 baseline

邊際遞減效應:當覆蓋率從 0% 提升到 70% 時,每 1% 的提升都能帶來顯著的品質改善。但從 90% 提升到 95% 時,你可能需要花大量時間去測試一些極端邊界條件,而這些條件在生產環境中幾乎不會發生。不要為了追求數字而寫無意義的測試

6. TDD vs BDD

TDD(Test-Driven Development)——測試驅動開發:

核心循環:Red → Green → Refactor

  1. Red:先寫一個會失敗的測試
  2. Green:寫最少的程式碼讓測試通過
  3. Refactor:重構程式碼,保持測試通過

適用時機:

  • 明確知道函式的輸入與輸出
  • 需要設計清晰的 API 介面
  • 處理複雜的業務邏輯或演算法

BDD(Behavior-Driven Development)——行為驅動開發:

以 Given-When-Then 的格式描述預期行為:

  • Given(前提條件):系統處於什麼狀態
  • When(觸發動作):使用者做了什麼操作
  • Then(預期結果):系統應該有什麼反應

適用時機:

  • 需求來自非技術人員,需要共同語言
  • 驗收標準需要利害關係人確認
  • 測試情境較為複雜,需要更好的可讀性

兩者並非互斥——TDD 更適合用在單元測試層級,BDD 更適合用在整合測試和 E2E 測試層級。


各層測試的工具選擇

單元測試工具

語言工具特點
JavaScript / TypeScriptJest零配置、快照測試、內建 Mock
JavaScript / TypeScriptVitestVite 生態系、ESM 原生支援、與 Jest 相容
Pythonpytest簡潔的 fixture 機制、豐富的插件生態系
JavaJUnit 5業界標準、參數化測試、擴展模型
Gotesting(內建)標準庫即支援、搭配 testify 更方便

整合測試工具

工具用途語言支援
SupertestHTTP API 測試Node.js
Testcontainers使用 Docker 管理測試依賴Java, Node.js, Python, Go
REST AssuredREST API 測試Java
httptestHTTP 測試Go(標準庫)

E2E 測試工具

工具特點適用場景
Cypress開發體驗佳、自動等待、時間旅行除錯Web 應用
Playwright跨瀏覽器、自動程式碼生成、網路攔截Web 應用(需跨瀏覽器)
Selenium歷史最悠久、支援最多語言Legacy 系統或特殊需求

壓力測試工具

工具特點適用場景
k6使用 JavaScript 編寫腳本、輕量、CI 友善API 壓力測試
ArtilleryYAML 配置、Node.js 生態系、支援 WebSocketAPI 與即時應用
JMeterGUI 操作、功能豐富、插件生態系複雜場景(需 GUI 配置)
LocustPython 編寫、分散式支援Python 團隊

實戰範例

範例一:Jest 單元測試

以下是一個電商折扣計算函式的單元測試範例:

// 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; // 季節折扣 10%
      break;
    case 'clearance':
      discountRate = 0.3; // 清倉折扣 30%
      break;
    case 'none':
      discountRate = 0;
      break;
    default:
      throw new Error(`Unknown discount type: ${discountType}`);
  }
 
  // VIP 會員額外折扣
  if (memberLevel === 'vip') {
    discountRate += 0.05;
  } else if (memberLevel === 'svip') {
    discountRate += 0.1;
  }
 
  // 折扣上限為 50%
  discountRate = Math.min(discountRate, 0.5);
 
  const finalPrice = originalPrice * (1 - discountRate);
  return Math.round(finalPrice * 100) / 100; // 四捨五入到小數點後兩位
}
 
module.exports = { calculateDiscount };
 
// discount.test.js
const { calculateDiscount } = require('./discount');
 
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%', () => {
      // 季節折扣 10% + VIP 5% = 15%
      expect(calculateDiscount(1000, 'seasonal', 'vip')).toBe(850);
    });
 
    it('SVIP 會員應在折扣基礎上再減 10%', () => {
      // 清倉折扣 30% + SVIP 10% = 40%
      expect(calculateDiscount(1000, 'clearance', 'svip')).toBe(600);
    });
  });
 
  describe('邊界條件', () => {
    it('折扣上限不應超過 50%', () => {
      // 清倉 30% + SVIP 10% = 40%,未超過上限
      expect(calculateDiscount(1000, 'clearance', 'svip')).toBe(600);
    });
 
    it('價格為零或負數時應拋出錯誤', () => {
      expect(() => calculateDiscount(0, 'seasonal', 'normal')).toThrow('Price must be positive');
      expect(() => calculateDiscount(-100, '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);
    });
  });
});

範例二:Supertest API 整合測試

以下是使用 Supertest 對 Express API 進行整合測試的範例:

// app.js(Express 應用)
const express = require('express');
const app = express();
app.use(express.json());
 
// 假設 orderService 連接真實資料庫
const orderService = require('./services/orderService');
 
app.post('/api/v1/orders', async (req, res) => {
  try {
    const { userId, items } = req.body;
    if (!userId || !items || items.length === 0) {
      return res.status(400).json({ error: 'userId and items are required' });
    }
    const order = await orderService.create({ userId, items });
    res.status(201).json({ data: order });
  } catch (err) {
    res.status(500).json({ error: 'Internal server error' });
  }
});
 
app.get('/api/v1/orders/:id', async (req, res) => {
  try {
    const order = await orderService.findById(req.params.id);
    if (!order) {
      return res.status(404).json({ error: 'Order not found' });
    }
    res.status(200).json({ data: order });
  } catch (err) {
    res.status(500).json({ error: 'Internal server error' });
  }
});
 
module.exports = app;
 
// orders.integration.test.js
const request = require('supertest');
const app = require('./app');
const { setupTestDB, teardownTestDB, seedTestData } = require('./test-helpers');
 
describe('Orders API Integration Tests', () => {
  let testUserId;
 
  beforeAll(async () => {
    await setupTestDB(); // 啟動測試資料庫(可使用 Testcontainers)
  });
 
  afterAll(async () => {
    await teardownTestDB(); // 清除測試資料庫
  });
 
  beforeEach(async () => {
    const seed = await seedTestData(); // 每個測試前重設資料
    testUserId = seed.userId;
  });
 
  describe('POST /api/v1/orders', () => {
    it('應成功建立訂單並回傳 201', async () => {
      const orderData = {
        userId: testUserId,
        items: [
          { productId: 'prod-001', quantity: 2, price: 299 },
          { productId: 'prod-002', quantity: 1, price: 599 },
        ],
      };
 
      const response = await request(app)
        .post('/api/v1/orders')
        .send(orderData)
        .expect(201);
 
      expect(response.body.data).toMatchObject({
        userId: testUserId,
        status: 'pending',
      });
      expect(response.body.data.id).toBeDefined();
      expect(response.body.data.items).toHaveLength(2);
    });
 
    it('缺少必要欄位時應回傳 400', async () => {
      const response = await request(app)
        .post('/api/v1/orders')
        .send({ userId: testUserId }) // 缺少 items
        .expect(400);
 
      expect(response.body.error).toBe('userId and items are required');
    });
 
    it('空的 items 陣列應回傳 400', async () => {
      const response = await request(app)
        .post('/api/v1/orders')
        .send({ userId: testUserId, items: [] })
        .expect(400);
 
      expect(response.body.error).toBe('userId and items are required');
    });
  });
 
  describe('GET /api/v1/orders/:id', () => {
    it('應成功查詢已存在的訂單', async () => {
      // 先建立一筆訂單
      const createRes = await request(app)
        .post('/api/v1/orders')
        .send({
          userId: testUserId,
          items: [{ productId: 'prod-001', quantity: 1, price: 299 }],
        });
 
      const orderId = createRes.body.data.id;
 
      // 查詢該訂單
      const response = await request(app)
        .get(`/api/v1/orders/${orderId}`)
        .expect(200);
 
      expect(response.body.data.id).toBe(orderId);
      expect(response.body.data.userId).toBe(testUserId);
    });
 
    it('查詢不存在的訂單應回傳 404', async () => {
      await request(app)
        .get('/api/v1/orders/non-existent-id')
        .expect(404);
    });
  });
});

範例三:k6 壓力測試腳本

以下是使用 k6 對 API 進行壓力測試的完整範例:

// load-test.js(k6 腳本)
import http from 'k6/http';
import { check, sleep, group } from 'k6';
import { Rate, Trend } from 'k6/metrics';
 
// 自訂指標
const errorRate = new Rate('errors');
const orderCreationTrend = new Trend('order_creation_duration');
 
// 測試配置:模擬逐步增加的負載
export const options = {
  stages: [
    { duration: '1m', target: 50 },   // 1 分鐘內從 0 爬升到 50 個虛擬使用者
    { duration: '3m', target: 50 },   // 維持 50 個虛擬使用者持續 3 分鐘
    { duration: '1m', target: 100 },  // 1 分鐘內爬升到 100 個虛擬使用者
    { duration: '3m', target: 100 },  // 維持 100 個虛擬使用者持續 3 分鐘
    { duration: '2m', target: 0 },    // 2 分鐘內逐步降回 0
  ],
  thresholds: {
    http_req_duration: ['p(95)<500', 'p(99)<1000'], // P95 < 500ms, P99 < 1s
    errors: ['rate<0.05'],                           // 錯誤率 < 5%
    order_creation_duration: ['p(95)<800'],           // 訂單建立 P95 < 800ms
  },
};
 
const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000';
 
// 測試前的全域設定(登入取得 Token)
export function setup() {
  const loginRes = http.post(`${BASE_URL}/api/v1/auth/login`, JSON.stringify({
    email: 'loadtest@example.com',
    password: 'test-password',
  }), { headers: { 'Content-Type': 'application/json' } });
 
  check(loginRes, { '登入成功': (r) => r.status === 200 });
 
  return { token: loginRes.json('data.token') };
}
 
// 主要測試情境
export default function (data) {
  const headers = {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${data.token}`,
  };
 
  group('瀏覽商品列表', () => {
    const res = http.get(`${BASE_URL}/api/v1/products?page=1&limit=20`, { headers });
    check(res, {
      '商品列表回傳 200': (r) => r.status === 200,
      '商品列表有資料': (r) => r.json('data.items').length > 0,
    });
    errorRate.add(res.status !== 200);
    sleep(1); // 模擬使用者瀏覽時間
  });
 
  group('建立訂單', () => {
    const orderPayload = JSON.stringify({
      items: [
        { productId: 'prod-001', quantity: Math.ceil(Math.random() * 3) },
        { productId: 'prod-002', quantity: 1 },
      ],
    });
 
    const res = http.post(`${BASE_URL}/api/v1/orders`, orderPayload, { headers });
    check(res, {
      '訂單建立成功': (r) => r.status === 201,
      '回傳訂單 ID': (r) => r.json('data.id') !== undefined,
    });
    errorRate.add(res.status !== 201);
    orderCreationTrend.add(res.timings.duration);
    sleep(2);
  });
 
  group('查詢訂單', () => {
    const res = http.get(`${BASE_URL}/api/v1/orders?page=1&limit=10`, { headers });
    check(res, {
      '訂單列表回傳 200': (r) => r.status === 200,
    });
    errorRate.add(res.status !== 200);
    sleep(1);
  });
}
 
// 測試結束後的清理
export function teardown(data) {
  console.log('壓力測試完成,請檢查 Grafana Dashboard 查看詳細指標。');
}

範例四:GitLab CI Pipeline 測試整合

以下是一個完整的 GitLab CI 配置,將各層測試整合進 Pipeline:

# .gitlab-ci.yml
stages:
  - lint
  - unit-test
  - build
  - integration-test
  - e2e-test
  - load-test
  - deploy
 
variables:
  NODE_ENV: test
  POSTGRES_DB: testdb
  POSTGRES_USER: testuser
  POSTGRES_PASSWORD: testpass
 
# ===== Stage 1: 程式碼品質檢查 =====
lint:
  stage: lint
  image: node:20-alpine
  script:
    - npm ci
    - npm run lint
    - npm run type-check
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'
 
# ===== Stage 2: 單元測試 =====
unit-test:
  stage: unit-test
  image: node:20-alpine
  script:
    - npm ci
    - npm run test:unit -- --coverage --ci
  coverage: '/All files\s*\|\s*([\d.]+)%/'
  artifacts:
    when: always
    paths:
      - coverage/
    reports:
      junit: test-results/unit-junit.xml
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'
 
# ===== Stage 3: 建置應用 =====
build:
  stage: build
  image: node:20-alpine
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'
 
# ===== Stage 4: 整合測試 =====
integration-test:
  stage: integration-test
  image: node:20-alpine
  services:
    - name: postgres:16-alpine
      alias: postgres
    - name: redis:7-alpine
      alias: redis
  variables:
    DATABASE_URL: "postgresql://testuser:testpass@postgres:5432/testdb"
    REDIS_URL: "redis://redis:6379"
  script:
    - npm ci
    - npm run db:migrate:test
    - npm run test:integration -- --ci
  artifacts:
    when: always
    reports:
      junit: test-results/integration-junit.xml
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'
 
# ===== Stage 5: E2E 測試 =====
e2e-test:
  stage: e2e-test
  image: cypress/browsers:node-20.11.0-chrome-121.0.6167.184-1-ff-123.0-edge-121.0.2277.128-1
  services:
    - name: postgres:16-alpine
      alias: postgres
    - name: redis:7-alpine
      alias: redis
  variables:
    DATABASE_URL: "postgresql://testuser:testpass@postgres:5432/testdb"
    REDIS_URL: "redis://redis:6379"
  script:
    - npm ci
    - npm run db:migrate:test
    - npm run db:seed:test
    - npm run start:test &  # 在背景啟動應用
    - npx wait-on http://localhost:3000 --timeout 30000
    - npm run test:e2e -- --ci
  artifacts:
    when: always
    paths:
      - cypress/screenshots/
      - cypress/videos/
    reports:
      junit: test-results/e2e-junit.xml
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
    - if: '$CI_COMMIT_BRANCH == "main"'
 
# ===== Stage 6: 壓力測試(僅在 main 分支執行) =====
load-test:
  stage: load-test
  image:
    name: grafana/k6:latest
    entrypoint: ['']
  script:
    - k6 run --out json=load-test-results.json
      -e BASE_URL=$STAGING_URL
      tests/load/load-test.js
  artifacts:
    when: always
    paths:
      - load-test-results.json
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
 
# ===== Stage 7: 部署 =====
deploy-staging:
  stage: deploy
  script:
    - echo "Deploying to staging..."
    # 實際部署腳本
  environment:
    name: staging
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'

CI/CD 整合策略

將測試整合到 CI/CD Pipeline 中,是確保程式碼品質的最後一道防線。以下是幾個關鍵原則:

Fail Fast 原則

將執行速度快的測試放在 Pipeline 前面,讓問題儘早暴露:

  1. Lint + Type Check(秒級)→ 語法與型別錯誤立即攔截
  2. Unit Test(秒至分鐘級)→ 邏輯錯誤快速發現
  3. Build(分鐘級)→ 編譯問題在此階段暴露
  4. Integration Test(分鐘級)→ 元件互動問題
  5. E2E Test(數分鐘至十數分鐘)→ 使用者流程驗證
  6. Load Test(十數分鐘至數十分鐘)→ 效能基準驗證

如果 Unit Test 階段就失敗了,就不需要浪費時間跑後面更耗時的測試。

測試覆蓋率門檻

在 CI 中設定覆蓋率門檻,作為合併程式碼的必要條件:

  • 新程式碼的覆蓋率不得低於 80%
  • 整體覆蓋率不得下降超過 1%
  • 核心模組的覆蓋率不得低於 90%

測試報告與可視化

  • 將測試結果以 JUnit XML 格式輸出,讓 GitLab / GitHub 可以直接顯示
  • 覆蓋率報告使用 Cobertura 格式,支援 MR 中的行級覆蓋率標示
  • 壓力測試結果推送到 Grafana Dashboard,建立歷史趨勢

常見問題與風險

問題一:Flaky Tests(不穩定測試)

症狀:同一份測試有時通過、有時失敗,沒有任何程式碼變更。

常見原因

  • 測試依賴執行順序(共享狀態未清除)
  • 依賴外部服務的回應時間(網路波動)
  • 使用了 setTimeout 等時間相關的邏輯
  • 非同步操作的競爭條件(Race Condition)
  • E2E 測試中的動畫或載入時間不一致

解法

  • 每個測試前後都要重設狀態(beforeEach / afterEach
  • 使用 retry 機制作為短期止血,但不要依賴它
  • 建立 Flaky Test 追蹤機制,定期清理
  • E2E 測試使用 cy.intercept()page.waitForSelector() 而非固定等待時間

問題二:測試套件太慢

症狀:CI Pipeline 跑完所有測試需要 30 分鐘以上,嚴重拖慢開發迭代速度。

解法

  • 平行化執行:將測試拆分成多個 Job 平行跑(Jest --shard、Cypress --parallel
  • 分層執行:MR 只跑 Unit + Integration,E2E 和 Load Test 在合併後的 main 分支執行
  • 快取依賴:CI 中快取 node_modules,避免每次都重新安裝
  • 只跑受影響的測試:使用 jest --changedSince 只跑被修改檔案相關的測試
  • 定期清理慢測試:設定單一測試的超時限制,標記超過閾值的測試

問題三:測試實作細節而非行為

症狀:每次重構都要大量修改測試,即使外部行為沒有改變。

錯誤範例

// 不好的做法:測試內部實作
it('should call database.save with correct params', () => {
  createOrder({ userId: '123', items: [] });
  expect(database.save).toHaveBeenCalledWith({
    table: 'orders',
    data: { userId: '123', items: [], status: 'pending' },
  });
});

正確範例

// 好的做法:測試外部行為
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)。這樣當內部實作重構時,只要外部行為不變,測試就不需要修改。

問題四:過度 Mock 導致假安全感

症狀:所有測試都通過,但上線後各種 Bug,因為 Mock 的行為與真實依賴不一致。

解法

  • Mock 應該只用在你不需要測試的邊界(例如第三方 API)
  • 對於自己的服務,盡量使用真實的依賴(透過 Testcontainers)
  • 定期執行 Contract Test,確保 Mock 的行為與真實服務一致
  • 使用錄製/重播(Record/Replay)機制取代手寫 Mock

問題五:忽略非功能性測試

症狀:功能測試全部通過,但上線後 P99 延遲超過 3 秒,或在高流量時直接崩潰。

解法

  • 將壓力測試納入 CI Pipeline(至少在 main 分支執行)
  • 定義明確的效能 SLA 並設為 k6 的 threshold
  • 建立效能基線(Baseline),每次釋出與基線比較
  • 關注資源使用率(CPU、Memory),而不僅僅是回應時間

問題六:測試環境不穩定

症狀:測試在本機通過,但在 CI 環境失敗,或反過來。

解法

  • 使用 Docker 統一開發與 CI 的測試環境
  • 測試用的外部依賴(DB、Redis)使用 Testcontainers 啟動,而非共用的遠端服務
  • 避免測試依賴特定的系統時區、語系設定
  • 確保測試資料的獨立性(每個測試使用自己的資料集)

小結

測試策略的核心不是追求 100% 覆蓋率,而是在正確的層級投入正確的測試資源。回顧測試金字塔的核心精神:

層級核心目的投入比例關鍵工具
Unit Test驗證單一函式的正確性70%Jest, pytest, JUnit
Integration Test驗證元件互動的正確性20%Supertest, Testcontainers
E2E Test驗證使用者流程的正確性5-8%Cypress, Playwright
Load Test驗證系統在負載下的穩定性視需求k6, Artillery

建立好的測試策略,帶來的不僅是更少的 Bug,更重要的是團隊對程式碼品質的信心。當你有完善的測試保護網時,你可以更大膽地重構、更快速地迭代、更安心地部署。

最後,測試策略不是一次制定就永遠不變的。隨著專案演進、團隊成長、系統複雜度提升,你的測試策略也應該持續調整與優化。重要的是,從今天就開始建立測試的習慣——哪怕只是為最關鍵的函式寫上第一個單元測試。


Proto 實踐對照

Proto 中的測試策略實踐:Django Proto 使用 pytest + factory-boy 實現單元/整合測試;Vue 3 Proto 使用 Vitest + Vue Test Utils 做元件測試;Flutter Proto 使用 dart test + mocktail 做邏輯層測試。每個 Proto 都設定了最低覆蓋率門檻。詳見 Proto 規劃方法論 中的測試基線章節。


延伸閱讀