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

17 KiB
Raw Blame History

name description
golang-testing 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 逐步示範

// 步驟 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 測試的標準模式。能以最少的程式碼達成全面的覆蓋。

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)
            }
        })
    }
}

包含錯誤案例的表驅動測試

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)

組織相關聯的測試項

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) {
        // ... 其他子測試
    })
}

並行子測試

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)

輔助函式

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)
    }
}

臨時檔案與目錄

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/ 目錄下的檔案中,並以此進行比對。

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

// 為依賴項定義介面
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)

基礎評測

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

不同規模的評測

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)

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+)

基礎模糊測試

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

多輸入的模糊測試

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)

執行覆蓋率檢查

# 基本覆蓋率檢查
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:generate mockgen -source=interface.go -destination=mock_interface.go

// 在執行覆蓋率檢查時,透過 Build Tags 排除:
// go test -cover -tags=!generate ./...

HTTP Handler 測試

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)
            }
        })
    }
}

常用測試指令

# 執行所有測試
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 整合

# 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}'        

請記住:測試也是一種文件。它們展示了程式碼應如何被使用。請保持測試條理清晰並及時更新。