Go testing 詳解
目錄
1. testing 包基礎
1.1 測試文件和函數命名規則
Go 測試遵循嚴格的命名約定:
- 測試文件以
_test.go結尾(如user_test.go) - 測試函數以
Test開頭,接一個大寫字母開頭的名稱 - 測試函數接受
*testing.T參數 - 測試文件和被測文件放在同一個包中
myproject/
├── math.go # 被測代碼
├── math_test.go # 測試代碼
├── user.go
└── user_test.go
1.2 第一個測試
// math.go
package mymath
// Add 返回兩個整數的和
func Add(a, b int) int {
return a + b
}
// Abs 返回絕對值
func Abs(n int) int {
if n < 0 {
return -n
}
return n
}
// math_test.go
package mymath
import "testing"
func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d; 期望 5", result)
}
}
func TestAbs(t *testing.T) {
result := Abs(-5)
if result != 5 {
t.Errorf("Abs(-5) = %d; 期望 5", result)
}
}
運行測試:
# 運行當前包的所有測試
go test
# 顯示詳細輸出
go test -v
# 運行匹配特定名稱的測試
go test -run TestAdd
# 運行項目中所有包的測試
go test ./...
1.3 t.Error, t.Fatal, t.Log 的區別
package mymath
import "testing"
func TestErrorVsFatal(t *testing.T) {
// t.Log 輸出日誌信息(僅在 -v 模式或測試失敗時顯示)
t.Log("開始測試...")
// t.Error / t.Errorf 標記測試失敗,但繼續執行後續代碼
result := Add(1, 1)
if result != 2 {
t.Errorf("Add(1,1) = %d; 期望 2", result)
}
t.Log("這行代碼在 t.Error 後仍然會執行")
// t.Fatal / t.Fatalf 標記測試失敗,並立即停止當前測試函數
if result == 0 {
t.Fatal("結果為零,無法繼續後續驗證")
// 下面的代碼不會執行
}
t.Log("這行代碼在 t.Fatal 未觸發時才會執行")
}
// 何時用 Error,何時用 Fatal?
// - Error: 當後續的斷言仍有意義時(想看到所有失敗的斷言)
// - Fatal: 當後續代碼依賴於當前斷言的結果(如 nil 檢查後解引用)
func TestFatalForPrerequisite(t *testing.T) {
// 典型用法:對 error/nil 檢查用 Fatal
result := Add(10, 20)
// 假設這裡是某個可能返回 nil 的操作
if result == 0 {
t.Fatal("前置條件不滿足,終止測試")
}
// 後續操作依賴 result 不為 0
t.Logf("結果: %d", result)
}
1.4 t.Helper
package mymath
import "testing"
// assertEqual 是一個測試輔助函數
// t.Helper() 讓錯誤報告的行號指向調用者,而不是這個輔助函數內部
func assertEqual(t *testing.T, got, want int) {
t.Helper() // 標記為輔助函數
if got != want {
t.Errorf("得到 %d; 期望 %d", got, want)
}
}
func assertNoError(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestWithHelper(t *testing.T) {
// 如果斷言失敗,錯誤報告會指向這一行,而不是 assertEqual 內部
assertEqual(t, Add(1, 2), 3)
assertEqual(t, Add(-1, 1), 0)
assertEqual(t, Abs(-10), 10)
}
1.5 t.Cleanup
package mymath
import (
"fmt"
"os"
"testing"
)
func TestWithCleanup(t *testing.T) {
// 創建臨時文件
f, err := os.CreateTemp("", "test-*")
if err != nil {
t.Fatal(err)
}
// t.Cleanup 註冊清理函數,測試結束時自動執行
// 類似 defer 但由測試框架管理,子測試結束時也會執行
t.Cleanup(func() {
f.Close()
os.Remove(f.Name())
fmt.Println("清理完畢:", f.Name())
})
// 在測試中使用臨時文件
_, err = f.WriteString("test data")
if err != nil {
t.Fatal(err)
}
t.Log("臨時文件:", f.Name())
}
1.6 t.Skip 跳過測試
package mymath
import (
"os"
"runtime"
"testing"
)
func TestOnlyOnLinux(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("此測試僅在 Linux 上運行")
}
t.Log("在 Linux 上執行特定測試...")
}
func TestRequiresDatabase(t *testing.T) {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
t.Skip("跳過:未設置 DATABASE_URL 環境變量")
}
t.Logf("連接數據庫: %s", dsn)
}
func TestSlow(t *testing.T) {
if testing.Short() {
t.Skip("跳過慢速測試(使用 -short 標誌時)")
}
// 運行耗時較長的測試...
t.Log("執行慢速測試...")
}
運行方式:
# 跳過慢速測試
go test -short
# 查看跳過原因
go test -v -short
2. 表格驅動測試
2.1 基本的表格驅動測試
表格驅動測試(Table-Driven Tests)是 Go 社區最推薦的測試模式:
package mymath
import "testing"
func TestAdd_TableDriven(t *testing.T) {
// 定義測試用例表
tests := []struct {
name string // 測試用例名稱
a, b int // 輸入
expected int // 期望輸出
}{
{"正數相加", 1, 2, 3},
{"負數相加", -1, -2, -3},
{"正負相加", -1, 1, 0},
{"零值", 0, 0, 0},
{"大數", 1000000, 2000000, 3000000},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; 期望 %d",
tt.a, tt.b, result, tt.expected)
}
})
}
}
2.2 包含錯誤檢查的表格測試
package mymath
import (
"errors"
"fmt"
"testing"
)
// Divide 整數除法
func Divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("除數不能為零")
}
return a / b, nil
}
func TestDivide(t *testing.T) {
tests := []struct {
name string
a, b int
want int
wantErr bool // 是否期望錯誤
errMsg string // 期望的錯誤消息(可選)
}{
{
name: "正常除法",
a: 10, b: 3,
want: 3, wantErr: false,
},
{
name: "整除",
a: 10, b: 2,
want: 5, wantErr: false,
},
{
name: "除以零",
a: 10, b: 0,
want: 0, wantErr: true,
errMsg: "除數不能為零",
},
{
name: "負數除法",
a: -10, b: 3,
want: -3, wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
// 檢查錯誤
if tt.wantErr {
if err == nil {
t.Fatal("期望錯誤,但沒有返回錯誤")
}
if tt.errMsg != "" && err.Error() != tt.errMsg {
t.Errorf("錯誤消息 = %q; 期望 %q", err.Error(), tt.errMsg)
}
return // 有錯誤時不檢查返回值
}
if err != nil {
t.Fatalf("不期望錯誤,但得到: %v", err)
}
// 檢查結果
if got != tt.want {
t.Errorf("Divide(%d, %d) = %d; 期望 %d",
tt.a, tt.b, got, tt.want)
}
})
}
}
// 使用 map 作為測試用例集合(無序但可讀性好)
func TestDivide_Map(t *testing.T) {
tests := map[string]struct {
a, b int
want int
wantErr bool
}{
"10/2": {10, 2, 5, false},
"9/3": {9, 3, 3, false},
"1/0": {1, 0, 0, true},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
got, err := Divide(tt.a, tt.b)
if (err != nil) != tt.wantErr {
t.Fatalf("Divide(%d, %d) error = %v, wantErr %v",
tt.a, tt.b, err, tt.wantErr)
}
if !tt.wantErr && got != tt.want {
t.Errorf("got %d, want %d", got, tt.want)
}
})
}
}
func init() {
_ = fmt.Sprintf // avoid unused import
}
3. 子測試 t.Run
3.1 子測試基礎
package mymath
import "testing"
func TestAbs_Subtests(t *testing.T) {
// 子測試允許將相關測試組織在一起
t.Run("正數", func(t *testing.T) {
if Abs(5) != 5 {
t.Error("正數的絕對值應該等於自身")
}
})
t.Run("負數", func(t *testing.T) {
if Abs(-5) != 5 {
t.Error("負數的絕對值應該等於其相反數")
}
})
t.Run("零", func(t *testing.T) {
if Abs(0) != 0 {
t.Error("零的絕對值應該等於零")
}
})
}
運行特定子測試:
# 運行 TestAbs_Subtests 下的 "負數" 子測試
go test -run TestAbs_Subtests/負數 -v
# 支持正則表達式
go test -run "TestAbs_Subtests/正" -v
3.2 嵌套子測試與共享 setup
package mymath
import (
"testing"
)
// UserService 模擬服務
type UserService struct {
users map[string]int // name -> age
}
func NewUserService() *UserService {
return &UserService{
users: map[string]int{
"Alice": 30,
"Bob": 25,
},
}
}
func (s *UserService) GetAge(name string) (int, bool) {
age, ok := s.users[name]
return age, ok
}
func (s *UserService) AddUser(name string, age int) {
s.users[name] = age
}
func TestUserService(t *testing.T) {
// 頂層 setup: 所有子測試共享
svc := NewUserService()
t.Run("GetAge", func(t *testing.T) {
t.Run("存在的用戶", func(t *testing.T) {
age, ok := svc.GetAge("Alice")
if !ok {
t.Fatal("用戶 Alice 應該存在")
}
if age != 30 {
t.Errorf("Alice 的年齡 = %d; 期望 30", age)
}
})
t.Run("不存在的用戶", func(t *testing.T) {
_, ok := svc.GetAge("Charlie")
if ok {
t.Error("用戶 Charlie 不應該存在")
}
})
})
t.Run("AddUser", func(t *testing.T) {
svc.AddUser("Dave", 35)
age, ok := svc.GetAge("Dave")
if !ok {
t.Fatal("添加後用戶 Dave 應該存在")
}
if age != 35 {
t.Errorf("Dave 的年齡 = %d; 期望 35", age)
}
})
}
3.3 並行子測試
package mymath
import (
"testing"
"time"
)
// slowOperation 模擬耗時操作
func slowOperation(input int) int {
time.Sleep(100 * time.Millisecond)
return input * 2
}
func TestParallelSubtests(t *testing.T) {
tests := []struct {
name string
input int
want int
}{
{"double_1", 1, 2},
{"double_2", 2, 4},
{"double_5", 5, 10},
{"double_10", 10, 20},
}
for _, tt := range tests {
tt := tt // 重要!在 Go 1.22 之前需要創建循環變量的副本
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // 標記為可並行執行
got := slowOperation(tt.input)
if got != tt.want {
t.Errorf("slowOperation(%d) = %d; want %d",
tt.input, got, tt.want)
}
})
}
// 所有並行子測試會在 TestParallelSubtests 返回前完成
}
注意:在 Go 1.22 之前,
for循環中的變量在每次迭代時共享同一地址。如果不使用tt := tt創建副本,並行子測試可能讀到錯誤的值。Go 1.22+ 已修復此問題(循環變量按迭代創建)。
4. 基準測試 Benchmark
4.1 基準測試基礎
package mymath
import "testing"
// 基準測試函數以 Benchmark 開頭,參數是 *testing.B
func BenchmarkAdd(b *testing.B) {
// b.N 由測試框架動態調整,直到結果穩定
for i := 0; i < b.N; i++ {
Add(100, 200)
}
}
func BenchmarkAbs(b *testing.B) {
for i := 0; i < b.N; i++ {
Abs(-42)
}
}
運行基準測試:
# 運行所有基準測試
go test -bench=.
# 運行特定基準測試
go test -bench=BenchmarkAdd
# 運行多次以獲得更穩定的結果
go test -bench=. -count=5
# 同時顯示內存分配信息
go test -bench=. -benchmem
# 指定運行時間(默認1秒)
go test -bench=. -benchtime=3s
輸出示例:
BenchmarkAdd-8 1000000000 0.2950 ns/op
BenchmarkAbs-8 1000000000 0.5100 ns/op
-8表示使用 8 個 CPU 核心1000000000是 b.N 的值(運行次數)0.2950 ns/op是每次操作的耗時
4.2 b.ResetTimer 和 b.StopTimer
package mymath
import (
"testing"
"time"
)
// 場景:基準測試需要準備數據,但準備時間不應計入基準
func BenchmarkWithSetup(b *testing.B) {
// 準備階段(耗時的初始化)
data := make([]int, 10000)
for i := range data {
data[i] = i
}
time.Sleep(10 * time.Millisecond) // 模擬耗時初始化
// 重置計時器,排除準備階段的時間
b.ResetTimer()
for i := 0; i < b.N; i++ {
// 只測量這個操作的性能
sum := 0
for _, v := range data {
sum += v
}
}
}
func BenchmarkWithPause(b *testing.B) {
for i := 0; i < b.N; i++ {
// 暫停計時:準備每次迭代的輸入
b.StopTimer()
data := make([]int, 100)
for j := range data {
data[j] = j
}
b.StartTimer()
// 只測量這個操作
sum := 0
for _, v := range data {
sum += v
}
_ = sum
}
}
4.3 b.ReportAllocs 和內存基準
package mymath
import (
"fmt"
"strings"
"testing"
)
// 比較字符串拼接的不同方式
func concatWithPlus(strs []string) string {
result := ""
for _, s := range strs {
result += s
}
return result
}
func concatWithBuilder(strs []string) string {
var b strings.Builder
for _, s := range strs {
b.WriteString(s)
}
return b.String()
}
func concatWithJoin(strs []string) string {
return strings.Join(strs, "")
}
func BenchmarkConcatPlus(b *testing.B) {
strs := make([]string, 100)
for i := range strs {
strs[i] = fmt.Sprintf("item%d", i)
}
b.ReportAllocs() // 報告內存分配
b.ResetTimer()
for i := 0; i < b.N; i++ {
concatWithPlus(strs)
}
}
func BenchmarkConcatBuilder(b *testing.B) {
strs := make([]string, 100)
for i := range strs {
strs[i] = fmt.Sprintf("item%d", i)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
concatWithBuilder(strs)
}
}
func BenchmarkConcatJoin(b *testing.B) {
strs := make([]string, 100)
for i := range strs {
strs[i] = fmt.Sprintf("item%d", i)
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
concatWithJoin(strs)
}
}
運行結果示例(go test -bench=BenchmarkConcat -benchmem):
BenchmarkConcatPlus-8 5000 300000 ns/op 530000 B/op 99 allocs/op
BenchmarkConcatBuilder-8 200000 8000 ns/op 2048 B/op 8 allocs/op
BenchmarkConcatJoin-8 200000 7500 ns/op 1024 B/op 1 allocs/op
可以清楚看到 + 拼接的內存分配遠高於 Builder 和 Join。
4.4 子基準測試
package mymath
import (
"fmt"
"testing"
)
func BenchmarkAbs_Sizes(b *testing.B) {
inputs := []int{1, 100, -1, -100, 0}
for _, input := range inputs {
b.Run(fmt.Sprintf("input=%d", input), func(b *testing.B) {
for i := 0; i < b.N; i++ {
Abs(input)
}
})
}
}
go test -bench="BenchmarkAbs_Sizes" -v
# BenchmarkAbs_Sizes/input=1-8 1000000000 0.30 ns/op
# BenchmarkAbs_Sizes/input=100-8 1000000000 0.30 ns/op
# BenchmarkAbs_Sizes/input=-1-8 1000000000 0.52 ns/op
# ...
5. 測試覆蓋率
5.1 基本覆蓋率
# 查看覆蓋率百分比
go test -cover
# 輸出示例:
# PASS
# coverage: 85.7% of statements
# ok mypackage 0.003s
# 生成覆蓋率 profile 文件
go test -coverprofile=coverage.out
# 在終端查看每個函數的覆蓋率
go tool cover -func=coverage.out
# 輸出示例:
# mypackage/math.go:3: Add 100.0%
# mypackage/math.go:8: Abs 80.0%
# mypackage/math.go:15: Divide 100.0%
# total: (statements) 93.3%
# 在瀏覽器中查看可視化覆蓋率報告(綠色=已覆蓋,紅色=未覆蓋)
go tool cover -html=coverage.out
5.2 覆蓋率模式
# 默認模式:set(行是否被執行)
go test -coverprofile=coverage.out -covermode=set
# count 模式:每行執行了幾次
go test -coverprofile=coverage.out -covermode=count
# atomic 模式:count 的線程安全版本,適合 -race
go test -coverprofile=coverage.out -covermode=atomic
5.3 多包覆蓋率
# 計算所有包的覆蓋率
go test -coverprofile=coverage.out ./...
# 包含所有包(即使沒有測試文件的包也統計)
go test -coverpkg=./... -coverprofile=coverage.out ./...
5.4 在 CI 中使用覆蓋率
#!/bin/bash
# ci_test.sh - CI 中的測試腳本
# 運行測試並生成覆蓋率報告
go test -coverprofile=coverage.out -covermode=atomic ./...
# 提取總覆蓋率
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
echo "總覆蓋率: ${COVERAGE}%"
# 檢查覆蓋率是否達到最低要求(如 80%)
MIN_COVERAGE=80.0
if [ "$(echo "$COVERAGE < $MIN_COVERAGE" | bc)" -eq 1 ]; then
echo "覆蓋率 ${COVERAGE}% 低於最低要求 ${MIN_COVERAGE}%"
exit 1
fi
6. TestMain
6.1 TestMain 基礎
TestMain 可以控制測試的整體生命週期,在所有測試執行前後做 setup 和 teardown。
package mymath
import (
"fmt"
"os"
"testing"
)
// TestMain 每個包最多只能有一個
// 它在所有測試之前運行,控制測試流程
func TestMain(m *testing.M) {
// ===== Setup: 在所有測試之前執行 =====
fmt.Println(">>> 全局 Setup: 初始化測試環境")
// 例如:創建測試數據庫、啓動測試服務器、加載測試數據等
// ===== 運行所有測試 =====
// m.Run() 運行所有 Test* 函數,返回退出碼
exitCode := m.Run()
// ===== Teardown: 在所有測試之後執行 =====
fmt.Println(">>> 全局 Teardown: 清理測試環境")
// 例如:刪除測試數據庫、關閉連接等
// 必須調用 os.Exit,否則測試結果不會正確報告
os.Exit(exitCode)
}
func TestExample1(t *testing.T) {
t.Log("運行測試1")
}
func TestExample2(t *testing.T) {
t.Log("運行測試2")
}
輸出:
>>> 全局 Setup: 初始化測試環境
=== RUN TestExample1
運行測試1
--- PASS: TestExample1
=== RUN TestExample2
運行測試2
--- PASS: TestExample2
>>> 全局 Teardown: 清理測試環境
PASS
6.2 實際應用:數據庫測試
package db_test
import (
"database/sql"
"fmt"
"os"
"testing"
_ "github.com/mattn/go-sqlite3" // SQLite 驅動
)
var testDB *sql.DB
func TestMain(m *testing.M) {
// Setup: 創建測試數據庫
var err error
testDB, err = sql.Open("sqlite3", ":memory:")
if err != nil {
fmt.Fprintf(os.Stderr, "無法創建測試數據庫: %v\n", err)
os.Exit(1)
}
// 創建表結構
_, err = testDB.Exec(`
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL
)
`)
if err != nil {
fmt.Fprintf(os.Stderr, "無法創建表: %v\n", err)
os.Exit(1)
}
// 運行測試
exitCode := m.Run()
// Teardown: 關閉數據庫
testDB.Close()
os.Exit(exitCode)
}
func TestInsertUser(t *testing.T) {
// 每個測試前清理數據(或使用事務回滾)
t.Cleanup(func() {
testDB.Exec("DELETE FROM users")
})
_, err := testDB.Exec(
"INSERT INTO users (name, email) VALUES (?, ?)",
"Alice", "alice@example.com",
)
if err != nil {
t.Fatalf("插入用戶失敗: %v", err)
}
var count int
err = testDB.QueryRow("SELECT COUNT(*) FROM users").Scan(&count)
if err != nil {
t.Fatalf("查詢失敗: %v", err)
}
if count != 1 {
t.Errorf("用戶數量 = %d; 期望 1", count)
}
}
func TestUniqueEmail(t *testing.T) {
t.Cleanup(func() {
testDB.Exec("DELETE FROM users")
})
testDB.Exec("INSERT INTO users (name, email) VALUES (?, ?)",
"Alice", "alice@example.com")
// 插入重復郵箱應該失敗
_, err := testDB.Exec("INSERT INTO users (name, email) VALUES (?, ?)",
"Bob", "alice@example.com")
if err == nil {
t.Error("期望唯一約束錯誤,但沒有返回錯誤")
}
}
7. httptest 包
7.1 測試 HTTP Handler
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// ========== 被測代碼 ==========
// HealthHandler 健康檢查
func HealthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
})
}
// GreetHandler 問候接口
func GreetHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
http.Error(w, "缺少 name 參數", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
fmt.Fprintf(w, "你好, %s!", name)
}
// CreateUserHandler 創建用戶
func CreateUserHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "只接受 POST 請求", http.StatusMethodNotAllowed)
return
}
var user struct {
Name string `json:"name"`
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
http.Error(w, "無效的 JSON", http.StatusBadRequest)
return
}
if user.Name == "" || user.Email == "" {
http.Error(w, "name 和 email 不能為空", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"id": 1,
"name": user.Name,
"email": user.Email,
})
}
// ========== 測試代碼 ==========
func TestHealthHandler(t *testing.T) {
// 創建請求
req := httptest.NewRequest(http.MethodGet, "/health", nil)
// 創建 ResponseRecorder(模擬的 http.ResponseWriter)
rr := httptest.NewRecorder()
// 調用 handler
HealthHandler(rr, req)
// 檢查狀態碼
if rr.Code != http.StatusOK {
t.Errorf("狀態碼 = %d; 期望 %d", rr.Code, http.StatusOK)
}
// 檢查 Content-Type
contentType := rr.Header().Get("Content-Type")
if contentType != "application/json" {
t.Errorf("Content-Type = %s; 期望 application/json", contentType)
}
// 檢查響應體
var body map[string]string
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatalf("解析響應體失敗: %v", err)
}
if body["status"] != "ok" {
t.Errorf("status = %s; 期望 ok", body["status"])
}
}
func TestGreetHandler(t *testing.T) {
tests := []struct {
name string
queryName string
wantCode int
wantBody string
}{
{
name: "正常請求",
queryName: "Alice",
wantCode: http.StatusOK,
wantBody: "你好, Alice!",
},
{
name: "缺少參數",
queryName: "",
wantCode: http.StatusBadRequest,
wantBody: "缺少 name 參數\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
url := "/greet"
if tt.queryName != "" {
url += "?name=" + tt.queryName
}
req := httptest.NewRequest(http.MethodGet, url, nil)
rr := httptest.NewRecorder()
GreetHandler(rr, req)
if rr.Code != tt.wantCode {
t.Errorf("狀態碼 = %d; 期望 %d", rr.Code, tt.wantCode)
}
if rr.Body.String() != tt.wantBody {
t.Errorf("響應體 = %q; 期望 %q", rr.Body.String(), tt.wantBody)
}
})
}
}
func TestCreateUserHandler(t *testing.T) {
tests := []struct {
name string
method string
body string
wantCode int
}{
{
name: "成功創建",
method: http.MethodPost,
body: `{"name":"Alice","email":"alice@example.com"}`,
wantCode: http.StatusCreated,
},
{
name: "錯誤的方法",
method: http.MethodGet,
body: "",
wantCode: http.StatusMethodNotAllowed,
},
{
name: "無效JSON",
method: http.MethodPost,
body: "not json",
wantCode: http.StatusBadRequest,
},
{
name: "缺少字段",
method: http.MethodPost,
body: `{"name":"Alice"}`,
wantCode: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var body *strings.Reader
if tt.body != "" {
body = strings.NewReader(tt.body)
} else {
body = strings.NewReader("")
}
req := httptest.NewRequest(tt.method, "/users", body)
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
CreateUserHandler(rr, req)
if rr.Code != tt.wantCode {
t.Errorf("狀態碼 = %d; 期望 %d\n響應: %s",
rr.Code, tt.wantCode, rr.Body.String())
}
})
}
}
7.2 httptest.NewServer 測試完整的 HTTP 流程
package api
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing"
)
func TestWithTestServer(t *testing.T) {
// 創建一個真正監聽端口的測試服務器
mux := http.NewServeMux()
mux.HandleFunc("/health", HealthHandler)
mux.HandleFunc("/greet", GreetHandler)
server := httptest.NewServer(mux)
defer server.Close() // 測試結束後關閉服務器
t.Logf("測試服務器地址: %s", server.URL)
// 像真正的客戶端一樣發送請求
t.Run("health", func(t *testing.T) {
resp, err := http.Get(server.URL + "/health")
if err != nil {
t.Fatalf("請求失敗: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("狀態碼 = %d; 期望 200", resp.StatusCode)
}
var body map[string]string
json.NewDecoder(resp.Body).Decode(&body)
if body["status"] != "ok" {
t.Errorf("status = %s; 期望 ok", body["status"])
}
})
t.Run("greet", func(t *testing.T) {
resp, err := http.Get(server.URL + "/greet?name=World")
if err != nil {
t.Fatalf("請求失敗: %v", err)
}
defer resp.Body.Close()
bodyBytes, _ := io.ReadAll(resp.Body)
if string(bodyBytes) != "你好, World!" {
t.Errorf("響應 = %q; 期望 %q", string(bodyBytes), "你好, World!")
}
})
}
// 測試 TLS 服務器
func TestWithTLSServer(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(HealthHandler))
defer server.Close()
// server.Client() 返回配置好 TLS 證書的 http.Client
client := server.Client()
resp, err := client.Get(server.URL + "/health")
if err != nil {
t.Fatalf("TLS 請求失敗: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("狀態碼 = %d; 期望 200", resp.StatusCode)
}
}
8. Mock 和接口測試技巧
8.1 使用接口實現 Mock
Go 推薦通過接口來實現依賴注入和 Mock,而不是使用複雜的 Mock 框架。
package service
import (
"errors"
"testing"
)
// ========== 定義接口 ==========
// UserRepository 用戶數據訪問接口
type UserRepository interface {
GetByID(id int) (*User, error)
Save(user *User) error
Delete(id int) error
}
// EmailSender 郵件發送接口
type EmailSender interface {
Send(to, subject, body string) error
}
type User struct {
ID int
Name string
Email string
}
// ========== 被測服務 ==========
// UserService 依賴接口而不是具體實現
type UserService struct {
repo UserRepository
email EmailSender
}
func NewUserService(repo UserRepository, email EmailSender) *UserService {
return &UserService{repo: repo, email: email}
}
func (s *UserService) GetUser(id int) (*User, error) {
return s.repo.GetByID(id)
}
func (s *UserService) CreateUser(name, email string) (*User, error) {
user := &User{Name: name, Email: email}
if err := s.repo.Save(user); err != nil {
return nil, err
}
// 發送歡迎郵件
err := s.email.Send(email, "歡迎", "歡迎加入!")
if err != nil {
// 郵件發送失敗不影響用戶創建
// 實際項目中可能記錄日誌或放入隊列
}
return user, nil
}
// ========== Mock 實現 ==========
// MockUserRepo 模擬用戶倉庫
type MockUserRepo struct {
users map[int]*User
saveErr error // 可以注入錯誤
saveCalled bool
}
func NewMockUserRepo() *MockUserRepo {
return &MockUserRepo{
users: make(map[int]*User),
}
}
func (m *MockUserRepo) GetByID(id int) (*User, error) {
user, ok := m.users[id]
if !ok {
return nil, errors.New("用戶未找到")
}
return user, nil
}
func (m *MockUserRepo) Save(user *User) error {
m.saveCalled = true
if m.saveErr != nil {
return m.saveErr
}
user.ID = len(m.users) + 1
m.users[user.ID] = user
return nil
}
func (m *MockUserRepo) Delete(id int) error {
delete(m.users, id)
return nil
}
// MockEmailSender 模擬郵件發送
type MockEmailSender struct {
sentEmails []struct {
To, Subject, Body string
}
sendErr error
}
func (m *MockEmailSender) Send(to, subject, body string) error {
if m.sendErr != nil {
return m.sendErr
}
m.sentEmails = append(m.sentEmails, struct {
To, Subject, Body string
}{to, subject, body})
return nil
}
// ========== 測試 ==========
func TestUserService_GetUser(t *testing.T) {
repo := NewMockUserRepo()
repo.users[1] = &User{ID: 1, Name: "Alice", Email: "alice@test.com"}
svc := NewUserService(repo, &MockEmailSender{})
t.Run("用戶存在", func(t *testing.T) {
user, err := svc.GetUser(1)
if err != nil {
t.Fatalf("不期望錯誤: %v", err)
}
if user.Name != "Alice" {
t.Errorf("Name = %s; 期望 Alice", user.Name)
}
})
t.Run("用戶不存在", func(t *testing.T) {
_, err := svc.GetUser(999)
if err == nil {
t.Fatal("期望錯誤,但沒有返回")
}
})
}
func TestUserService_CreateUser(t *testing.T) {
t.Run("成功創建併發送郵件", func(t *testing.T) {
repo := NewMockUserRepo()
emailer := &MockEmailSender{}
svc := NewUserService(repo, emailer)
user, err := svc.CreateUser("Bob", "bob@test.com")
if err != nil {
t.Fatalf("創建失敗: %v", err)
}
if user.ID == 0 {
t.Error("用戶ID不應為0")
}
if !repo.saveCalled {
t.Error("Save 應該被調用")
}
if len(emailer.sentEmails) != 1 {
t.Errorf("發送了 %d 封郵件; 期望 1", len(emailer.sentEmails))
}
if emailer.sentEmails[0].To != "bob@test.com" {
t.Errorf("郵件發送給 %s; 期望 bob@test.com",
emailer.sentEmails[0].To)
}
})
t.Run("保存失敗", func(t *testing.T) {
repo := NewMockUserRepo()
repo.saveErr = errors.New("數據庫錯誤")
svc := NewUserService(repo, &MockEmailSender{})
_, err := svc.CreateUser("Bob", "bob@test.com")
if err == nil {
t.Fatal("期望錯誤")
}
})
t.Run("郵件失敗不影響創建", func(t *testing.T) {
repo := NewMockUserRepo()
emailer := &MockEmailSender{sendErr: errors.New("SMTP錯誤")}
svc := NewUserService(repo, emailer)
user, err := svc.CreateUser("Charlie", "charlie@test.com")
if err != nil {
t.Fatalf("用戶創建不應失敗: %v", err)
}
if user.Name != "Charlie" {
t.Errorf("Name = %s; 期望 Charlie", user.Name)
}
})
}
8.2 函數類型作為輕量 Mock
package service
import (
"fmt"
"testing"
)
// Fetcher 使用函數類型定義接口(更輕量)
type Fetcher func(url string) ([]byte, error)
// CachedFetcher 帶緩存的獲取器
type CachedFetcher struct {
fetch Fetcher
cache map[string][]byte
}
func NewCachedFetcher(fetch Fetcher) *CachedFetcher {
return &CachedFetcher{
fetch: fetch,
cache: make(map[string][]byte),
}
}
func (cf *CachedFetcher) Get(url string) ([]byte, error) {
if data, ok := cf.cache[url]; ok {
return data, nil // 緩存命中
}
data, err := cf.fetch(url)
if err != nil {
return nil, fmt.Errorf("獲取 %s: %w", url, err)
}
cf.cache[url] = data
return data, nil
}
func TestCachedFetcher(t *testing.T) {
callCount := 0
// 直接用閉包作為 Mock
mockFetch := Fetcher(func(url string) ([]byte, error) {
callCount++
return []byte("response from " + url), nil
})
cf := NewCachedFetcher(mockFetch)
// 第一次調用:應該調用 fetch
data, err := cf.Get("https://example.com")
if err != nil {
t.Fatal(err)
}
if string(data) != "response from https://example.com" {
t.Errorf("響應 = %s", string(data))
}
if callCount != 1 {
t.Errorf("fetch 被調用 %d 次; 期望 1", callCount)
}
// 第二次調用相同 URL:應該命中緩存,不調用 fetch
_, err = cf.Get("https://example.com")
if err != nil {
t.Fatal(err)
}
if callCount != 1 {
t.Errorf("fetch 被調用 %d 次; 期望仍為 1(緩存命中)", callCount)
}
// 不同 URL:應該再次調用 fetch
_, err = cf.Get("https://other.com")
if err != nil {
t.Fatal(err)
}
if callCount != 2 {
t.Errorf("fetch 被調用 %d 次; 期望 2", callCount)
}
}
8.3 接口設計的測試原則
package service
import "testing"
// 測試技巧:在消費者包中定義小接口
// 不要在提供者包中定義大接口
// 差的做法:定義一個大而全的接口
// type BigRepository interface {
// GetByID(id int) (*User, error)
// GetByEmail(email string) (*User, error)
// GetAll() ([]*User, error)
// Save(user *User) error
// Update(user *User) error
// Delete(id int) error
// Count() (int, error)
// ... 更多方法
// }
// 好的做法:按需定義小接口(接口隔離原則)
type UserGetter interface {
GetByID(id int) (*User, error)
}
type UserSaver interface {
Save(user *User) error
}
// 函數只依賴它真正需要的方法
func getUserName(getter UserGetter, id int) (string, error) {
user, err := getter.GetByID(id)
if err != nil {
return "", err
}
return user.Name, nil
}
// 測試時只需要 mock 用到的方法
type simpleGetter struct {
user *User
err error
}
func (g *simpleGetter) GetByID(id int) (*User, error) {
return g.user, g.err
}
func TestGetUserName(t *testing.T) {
// Mock 非常簡單,只需實現一個方法
getter := &simpleGetter{
user: &User{ID: 1, Name: "Alice"},
}
name, err := getUserName(getter, 1)
if err != nil {
t.Fatal(err)
}
if name != "Alice" {
t.Errorf("name = %s; want Alice", name)
}
}
9. 模糊測試 Fuzz
9.1 模糊測試基礎(Go 1.18+)
模糊測試(Fuzz Testing)自動生成大量隨機輸入來發現邊界情況和潛在的 bug。
package mymath
import (
"testing"
"unicode/utf8"
)
// Reverse 反轉字符串(按字符而非字節)
func Reverse(s string) string {
runes := []rune(s)
for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {
runes[i], runes[j] = runes[j], runes[i]
}
return string(runes)
}
// FuzzReverse 模糊測試函數以 Fuzz 開頭
func FuzzReverse(f *testing.F) {
// 添加種子語料庫(seed corpus)
// 模糊測試引擎會基於這些種子生成更多測試輸入
f.Add("hello")
f.Add("世界")
f.Add("")
f.Add("a")
f.Add("Hello, 世界! 🌍")
// f.Fuzz 接收的函數參數類型必須與 f.Add 的參數類型一致
f.Fuzz(func(t *testing.T, original string) {
// 對每個自動生成的輸入執行測試
// 屬性1: 反轉兩次應該得到原始字符串
reversed := Reverse(original)
doubleReversed := Reverse(reversed)
if original != doubleReversed {
t.Errorf("反轉兩次不等於原始值:\n原始: %q\n反轉: %q\n雙反: %q",
original, reversed, doubleReversed)
}
// 屬性2: 反轉後的字符串長度應該與原始相同
if utf8.RuneCountInString(original) != utf8.RuneCountInString(reversed) {
t.Errorf("長度不一致:\n原始: %q (len=%d)\n反轉: %q (len=%d)",
original, utf8.RuneCountInString(original),
reversed, utf8.RuneCountInString(reversed))
}
// 屬性3: 反轉後的字符串應該是有效的 UTF-8
if !utf8.ValidString(reversed) {
t.Errorf("反轉後不是有效的 UTF-8: %q", reversed)
}
})
}
運行模糊測試:
# 僅運行種子語料庫(不做模糊測試)
go test -run FuzzReverse
# 運行模糊測試(持續運行直到找到 bug 或被中斷)
go test -fuzz FuzzReverse
# 限制模糊測試運行時間
go test -fuzz FuzzReverse -fuzztime 30s
# 限制模糊測試迭代次數
go test -fuzz FuzzReverse -fuzztime 1000x
9.2 支持的參數類型
模糊測試的 f.Add 和 f.Fuzz 支持以下類型:
package mymath
import "testing"
// 所有支持的參數類型
func FuzzAllTypes(f *testing.F) {
f.Add(
"string", // string
[]byte("bytes"), // []byte
int(42), // int
int8(8), // int8
int16(16), // int16
int32(32), // int32
int64(64), // int64
uint(42), // uint
uint8(8), // uint8
uint16(16), // uint16
uint32(32), // uint32
uint64(64), // uint64
float32(3.14), // float32
float64(2.718), // float64
rune('A'), // rune (= int32)
byte(0xFF), // byte (= uint8)
true, // bool
)
f.Fuzz(func(t *testing.T,
s string, b []byte,
i int, i8 int8, i16 int16, i32 int32, i64 int64,
u uint, u8 uint8, u16 uint16, u32 uint32, u64 uint64,
f32 float32, f64 float64,
r rune, by byte, bl bool,
) {
// 使用自動生成的各類型值進行測試
_ = s
_ = b
})
}
9.3 實際應用:測試 JSON 解析
package mymath
import (
"encoding/json"
"testing"
)
// Config 應用配置
type Config struct {
Name string `json:"name"`
Port int `json:"port"`
Debug bool `json:"debug"`
}
// ParseConfig 解析配置 JSON
func ParseConfig(data []byte) (*Config, error) {
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func FuzzParseConfig(f *testing.F) {
// 種子:有效的 JSON
f.Add([]byte(`{"name":"app","port":8080,"debug":true}`))
f.Add([]byte(`{}`))
f.Add([]byte(`{"name":""}`))
// 種子:無效的輸入
f.Add([]byte(`invalid json`))
f.Add([]byte(``))
f.Add([]byte(`null`))
f.Fuzz(func(t *testing.T, data []byte) {
cfg, err := ParseConfig(data)
if err != nil {
// 解析失敗是正常的(對於隨機輸入)
// 關鍵是不能 panic
return
}
// 如果解析成功,重新序列化應該不 panic
reEncoded, err := json.Marshal(cfg)
if err != nil {
t.Errorf("重新編碼失敗: %v", err)
}
// 重新解析應該成功
var cfg2 Config
if err := json.Unmarshal(reEncoded, &cfg2); err != nil {
t.Errorf("重新解析失敗: %v", err)
}
// 屬性:name 字段應該保持一致
if cfg.Name != cfg2.Name {
t.Errorf("Name 不一致: %q vs %q", cfg.Name, cfg2.Name)
}
})
}
9.4 模糊測試發現 bug 後的工作流
# 1. 運行模糊測試
go test -fuzz FuzzReverse -fuzztime 1m
# 如果發現 bug,測試框架會將導致失敗的輸入保存到
# testdata/fuzz/FuzzReverse/ 目錄下
# 2. 查看失敗的輸入
cat testdata/fuzz/FuzzReverse/xxxxxxxxx
# 3. 修復 bug 後,再次運行測試
# 種子語料庫中保存的失敗用例會自動成為回歸測試
go test -run FuzzReverse
# 4. 繼續模糊測試確認修復
go test -fuzz FuzzReverse -fuzztime 1m
9.5 模糊測試實用示例:URL 解析
package mymath
import (
"net/url"
"testing"
)
func FuzzURLParse(f *testing.F) {
f.Add("https://example.com/path?query=value#fragment")
f.Add("http://localhost:8080")
f.Add("ftp://user:pass@host/dir")
f.Add("/relative/path")
f.Add("")
f.Fuzz(func(t *testing.T, rawURL string) {
// 解析 URL
parsed, err := url.Parse(rawURL)
if err != nil {
return // 無效 URL 是可接受的
}
// 屬性:重新編碼後再解析應該得到相同結果
reEncoded := parsed.String()
reParsed, err := url.Parse(reEncoded)
if err != nil {
t.Errorf("重新編碼的 URL 無法解析: %q -> %q -> error: %v",
rawURL, reEncoded, err)
return
}
// 關鍵字段應該一致
if parsed.Scheme != reParsed.Scheme {
t.Errorf("Scheme 不一致: %q vs %q", parsed.Scheme, reParsed.Scheme)
}
if parsed.Host != reParsed.Host {
t.Errorf("Host 不一致: %q vs %q", parsed.Host, reParsed.Host)
}
if parsed.Path != reParsed.Path {
t.Errorf("Path 不一致: %q vs %q", parsed.Path, reParsed.Path)
}
})
}
總結
| 功能 | 核心用法 | 運行命令 |
|---|---|---|
| 單元測試 | func TestXxx(t *testing.T) |
go test -v |
| 表格驅動 | tests := []struct{...} + t.Run |
go test -run TestXxx |
| 子測試 | t.Run("name", func(t *testing.T){...}) |
go test -run TestXxx/sub |
| 並行測試 | t.Parallel() |
go test -parallel 4 |
| 基準測試 | func BenchmarkXxx(b *testing.B) |
go test -bench=. |
| 內存分析 | b.ReportAllocs() |
go test -bench=. -benchmem |
| 覆蓋率 | -cover / -coverprofile |
go test -cover |
| TestMain | func TestMain(m *testing.M) |
自動調用 |
| HTTP 測試 | httptest.NewRequest + httptest.NewRecorder |
go test -v |
| 測試服務器 | httptest.NewServer(handler) |
go test -v |
| Mock | 定義接口 + 實現 Mock 結構體 | go test -v |
| 模糊測試 | func FuzzXxx(f *testing.F) |
go test -fuzz FuzzXxx |
常用命令速查
# 運行所有測試
go test ./...
# 詳細輸出
go test -v ./...
# 跳過慢測試
go test -short ./...
# 檢測數據競爭
go test -race ./...
# 覆蓋率報告
go test -coverprofile=c.out ./... && go tool cover -html=c.out
# 基準測試
go test -bench=. -benchmem ./...
# 模糊測試(30秒)
go test -fuzz=FuzzXxx -fuzztime=30s
# 運行特定測試
go test -run "TestUser/創建" -v
主題測試文章,只做測試使用。發佈者:Walker,轉轉請注明出處:https://walker-learn.xyz/archives/6723