--- 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, "") } }) } ``` ## 模糊測試 (Fuzzing,Go 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}' ``` **請記住**:測試也是一種文件。它們展示了程式碼應如何被使用。請保持測試條理清晰並及時更新。