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

297 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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