opencode-workflow/design-idea/translate/skills/tdd/SKILL.md

7.8 KiB
Raw Blame History

name description
tdd Backend Agent 使用此技能進行測試驅動開發。遵循 Red-Green-Refactor 循環和垂直切片原則確保測試覆蓋行為而非實作細節。觸發時機實作階段Stage 9由 go-backend-dev 技能整合使用。

/tdd — 測試驅動開發

Backend Agent 使用此技能進行測試驅動開發。

核心理念

測試行為,而非實作細節。

好的測試透過公開介面驗證行為,描述系統「做什麼」而非「怎麼做」。重構後測試仍然通過。

壞的測試與實作耦合mock 內部協作者、測試私有方法。重構後測試失敗,但行為沒有改變。

反模式:水平切片

不要先寫完所有測試再寫所有實作。 這是「水平切片」:

❌ 錯誤方式:
  RED:   test1, test2, test3, test4, test5
  GREEN: impl1, impl2, impl3, impl4, impl5

✅ 正確方式(垂直切片):
  RED→GREEN: test1 → impl1
  RED→GREEN: test2 → impl2
  RED→GREEN: test3 → impl3

水平切片會產生劣質測試:

  • 大量寫的測試驗證「想像的」行為,而非「實際的」行為
  • 測試會變成驗證資料結構和函式簽名,而非使用者可觀察的行為
  • 測試對真正的變更不敏感 — 行為壞了還是通過,行為沒變但重構後卻失敗

流程

確認介面變更與測試範圍
    ↓
寫第一個測試tracer bullet
    ↓
RED: 測試失敗
    ↓
GREEN: 寫最少程式碼讓測試通過
    ↓
寫下一個測試
    ↓
RED → GREEN 循環
    ↓
所有行為測試完成
    ↓
REFACTOR: 重構
    ↓
確認所有測試仍然通過

步驟說明

1. 規劃

在寫任何程式碼之前:

  • 與使用者確認需要哪些介面變更
  • 確認哪些行為需要測試(排序優先順序)
  • 識別深模組的機會(小介面,深實作)
  • 為可測試性設計介面
  • 列出要測試的行為(不是實作步驟)
  • 取得使用者對測試計畫的認可

提問:「公開介面應該長什麼樣子?哪些行為最重要需要測試?」

你不可能測試所有東西。 與使用者確認哪些行為最重要,將測試精力集中在關鍵路徑和複雜邏輯,而不是每個可能的邊緣案例。

2. Tracer Bullet

寫一個測試,確認系統的一件事:

RED:   寫第一個行為的測試 → 測試失敗
GREEN: 寫最少的程式碼讓測試通過 → 測試通過

這是你的 tracer bullet — 證明端到端路徑可行。

3. 遞增循環

對每個剩餘行為:

RED:   寫下一個測試 → 失敗
GREEN: 最少程式碼讓測試通過 → 通過

規則:

  • 一次一個測試
  • 只寫足夠讓當前測試通過的程式碼
  • 不要預測未來的測試
  • 測試聚焦在可觀察的行為

4. 重構

所有測試通過後,尋找重構候選:

  • 提取重複邏輯
  • 加深模組(將複雜度移到簡單介面後方)
  • 自然地應用 SOLID 原則
  • 思考新程式碼揭示了什麼既有程式碼的問題
  • 每個重構步驟後都跑測試

絕對不要在 RED 狀態下重構。先回到 GREEN。

好的測試 vs 壞的測試

好的測試

整合風格:透過真實介面測試,不是 mock 內部零件。

// GOOD: 測試可觀察的行為
func TestUserUsecase_CreateUser_Success(t *testing.T) {
    mockRepo := new(mock.UserRepository)
    uc := NewUserUsecase(mockRepo, logger)

    mockRepo.On("GetByEmail", mock.Anything, "test@example.com").Return(nil, nil)
    mockRepo.On("Create", mock.Anything, mock.AnythingOfType("*domain.User")).Return(nil)

    user, err := uc.CreateUser(context.Background(), input)

    assert.NoError(t, err)
    assert.NotNil(t, user)
    assert.Equal(t, "test@example.com", user.Email)
}

特徵:

  • 測試使用者/呼叫者關心的行為
  • 只使用公開 API
  • 重構內部實作後測試仍然通過
  • 描述「做什麼」而非「怎麼做」
  • 每個測試一個邏輯斷言

壊的測試

實作細節測試:與內部結構耦合。

// BAD: 測試實作細節
func TestUserUsecase_CreateUser_CallsRepoCreate(t *testing.T) {
    mockRepo := new(mock.UserRepository)
    uc := NewUserUsecase(mockRepo, logger)

    uc.CreateUser(context.Background(), input)

    // 這測試的是「怎麼做」而非「做什麼」
    mockRepo.AssertCalled(t, "Create", mock.Anything, mock.Anything)
}

紅旗:

  • Mock 內部協作者只是為了驗證被呼叫
  • 測試私有方法
  • 斷言呼叫次數或順序
  • 重構後測試失敗但行為沒變
  • 測試名稱描述「怎麼做」而非「做什麼」
// BAD: 繞過介面驗證
func TestCreateUser_SavesToDatabase(t *testing.T) {
    CreateUser(ctx, input)
    row := db.QueryRow("SELECT * FROM users WHERE name = $1", "Alice")
    // 直接查資料庫驗證,繞過公開介面
}

// GOOD: 透過介面驗證
func TestCreateUser_MakesUserRetrievable(t *testing.T) {
    user, _ := CreateUser(ctx, input)
    retrieved, _ := GetUser(ctx, user.ID)
    assert.Equal(t, "Alice", retrieved.Name)
    // 透過公開介面驗證行為
}

Golang 測試規範

測試命名

// Test{Unit}_{Scenario}
func TestUserUsecase_CreateUser_Success(t *testing.T) {}
func TestUserUsecase_CreateUser_InvalidEmail(t *testing.T) {}
func TestUserUsecase_CreateUser_Duplicate(t *testing.T) {}

測試金字塔

         /\
        /  \
       / E2E \          <- 少數關鍵流程
      /--------\
     /Integration\      <- API + DB
    /--------------\
   /   Unit Tests    \   <- 最多80%+ 覆蓋
  /--------------------\

Mock 策略

只在系統邊界 mock

  • 外部 API支付、郵件等
  • 資料庫(有時 — 優先使用測試 DB
  • 時間/隨機性
  • 檔案系統(有時)

不要 mock

  • 你自己的類別/模組
  • 內部協作者
  • 你可以控制的東西
// 使用 mockery 自動產生 mock
//go:generate mockery --name=UserRepository

// 單元測試使用 mock repo
func TestUserUsecase_CreateUser_Success(t *testing.T) {
    mockRepo := new(mock.UserRepository)
    uc := NewUserUsecase(mockRepo, logger)

    mockRepo.On("GetByEmail", mock.Anything, "test@example.com").Return(nil, nil)
    mockRepo.On("Create", mock.Anything, mock.AnythingOfType("*domain.User")).Return(nil)

    user, err := uc.CreateUser(context.Background(), input)

    assert.NoError(t, err)
    assert.NotNil(t, user)
}

介面設計的可測試性

好的介面讓測試自然:

1. 接受依賴,不要建立依賴

// Testable
func (s *UserService) CreateUser(ctx context.Context, input CreateUserInput, repo UserRepository) (*User, error) {}

// Hard to test
func (s *UserService) CreateUser(ctx context.Context, input CreateUserInput) (*User, error) {
    repo := postgres.NewUserRepository(db) // 建立依賴
}

2. 回傳結果,不要產生副作用

// Testable
func CalculateDiscount(cart *Cart) Discount {}

// Hard to test
func ApplyDiscount(cart *Cart) {
    cart.Total -= discount // 修改輸入
}

3. 小介面面積

  • 方法少 = 測試少
  • 參數少 = 測試設定簡單

每個循環的檢查清單

[ ] 測試描述行為,而非實作
[ ] 測試只使用公開介面
[ ] 測試在內部重構後仍然通過
[ ] 程式碼是讓當前測試通過的最少實作
[ ] 沒有投機性的功能

重構候選

TDD 循環完成後,尋找:

  • 重複邏輯 → 提取 function / class
  • 過長方法 → 拆成私有 helper保持測試在公開介面
  • 淺模組 → 合併或加深
  • Feature envy → 把邏輯移到資料所在的地方
  • 原始型別偏執 → 引入 value object
  • 新程式碼揭示的既有程式碼問題

相依技能

  • 前置: go-backend-dev (在實作中整合使用)
  • 輔助: design-an-interface (為可測試性設計介面)
  • 後續: qa (QA 測試)