--- name: tdd description: "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 內部零件。 ```go // 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 - 重構內部實作後測試仍然通過 - 描述「做什麼」而非「怎麼做」 - 每個測試一個邏輯斷言 ### 壊的測試 **實作細節測試**:與內部結構耦合。 ```go // 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 內部協作者只是為了驗證被呼叫 - 測試私有方法 - 斷言呼叫次數或順序 - 重構後測試失敗但行為沒變 - 測試名稱描述「怎麼做」而非「做什麼」 ```go // 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 測試規範 ### 測試命名 ```go // 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: - 你自己的類別/模組 - 內部協作者 - 你可以控制的東西 ```go // 使用 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. 接受依賴,不要建立依賴** ```go // 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. 回傳結果,不要產生副作用** ```go // 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 測試)