本章節涵蓋 Go 語言的測試框架、基準測試、效能分析工具以及程式碼覆蓋率分析。
測試基礎#
測試檔案規範#
Go 的測試框架有嚴格的命名規範:
mypackage/
├── calculator.go # 原始碼
├── calculator_test.go # 測試檔案(必須以 _test.go 結尾)
└── helper_test.go # 測試輔助檔案測試檔案規則:
- 檔名必須以
_test.go結尾- 測試檔案會被
go build忽略,只有go test會編譯- 測試檔案可以存取同套件的私有成員
測試函式類型#
import "testing"
// 功能測試:以 Test 開頭
func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d; want 5", result)
}
}
// 基準測試:以 Benchmark 開頭
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
// 範例測試:以 Example 開頭
func ExampleAdd() {
fmt.Println(Add(2, 3))
// Output: 5
}測試函式命名#
// 測試函式命名規則:Test + 被測函式名 + 描述
func TestCalculator_Add(t *testing.T) { ... }
func TestCalculator_Add_NegativeNumbers(t *testing.T) { ... }
func TestCalculator_Add_Overflow(t *testing.T) { ... }
// 範例函式命名規則
func ExampleCalculator() { ... } // 套件範例
func ExampleCalculator_Add() { ... } // 方法範例
func Example_helper() { ... } // 私有函式範例功能測試#
基本測試模式#
func TestDivide(t *testing.T) {
result, err := Divide(10, 2)
// 使用 t.Error/t.Errorf 報告錯誤(繼續執行)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
if result != 5 {
t.Errorf("Divide(10, 2) = %f; want 5", result)
}
// 使用 t.Fatal/t.Fatalf 報告致命錯誤(停止執行)
_, err = Divide(10, 0)
if err == nil {
t.Fatal("expected error for division by zero")
}
}表格驅動測試#
這是 Go 社群推薦的測試模式:
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 2, 3, 5},
{"negative numbers", -2, -3, -5},
{"mixed numbers", -2, 3, 1},
{"zeros", 0, 0, 0},
}
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; want %d",
tt.a, tt.b, result, tt.expected)
}
})
}
}使用
t.Run()建立子測試有以下優點:
- 每個測試案例獨立執行
- 可以個別執行特定案例
- 錯誤訊息更清晰
測試輔助函式#
// 使用 t.Helper() 標記輔助函式
func assertEqual(t *testing.T, got, want int) {
t.Helper() // 讓錯誤報告指向呼叫者
if got != want {
t.Errorf("got %d; want %d", got, want)
}
}
func TestWithHelper(t *testing.T) {
result := Add(2, 3)
assertEqual(t, result, 5) // 錯誤會報告這一行
}測試設定與清理#
func TestMain(m *testing.M) {
// 全域設定(所有測試前執行一次)
setup()
code := m.Run() // 執行所有測試
// 全域清理(所有測試後執行一次)
teardown()
os.Exit(code)
}
func TestWithCleanup(t *testing.T) {
// 單一測試的清理
t.Cleanup(func() {
// 測試結束時執行
cleanup()
})
// 測試邏輯
}建立臨時測試目錄
func TestFileOperations(t *testing.T) {
// 建立臨時目錄(測試結束後自動刪除)
tempDir := t.TempDir()
// 在臨時目錄中建立測試檔案
testFile := filepath.Join(tempDir, "test.txt")
os.WriteFile(testFile, []byte("test content"), 0644)
// 測試檔案操作
content, err := os.ReadFile(testFile)
if err != nil {
t.Fatal(err)
}
if string(content) != "test content" {
t.Error("content mismatch")
}
}基準測試#
基準測試基礎#
func BenchmarkFibonacci(b *testing.B) {
// b.N 由測試框架動態決定
// 會自動調整直到執行時間穩定
for i := 0; i < b.N; i++ {
Fibonacci(20)
}
}執行基準測試:
# 執行所有基準測試
go test -bench=.
# 只執行特定基準測試
go test -bench=BenchmarkFibonacci
# 設定執行時間
go test -bench=. -benchtime=5s
# 包含記憶體分配統計
go test -bench=. -benchmem基準測試輸出#
BenchmarkFibonacci-8 1000000 1052 ns/op 0 B/op 0 allocs/op
│ │ │ │ │
│ │ │ │ └─ 每次操作的記憶體組態次數
│ │ │ └─ 每次操作組態的位元組數
│ │ └─ 每次操作的奈秒數
│ └─ 執行次數
└─ CPU 核心數進階基準測試#
// 重置計時器(排除設定時間)
func BenchmarkWithSetup(b *testing.B) {
data := generateLargeData() // 設定
b.ResetTimer() // 重置計時器
for i := 0; i < b.N; i++ {
processData(data)
}
}
// 停止/開始計時器
func BenchmarkWithPause(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer()
input := prepareInput() // 不計入
b.StartTimer()
process(input) // 只計這部分
}
}
// 報告自訂指標
func BenchmarkThroughput(b *testing.B) {
data := make([]byte, 1024)
b.SetBytes(1024) // 設定每次操作處理的位元組數
for i := 0; i < b.N; i++ {
processBytes(data)
}
}
// 輸出會包含 MB/s 指標並行基準測試#
func BenchmarkParallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
// 每個 goroutine 執行
for pb.Next() {
ConcurrentOperation()
}
})
}
RunParallel會根據GOMAXPROCS啟動對應數量的 goroutine。這對測試併發程式碼的效能特別有用。
效能分析(pprof)#
概要檔案類型#
Go 提供三種主要的概要檔案:
| 類型 | 說明 | 用途 |
|---|---|---|
| CPU Profile | CPU 使用情況 | 找出耗時的函式 |
| Mem Profile | 記憶體組態 | 找出記憶體使用熱點 |
| Block Profile | 阻塞操作 | 找出同步瓶頸 |
CPU 概要分析#
import "runtime/pprof"
func main() {
// 建立 CPU 概要檔案
f, _ := os.Create("cpu.prof")
defer f.Close()
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
// 執行要分析的程式碼
doWork()
}
StartCPUProfile會以 100 Hz 的頻率採樣(每 10 毫秒採樣一次)。這個頻率是固定的,無法調整。
記憶體概要分析#
import "runtime/pprof"
func main() {
// 執行要分析的程式碼
doWork()
// 建立記憶體概要檔案
f, _ := os.Create("mem.prof")
defer f.Close()
runtime.GC() // 先執行 GC 取得更準確的資料
pprof.WriteHeapProfile(f)
}使用 go test 產生概要檔案#
# CPU 分析
go test -cpuprofile cpu.prof -bench .
# 記憶體分析
go test -memprofile mem.prof -bench .
# 阻塞分析
go test -blockprofile block.prof -bench .
# 同時產生多個
go test -cpuprofile cpu.prof -memprofile mem.prof -bench .分析概要檔案#
# 進入互動式介面
go tool pprof cpu.prof
# 常用命令
(pprof) top # 顯示最耗時的函式
(pprof) top10 # 顯示前 10 名
(pprof) list main # 顯示特定函式的程式碼
(pprof) web # 在瀏覽器中顯示視覺化圖表
# 直接輸出
go tool pprof -top cpu.prof
go tool pprof -text cpu.prof
go tool pprof -svg cpu.prof > cpu.svg
go tool pprof -http=:8080 cpu.prof # 開啟 Web 介面pprof Web 介面功能
使用 go tool pprof -http=:8080 cpu.prof 開啟 Web 介面後,可以存取:
/- 圖形化呼叫圖/flamegraph- 火焰圖/top- 排行榜/source- 原始碼分析/disasm- 反組譯分析
火焰圖特別適合快速找出效能瓶頸:
- X 軸:函式在取樣中出現的比例
- Y 軸:呼叫堆疊深度
- 越寬的區塊表示該函式佔用越多 CPU 時間
HTTP 服務的即時分析#
import (
"net/http"
_ "net/http/pprof" // 自動註冊 pprof 路由
)
func main() {
go func() {
// 開啟 pprof 端點
http.ListenAndServe(":6060", nil)
}()
// 主程式邏輯
startServer()
}存取 pprof 端點:
# 30 秒 CPU 分析
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# 記憶體分析
go tool pprof http://localhost:6060/debug/pprof/heap
# goroutine 分析
go tool pprof http://localhost:6060/debug/pprof/goroutine
# 阻塞分析
go tool pprof http://localhost:6060/debug/pprof/block在生產環境中啟用 pprof 時,應該:
- 使用獨立的埠口(非對外埠口)
- 限制存取(防火牆、認證)
- 考慮效能影響
程式碼覆蓋率#
產生覆蓋率報告#
# 執行測試並產生覆蓋率資料
go test -cover ./...
# 輸出覆蓋率資料到檔案
go test -coverprofile=coverage.out ./...
# 查看覆蓋率統計
go tool cover -func=coverage.out
# 產生 HTML 報告
go tool cover -html=coverage.out -o coverage.html覆蓋率輸出範例#
ok mypackage 0.123s coverage: 78.5% of statements
--- coverage.out ---
mypackage/calculator.go:10: Add 100.0%
mypackage/calculator.go:15: Subtract 100.0%
mypackage/calculator.go:20: Multiply 80.0%
mypackage/calculator.go:30: Divide 50.0%
total: (statements) 78.5%覆蓋模式#
# set: 是否被執行(預設)
go test -covermode=set -coverprofile=coverage.out
# count: 執行次數
go test -covermode=count -coverprofile=coverage.out
# atomic: 原子計數(用於併發測試)
go test -covermode=atomic -coverprofile=coverage.out整合 CI/CD 的覆蓋率檢查
#!/bin/bash
# 產生覆蓋率報告
go test -coverprofile=coverage.out ./...
# 計算總覆蓋率
COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}' | sed 's/%//')
# 檢查是否達標
THRESHOLD=80
if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )); then
echo "覆蓋率 ${COVERAGE}% 低於門檻 ${THRESHOLD}%"
exit 1
fi
echo "覆蓋率檢查通過: ${COVERAGE}%"測試最佳實務#
測試隔離#
// 不好:測試間有依賴
var globalCounter int
func TestA(t *testing.T) {
globalCounter = 10
// ...
}
func TestB(t *testing.T) {
// 依賴 TestA 設定的值
if globalCounter != 10 { ... } // 可能失敗
}
// 好:每個測試獨立
func TestA(t *testing.T) {
counter := 10
// ...
}
func TestB(t *testing.T) {
counter := 10 // 獨立設定
// ...
}Mock 與 Stub#
// 定義介面
type EmailSender interface {
Send(to, subject, body string) error
}
// 實際實作
type SMTPSender struct { ... }
// 測試用 Mock
type MockSender struct {
SentEmails []Email
}
func (m *MockSender) Send(to, subject, body string) error {
m.SentEmails = append(m.SentEmails, Email{to, subject, body})
return nil
}
// 測試
func TestNotification(t *testing.T) {
mock := &MockSender{}
service := NewNotificationService(mock)
service.NotifyUser("user@example.com", "Hello")
if len(mock.SentEmails) != 1 {
t.Error("expected 1 email sent")
}
}避免測試污染#
// 使用 t.Parallel() 標記可並行的測試
func TestParallel1(t *testing.T) {
t.Parallel() // 可以與其他 Parallel 測試同時執行
// ...
}
func TestParallel2(t *testing.T) {
t.Parallel()
// ...
}
// 注意:並行測試不能共用可變狀態測試命名建議:
- 使用描述性名稱,讓測試失敗時能立即理解問題
- 遵循
Test<Function>_<Scenario>_<Expected>模式- 例如:
TestDivide_ByZero_ReturnsError
測試錯誤路徑#
func TestErrorHandling(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
errContains string
}{
{"empty input", "", true, "cannot be empty"},
{"invalid format", "abc", true, "invalid format"},
{"valid input", "123", false, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Parse(tt.input)
if tt.wantErr {
if err == nil {
t.Error("expected error, got nil")
} else if !strings.Contains(err.Error(), tt.errContains) {
t.Errorf("error %q should contain %q",
err.Error(), tt.errContains)
}
} else if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
}