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