Pipeline 中的測試策略

是什麼?

Pipeline 中的測試策略是定義在 CI/CD 的哪個階段、跑哪些類型的測試,以最小時間成本獲得最大信心。核心原則是「快的先跑、重的後跑、失敗就停」。

ℹ️Shift Left Testing

「Shift Left」意味著把測試往開發流程的左邊(更早期)移動。越早發現 bug,修復成本越低。Pipeline 中的測試就是 Shift Left 的具體實踐。

核心觀念

常見誤區

⚠️常見誤區

  • 只跑 Unit Test 不跑 Integration Test:Unit Test 全過但模組之間的整合可能是壞的。至少在 Pipeline 中包含關鍵路徑的 Integration Test
  • E2E Test 放在每次 PR 觸發:E2E 太慢(通常 10 分鐘以上),放在 merge 到 main 後或每日排程跑即可
  • 忽略 Flaky Test:開發者看到紅色測試但知道「那個一直 flaky」就直接重跑,久了就沒人相信 Pipeline 的結果

流程/步驟

1

Lint + Type Check

靜態分析,最快回饋(30 秒內)

2

Unit Test

獨立的函式/類別測試(1-2 分鐘)

3

Integration Test

模組間整合測試,可能需要 DB(3-5 分鐘)

4

Coverage Gate

檢查覆蓋率是否達標(如 80%)

5

E2E / Smoke Test

端對端或冒煙測試(合併後或排程)

6

Performance Test

效能基準測試(排程或 Release 前)

流程解讀:Pipeline 測試從輕量到重量排列。Lint 和 Unit Test 每次 PR 都跑,提供最快回饋。Integration Test 在 Unit Test 通過後才執行。E2E 和 Performance Test 因為耗時,通常在 merge 後或排程執行。

程式碼範例

C# 版本

csharp
// .github/workflows/test-strategy.yml 的 C# 範例
// Pipeline 中各層級測試的組織方式
 
// Unit Test — 獨立、快速、不依賴外部資源
[Fact]
public void CalculateDiscount_VipUser_Returns20Percent()
{
    var calculator = new DiscountCalculator();
    var result = calculator.Calculate(userType: UserType.Vip, amount: 1000m);
    Assert.Equal(200m, result);
}
 
// Integration Test — 需要真實資料庫(用 Testcontainers)
public class UserRepositoryTests : IClassFixture<DatabaseFixture>
{
    private readonly AppDbContext _db;
 
    public UserRepositoryTests(DatabaseFixture fixture)
    {
        _db = fixture.CreateContext();
    }
 
    [Fact]
    public async Task CreateUser_PersistsToDatabase()
    {
        var repo = new UserRepository(_db);
        await repo.Create(new User { Name = "Alice", Email = "a@b.com" });
 
        var user = await _db.Users.FirstAsync(u => u.Email == "a@b.com");
        Assert.Equal("Alice", user.Name);
    }
}

TypeScript 版本

yaml
# .github/workflows/test-strategy.yml
name: Test Strategy
 
on: [push, pull_request]
 
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm run lint
      - run: npx tsc --noEmit  # Type check
 
  unit-test:
    runs-on: ubuntu-latest
    needs: lint
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm test -- --coverage --ci
      # Coverage gate
      - run: |
          COVERAGE=$(npx coverage-summary)
          if [ "$COVERAGE" -lt 80 ]; then exit 1; fi
 
  integration-test:
    runs-on: ubuntu-latest
    needs: unit-test
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: test
        ports: ['5432:5432']
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: '20', cache: 'npm' }
      - run: npm ci
      - run: npm run test:integration
        env:
          DATABASE_URL: postgresql://postgres:test@localhost:5432/test
 
  e2e:
    runs-on: ubuntu-latest
    needs: integration-test
    if: github.ref == 'refs/heads/main'  # 只在 merge 到 main 時跑
    steps:
      - uses: actions/checkout@v4
      - run: npx playwright test

Python 版本

python
# conftest.py — 測試分層設定
import pytest
 
# 標記慢速測試
def pytest_configure(config):
    config.addinivalue_line("markers", "integration: integration tests")
    config.addinivalue_line("markers", "e2e: end-to-end tests")
 
# CI 中用 -m 篩選
# 快速回饋:pytest -m "not integration and not e2e"
# 整合測試:pytest -m integration
# E2E:pytest -m e2e
 
@pytest.mark.integration
class TestUserRepository:
    def test_create_and_retrieve(self, db_session):
        repo = UserRepository(db_session)
        repo.create(User(name="Alice", email="a@b.com"))
 
        user = repo.get_by_email("a@b.com")
        assert user.name == "Alice"
 
@pytest.mark.e2e
class TestUserFlow:
    def test_register_and_login(self, api_client):
        # 註冊
        res = api_client.post("/api/users", json={"name": "Alice", "email": "a@b.com"})
        assert res.status_code == 201
 
        # 登入
        res = api_client.post("/auth/login", json={"email": "a@b.com", "password": "..."})
        assert res.status_code == 200
        assert "access_token" in res.json()

架構圖/概念圖

Pull Request
always
Lint (30s)
pass
Unit Test (2min)
pass
Integration Test (5min)
approve
Merge to Main
post-merge
E2E Test (15min)

PR 觸發輕量測試(Lint → Unit → Integration),通過後人工 Approve 並 Merge。Merge 後觸發較重的 E2E 測試。這樣開發者在 PR 階段得到快速回饋,重量級測試不阻塞 PR 流程。

實戰補充

Q: 測試覆蓋率門檻設多少?

A: 新專案建議 80% 作為起點,逐步提升。不要追求 100% — 那會導致大量無意義的測試。重點是覆蓋關鍵業務邏輯容易出錯的邊界條件

Q: Flaky Test 怎麼處理?

A: (1) 標記為 @flaky,從主 Pipeline 中隔離到獨立的 Job。(2) 記錄 flaky 頻率,超過門檻就強制修復。(3) 常見原因:依賴時間、依賴網路、測試之間有共享狀態。

Q: Integration Test 需要真實資料庫嗎?

A: 建議用 Testcontainers 在 CI 中啟動真實的 Docker 化資料庫。比 In-Memory DB 更貼近生產環境,且每次測試都是乾淨的狀態。

理解測驗

🤔 為什麼 Pipeline 中要先跑 Unit Test 再跑 Integration Test?

🤔 Flaky Test 對 Pipeline 的最大危害是什麼?

🤔 E2E Test 為什麼通常不在每次 PR 觸發?

重點整理

💡一句話記住

Pipeline 測試 = 快的先跑、重的後跑、紅了就停。 口訣:「Lint 秒回,Unit 分鐘回,Integration 合併後」

| 測試層級 | 時間 | Pipeline 時機 | 特性 | |---------|------|--------------|------| | Lint | 30 秒 | 每次 PR | 靜態分析 | | Unit Test | 1-2 分鐘 | 每次 PR | 快、獨立 | | Integration | 3-5 分鐘 | 每次 PR | 需要外部資源 | | E2E | 10-15 分鐘 | merge 後/排程 | 慢、全流程 | | Performance | 15+ 分鐘 | Release 前/排程 | 基準測試 |

你可能也想看

GitHub Actions 實戰Docker 容器化

按 ← → 鍵切換課程