本章節涵蓋 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() 建立子測試有以下優點:

  1. 每個測試案例獨立執行
  2. 可以個別執行特定案例
  3. 錯誤訊息更清晰

測試輔助函式#

// 使用 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 ProfileCPU 使用情況找出耗時的函式
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 時,應該:

  1. 使用獨立的埠口(非對外埠口)
  2. 限制存取(防火牆、認證)
  3. 考慮效能影響

程式碼覆蓋率#

產生覆蓋率報告#

# 執行測試並產生覆蓋率資料
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)
            }
        })
    }
}