
測試策略:從單元測試到壓力測試的分層指南
流程概覽
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 Test | 70% | 毫秒級 | 低 | 單一函式正確性 |
| Integration Test | 20% | 秒級 | 中 | 元件互動正確性 |
| E2E Test | 5-8% | 分鐘級 | 高 | 使用者流程正確性 |
| Manual Test | 2-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
- Red:先寫一個會失敗的測試
- Green:寫最少的程式碼讓測試通過
- Refactor:重構程式碼,保持測試通過
適用時機:
- 明確知道函式的輸入與輸出
- 需要設計清晰的 API 介面
- 處理複雜的業務邏輯或演算法
BDD(Behavior-Driven Development)——行為驅動開發:
以 Given-When-Then 的格式描述預期行為:
- Given(前提條件):系統處於什麼狀態
- When(觸發動作):使用者做了什麼操作
- Then(預期結果):系統應該有什麼反應
適用時機:
- 需求來自非技術人員,需要共同語言
- 驗收標準需要利害關係人確認
- 測試情境較為複雜,需要更好的可讀性
兩者並非互斥——TDD 更適合用在單元測試層級,BDD 更適合用在整合測試和 E2E 測試層級。
各層測試的工具選擇
單元測試工具
| 語言 | 工具 | 特點 |
|---|---|---|
| JavaScript / TypeScript | Jest | 零配置、快照測試、內建 Mock |
| JavaScript / TypeScript | Vitest | Vite 生態系、ESM 原生支援、與 Jest 相容 |
| Python | pytest | 簡潔的 fixture 機制、豐富的插件生態系 |
| Java | JUnit 5 | 業界標準、參數化測試、擴展模型 |
| Go | testing(內建) | 標準庫即支援、搭配 testify 更方便 |
整合測試工具
| 工具 | 用途 | 語言支援 |
|---|---|---|
| Supertest | HTTP API 測試 | Node.js |
| Testcontainers | 使用 Docker 管理測試依賴 | Java, Node.js, Python, Go |
| REST Assured | REST API 測試 | Java |
| httptest | HTTP 測試 | Go(標準庫) |
E2E 測試工具
| 工具 | 特點 | 適用場景 |
|---|---|---|
| Cypress | 開發體驗佳、自動等待、時間旅行除錯 | Web 應用 |
| Playwright | 跨瀏覽器、自動程式碼生成、網路攔截 | Web 應用(需跨瀏覽器) |
| Selenium | 歷史最悠久、支援最多語言 | Legacy 系統或特殊需求 |
壓力測試工具
| 工具 | 特點 | 適用場景 |
|---|---|---|
| k6 | 使用 JavaScript 編寫腳本、輕量、CI 友善 | API 壓力測試 |
| Artillery | YAML 配置、Node.js 生態系、支援 WebSocket | API 與即時應用 |
| JMeter | GUI 操作、功能豐富、插件生態系 | 複雜場景(需 GUI 配置) |
| Locust | Python 編寫、分散式支援 | 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 前面,讓問題儘早暴露:
- Lint + Type Check(秒級)→ 語法與型別錯誤立即攔截
- Unit Test(秒至分鐘級)→ 邏輯錯誤快速發現
- Build(分鐘級)→ 編譯問題在此階段暴露
- Integration Test(分鐘級)→ 元件互動問題
- E2E Test(數分鐘至十數分鐘)→ 使用者流程驗證
- 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 規劃方法論 中的測試基線章節。