7.8 KiB
7.8 KiB
| 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 測試)