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

297 lines
7.8 KiB
Markdown
Raw Normal View History

2026-04-08 23:53:15 +00:00
---
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 測試)