claude-code/claude-zh/skills/golang-testing/SKILL.md

720 lines
17 KiB
Markdown
Raw Permalink 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: golang-testing
description: Go 語言測試模式,包含表驅動測試 (Table-Driven Tests)、子測試 (Subtests)、評測 (Benchmarks)、模糊測試 (Fuzzing) 以及測試覆蓋率。遵循道地的 Go 實踐與 TDD 方法論。
---
# Go 測試模式 (Go Testing Patterns)
綜合性的 Go 測試模式,遵循 TDD 方法論撰寫可靠且易於維護的測試。
## 何時啟用
- 撰寫新的 Go 函式或方法。
- 為現有程式碼增加測試覆蓋率。
- 為效能關鍵程式碼建立評測 (Benchmarks)。
- 為輸入驗證實作模糊測試 (Fuzz tests)。
- 在 Go 專案中遵循 TDD 工作流。
## Go 的 TDD 工作流
### 紅燈-綠燈-重構 週期 (RED-GREEN-REFACTOR Cycle)
```
紅燈 (RED) → 先撰寫一個會失敗的測試
綠燈 (GREEN) → 撰寫最少量的程式碼讓測試通過
重構 (REFACTOR) → 在保持測試綠燈的情況下優化程式碼
重複 (REPEAT) → 繼續下一個需求
```
### Go TDD 逐步示範
```go
// 步驟 1定義介面/簽名
// calculator.go
package calculator
func Add(a, b int) int {
panic("尚未實作") // 佔位符
}
// 步驟 2撰寫會失敗的測試 (紅燈)
// calculator_test.go
package calculator
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d; 期望為 %d", got, want)
}
}
// 步驟 3執行測試 - 驗證失敗 (FAIL)
// $ go test
// --- FAIL: TestAdd (0.00s)
// panic: 尚未實作
// 步驟 4實作最少量的程式碼 (綠燈)
func Add(a, b int) int {
return a + b
}
// 步驟 5執行測試 - 驗證通過 (PASS)
// $ go test
// PASS
// 步驟 6根據需要重構驗證測試仍保持通過
```
## 表驅動測試 (Table-Driven Tests)
Go 測試的標準模式。能以最少的程式碼達成全面的覆蓋。
```go
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"正數", 2, 3, 5},
{"負數", -1, -2, -3},
{"零值", 0, 0, 0},
{"正負混合", -1, 1, 0},
{"大數值", 1000000, 2000000, 3000000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Add(tt.a, tt.b)
if got != tt.expected {
t.Errorf("Add(%d, %d) = %d; 期望為 %d",
tt.a, tt.b, got, tt.expected)
}
})
}
}
```
### 包含錯誤案例的表驅動測試
```go
func TestParseConfig(t *testing.T) {
tests := []struct {
name string
input string
want *Config
wantErr bool
}{
{
name: "有效配置",
input: `{"host": "localhost", "port": 8080}`,
want: &Config{Host: "localhost", Port: 8080},
},
{
name: "無效 JSON",
input: `{invalid}`,
wantErr: true,
},
{
name: "空輸入",
input: "",
wantErr: true,
},
{
name: "最簡配置",
input: `{}`,
want: &Config{}, // 零值配置
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseConfig(tt.input)
if tt.wantErr {
if err == nil {
t.Error("預期會發生錯誤,但回傳為 nil")
}
return
}
if err != nil {
t.Fatalf("發生非預期的錯誤: %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("得到 %+v; 期望為 %+v", got, tt.want)
}
})
}
}
```
## 子測試 (Subtests) 與子評測 (Sub-benchmarks)
### 組織相關聯的測試項
```go
func TestUser(t *testing.T) {
// 所有子測試共用的 Setup
db := setupTestDB(t)
t.Run("Create", func(t *testing.T) {
user := &User{Name: "Alice"}
err := db.CreateUser(user)
if err != nil {
t.Fatalf("建立使用者失敗: %v", err)
}
if user.ID == "" {
t.Error("預期應設定使用者 ID")
}
})
t.Run("Get", func(t *testing.T) {
user, err := db.GetUser("alice-id")
if err != nil {
t.Fatalf("獲取使用者失敗: %v", err)
}
if user.Name != "Alice" {
t.Errorf("得到名稱 %q; 期望為 %q", user.Name, "Alice")
}
})
t.Run("Update", func(t *testing.T) {
// ... 其他子測試
})
t.Run("Delete", func(t *testing.T) {
// ... 其他子測試
})
}
```
### 並行子測試
```go
func TestParallel(t *testing.T) {
tests := []struct {
name string
input string
}{
{"case1", "input1"},
{"case2", "input2"},
{"case3", "input3"},
}
for _, tt := range tests {
tt := tt // 捕捉迭代變數
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // 並行執行子測試
result := Process(tt.input)
// 斷言...
_ = result
})
}
}
```
## 測試輔助程式 (Test Helpers)
### 輔助函式
```go
func setupTestDB(t *testing.T) *sql.DB {
t.Helper() // 標記此函式為測試輔助程式
db, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("無法開啟資料庫: %v", err)
}
// 測試結束時自動清理
t.Cleanup(func() {
db.Close()
})
// 執行遷移
if _, err := db.Exec(schema); err != nil {
t.Fatalf("無法建立架構: %v", err)
}
return db
}
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("發生非預期的錯誤: %v", err)
}
}
func assertEqual[T comparable](t *testing.T, got, want T) {
t.Helper()
if got != want {
t.Errorf("得到 %v; 期望為 %v", got, want)
}
}
```
### 臨時檔案與目錄
```go
func TestFileProcessing(t *testing.T) {
// 建立臨時目錄 - 測試結束後會自動清除
tmpDir := t.TempDir()
// 建立測試檔案
testFile := filepath.Join(tmpDir, "test.txt")
err := os.WriteFile(testFile, []byte("測試內容"), 0644)
if err != nil {
t.Fatalf("建立測試檔案失敗: %v", err)
}
// 執行測試
result, err := ProcessFile(testFile)
if err != nil {
t.Fatalf("執行 ProcessFile 失敗: %v", err)
}
// 斷言...
_ = result
}
```
## 黃金檔案 (Golden Files)
將預期的輸出儲存在 `testdata/` 目錄下的檔案中,並以此進行比對。
```go
var update = flag.Bool("update", false, "是否更新黃金檔案")
func TestRender(t *testing.T) {
tests := []struct {
name string
input Template
}{
{"simple", Template{Name: "test"}},
{"complex", Template{Name: "test", Items: []string{"a", "b"}}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Render(tt.input)
golden := filepath.Join("testdata", tt.name+".golden")
if *update {
// 更新黃金檔案指令go test -update
err := os.WriteFile(golden, got, 0644)
if err != nil {
t.Fatalf("更新黃金檔案失敗: %v", err)
}
}
want, err := os.ReadFile(golden)
if err != nil {
t.Fatalf("讀取黃金檔案失敗: %v", err)
}
if !bytes.Equal(got, want) {
t.Errorf("輸出不符:\n得到\n%s\n期望\n%s", got, want)
}
})
}
}
```
## 使用介面進行 Mock
### 基於介面的 Mocking
```go
// 為依賴項定義介面
type UserRepository interface {
GetUser(id string) (*User, error)
SaveUser(user *User) error
}
// 生產環境的實作
type PostgresUserRepository struct {
db *sql.DB
}
func (r *PostgresUserRepository) GetUser(id string) (*User, error) {
// 實際的資料庫查詢
}
// 測試用的 Mock 實作
type MockUserRepository struct {
GetUserFunc func(id string) (*User, error)
SaveUserFunc func(user *User) error
}
func (m *MockUserRepository) GetUser(id string) (*User, error) {
return m.GetUserFunc(id)
}
func (m *MockUserRepository) SaveUser(user *User) error {
return m.SaveUserFunc(user)
}
// 使用 Mock 進行測試
func TestUserService(t *testing.T) {
mock := &MockUserRepository{
GetUserFunc: func(id string) (*User, error) {
if id == "123" {
return &User{ID: "123", Name: "Alice"}, nil
}
return nil, ErrNotFound
},
}
service := NewUserService(mock)
user, err := service.GetUserProfile("123")
if err != nil {
t.Fatalf("發生非預期的錯誤: %v", err)
}
if user.Name != "Alice" {
t.Errorf("得到名稱 %q; 期望為 %q", user.Name, "Alice")
}
}
```
## 評測 (Benchmarks)
### 基礎評測
```go
func BenchmarkProcess(b *testing.B) {
data := generateTestData(1000)
b.ResetTimer() // 排除 Setup 所耗費的時間
for i := 0; i < b.N; i++ {
Process(data)
}
}
// 執行指令go test -bench=BenchmarkProcess -benchmem
// 輸出範例BenchmarkProcess-8 10000 105234 ns/op 4096 B/op 10 allocs/op
```
### 不同規模的評測
```go
func BenchmarkSort(b *testing.B) {
sizes := []int{100, 1000, 10000, 100000}
for _, size := range sizes {
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
data := generateRandomSlice(size)
b.ResetTimer()
for i := 0; i < b.N; i++ {
// 進行深拷貝以避免對已排序資料再次排序,從而準確評測
tmp := make([]int, len(data))
copy(tmp, data)
sort.Ints(tmp)
}
})
}
}
```
### 記憶體配置評測 (Memory Allocation Benchmarks)
```go
func BenchmarkStringConcat(b *testing.B) {
parts := []string{"hello", "world", "foo", "bar", "baz"}
b.Run("plus", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var s string
for _, p := range parts {
s += p
}
_ = s
}
})
b.Run("builder", func(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p)
}
_ = sb.String()
}
})
b.Run("join", func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = strings.Join(parts, "")
}
})
}
```
## 模糊測試 (FuzzingGo 1.18+)
### 基礎模糊測試
```go
func FuzzParseJSON(f *testing.F) {
// 加入種子語料庫 (Seed corpus)
f.Add(`{"name": "test"}`)
f.Add(`{"count": 123}`)
f.Add(`[]`)
f.Add(`""`)
f.Fuzz(func(t *testing.T, input string) {
var result map[string]interface{}
err := json.Unmarshal([]byte(input), &result)
if err != nil {
// 對於隨機輸入,無效的 JSON 是可預期的
return
}
// 如果解析成功,則重新進行編碼應該也要成功
_, err = json.Marshal(result)
if err != nil {
t.Errorf("解析成功後 Marshal 卻失敗了: %v", err)
}
})
}
// 執行指令go test -fuzz=FuzzParseJSON -fuzztime=30s
```
### 多輸入的模糊測試
```go
func FuzzCompare(f *testing.F) {
f.Add("hello", "world")
f.Add("", "")
f.Add("abc", "abc")
f.Fuzz(func(t *testing.T, a, b string) {
result := Compare(a, b)
// 屬性Compare(a, a) 結果應永遠為 0
if a == b && result != 0 {
t.Errorf("Compare(%q, %q) = %d; 期望為 0", a, b, result)
}
// 屬性Compare(a, b) 與 Compare(b, a) 應具備相反的正負號
reverse := Compare(b, a)
if (result > 0 && reverse >= 0) || (result < 0 && reverse <= 0) {
if result != 0 || reverse != 0 {
t.Errorf("Compare(%q, %q) = %d, Compare(%q, %q) = %d; 結果不一致",
a, b, result, b, a, reverse)
}
}
})
}
```
## 測試覆蓋率 (Test Coverage)
### 執行覆蓋率檢查
```bash
# 基本覆蓋率檢查
go test -cover ./...
# 生成覆蓋率配置檔案
go test -coverprofile=coverage.out ./...
# 在瀏覽器中查看詳情
go tool cover -html=coverage.out
# 按函式查看覆蓋率
go tool cover -func=coverage.out
# 同時開啟 Race 偵測與覆蓋率檢查
go test -race -coverprofile=coverage.out ./...
```
### 覆蓋率目標
| 程式碼類型 | 目標百分比 |
|-----------|--------|
| 關鍵業務邏輯 | 100% |
| 公開 API | 90%+ |
| 一般程式碼 | 80%+ |
| 自動生成的程式碼 | 排除不計 |
### 從覆蓋率中排除自動產生的程式碼
```go
//go:generate mockgen -source=interface.go -destination=mock_interface.go
// 在執行覆蓋率檢查時,透過 Build Tags 排除:
// go test -cover -tags=!generate ./...
```
## HTTP Handler 測試
```go
func TestHealthHandler(t *testing.T) {
// 建立請求
req := httptest.NewRequest(http.MethodGet, "/health", nil)
w := httptest.NewRecorder()
// 呼叫 Handler
HealthHandler(w, req)
// 檢查回應
resp := w.Result()
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("得到狀態碼 %d; 期望為 %d", resp.StatusCode, http.StatusOK)
}
body, _ := io.ReadAll(resp.Body)
if string(body) != "OK" {
t.Errorf("得到回應文字 %q; 期望為 %q", body, "OK")
}
}
func TestAPIHandler(t *testing.T) {
tests := []struct {
name string
method string
path string
body string
wantStatus int
wantBody string
}{
{
name: "獲取使用者",
method: http.MethodGet,
path: "/users/123",
wantStatus: http.StatusOK,
wantBody: `{"id":"123","name":"Alice"}`,
},
{
name: "找不到資源",
method: http.MethodGet,
path: "/users/999",
wantStatus: http.StatusNotFound,
},
{
name: "建立使用者",
method: http.MethodPost,
path: "/users",
body: `{"name":"Bob"}`,
wantStatus: http.StatusCreated,
},
}
handler := NewAPIHandler()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var body io.Reader
if tt.body != "" {
body = strings.NewReader(tt.body)
}
req := httptest.NewRequest(tt.method, tt.path, body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != tt.wantStatus {
t.Errorf("得到狀態碼 %d; 期望為 %d", w.Code, tt.wantStatus)
}
if tt.wantBody != "" && w.Body.String() != tt.wantBody {
t.Errorf("得到回應內容 %q; 期望為 %q", w.Body.String(), tt.wantBody)
}
})
}
}
```
## 常用測試指令
```bash
# 執行所有測試
go test ./...
# 執行測試並詳細輸出日誌
go test -v ./...
# 執行特定的測試項
go test -run TestAdd ./...
# 執行符合模式的測試項
go test -run "TestUser/Create" ./...
# 開啟 Race Detector (資源競爭偵測)
go test -race ./...
# 執行測試並產出覆蓋率報告
go test -cover -coverprofile=coverage.out ./...
# 僅執行標記為「短時」的測試
go test -short ./...
# 設定測試超時時間
go test -timeout 30s ./...
# 執行評測
go test -bench=. -benchmem ./...
# 執行模糊測試
go test -fuzz=FuzzParse -fuzztime=30s ./...
# 重複執行次數 (用於捕捉不穩定的測試)
go test -count=10 ./...
```
## 最佳實踐
**推薦做法 (DO)**
- 先寫測試 (TDD)。
- 使用表驅動測試來達成全面覆蓋。
- 測試「行為」,而非測試「實作」。
- 在輔助函式中使用 `t.Helper()`
- 對於彼此獨立的測試項使用 `t.Parallel()`
- 使用 `t.Cleanup()` 來清理資源。
- 使用具備明確語義、能描述情境的測試名稱。
**應避免的做法 (DON'T)**
- 直接測試私有函式 (應透過公開 API 進行測試)。
- 在測試中使用 `time.Sleep()` (應使用通道或同步條件)。
- 忽視不穩定的測試 (應修復或移除它們)。
- 對所有內容都進行 Mock (盡可能優先使用整合測試)。
- 遺漏錯誤路徑 (Error Path) 的測試。
## 與 CI/CD 整合
```yaml
# GitHub Actions 範例
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: 執行測試
run: go test -race -coverprofile=coverage.out ./...
- name: 檢查覆蓋率
run: |
go tool cover -func=coverage.out | grep total | awk '{print $3}' | \
awk -F'%' '{if ($1 < 80) exit 1}'
```
**請記住**:測試也是一種文件。它們展示了程式碼應如何被使用。請保持測試條理清晰並及時更新。