Go 測試與基準完全指南

Go testing 完整指南 — t.Run subtests、table-driven、Benchmarks、coverage、TestMain、testify、mock


目錄


什麼是 Go 測試?

Go 內建測試框架 testing無需第三方 framework 就能寫單元測試、基準測試、模糊測試。

核心特點

  • 🎯 約定優於配置_test.go 結尾的檔案、TestXxx 函式
  • 直接是 Go 程式:不用 DSL,普通 Go code 寫測試
  • 🔧 內建工具:覆蓋率、benchmark、race detector、fuzzing 全內建
  • 📦 與 build 整合go test ./... 一鍵跑全部
  • 🚀 平行化t.Parallel() 讓 test 跨 CPU 跑

跟其他語言對照

概念 Go Java (JUnit) JavaScript (Jest)
測試框架 內建 testing JUnit Jest
斷言 自己 t.Errorf assertEquals expect().toBe()
分組 t.Run subtests @Nested describe
Mock 介面 + 手刻 / mockgen Mockito jest.mock
覆蓋率 go test -cover JaCoCo --coverage
基準 內建 Benchmark* JMH benchmark.js

基本測試結構

檔案位置與命名

mypackage/
├── calc.go         # 實作
└── calc_test.go    # 測試

兩個檔案在同一套件,測試可以存取私有成員。

最簡測試

// calc.go
package calc

func Add(a, b int) int {
    return a + b
}
// calc_test.go
package calc

import "testing"

func TestAdd(t *testing.T) {
    got := Add(2, 3)
    want := 5
    if got != want {
        t.Errorf("Add(2, 3) = %d, want %d", got, want)
    }
}

執行:

go test ./...
# PASS
# ok  myapp/calc  0.001s

*testing.T 主要方法

方法 用途
t.Errorf(format, args...) 標示失敗但繼續執行
t.Fatalf(format, args...) 標示失敗並立刻結束本測試
t.Log(args...) / t.Logf 印訊息(預設只在失敗時顯示,加 -v 全印)
t.Skip(args...) / t.Skipf 跳過本測試
t.Helper() 標記為 helper(錯誤訊息會印呼叫端的行號)
t.Cleanup(fn) 註冊清理函式
t.Parallel() 標示可並行執行
t.Run(name, fn) 開 subtest

Errorf vs Fatalf

func TestSomething(t *testing.T) {
    val, err := doSomething()

    if err != nil {
        t.Fatalf("doSomething: %v", err) // 失敗就停(後面用不到 val)
    }

    // 多個獨立檢查用 Errorf(繼續看其他問題)
    if val.A != 1 {
        t.Errorf("A: got %d, want 1", val.A)
    }
    if val.B != 2 {
        t.Errorf("B: got %d, want 2", val.B)
    }
}

跑特定 test

# 跑全部
go test ./...

# 詳細輸出
go test -v ./...

# 跑特定測試(regex match)
go test -run TestAdd ./...

# 跑特定子測試
go test -run TestAdd/positive ./...

# 不快取
go test -count=1 ./...

# 顯示時間
go test -v ./... | grep -E "PASS|FAIL"

Table-driven 測試

Go 圈最標準的測試模式:用一個 slice 列出所有測試案例,迭代執行

基本範本

func TestAdd(t *testing.T) {
    tests := []struct {
        name    string
        a, b    int
        want    int
    }{
        {"正數", 2, 3, 5},
        {"含零", 0, 5, 5},
        {"負數", -1, -2, -3},
        {"正負相加", -5, 5, 0},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Add(tt.a, tt.b)
            if got != tt.want {
                t.Errorf("Add(%d, %d) = %d, want %d",
                    tt.a, tt.b, got, tt.want)
            }
        })
    }
}

進階:帶 error 的版本

func TestDivide(t *testing.T) {
    tests := []struct {
        name    string
        a, b    int
        want    int
        wantErr bool
    }{
        {"正常", 10, 2, 5, false},
        {"除以零", 10, 0, 0, true},
        {"負數", -10, 2, -5, false},
    }

    for _, tt := range tests {
        t.Run(tt.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("Divide(%d, %d) = %d, want %d",
                    tt.a, tt.b, got, tt.want)
            }
        })
    }
}

用 map 取代 slice(讓 name 自動就是 key)

tests := map[string]struct {
    a, b int
    want int
}{
    "正數": {2, 3, 5},
    "含零": {0, 5, 5},
    "負數": {-1, -2, -3},
}

for name, tt := range tests {
    t.Run(name, func(t *testing.T) {
        // ...
    })
}

注意:map 迭代順序隨機,如果測試有隱性順序依賴會出問題(通常不該有)。


Subtests 與 t.Run

t.Run 把測試切成子層級,每個子測試有獨立的 fail/skip 狀態。

名字含特殊字元的處理

Go 會把 subtest 的名字 normalize(空格變底線等):

t.Run("正常 case", ...) // 內部變成 "正常_case"

跑特定子測試:

go test -run "TestDivide/正常" ./...
go test -run "TestDivide/除以零" ./...

巢狀 subtest

func TestNested(t *testing.T) {
    t.Run("level1", func(t *testing.T) {
        t.Run("level2-a", func(t *testing.T) { ... })
        t.Run("level2-b", func(t *testing.T) { ... })
    })
}

跑:

go test -run TestNested/level1/level2-a

Parallel 平行化

func TestParallel(t *testing.T) {
    t.Parallel() // 標示這個測試可以跟其他 parallel 測試並行
    // ...
}

table-driven 內 parallel 的陷阱:

for _, tt := range tests {
    tt := tt // ⚠️ Go 1.21 之前必須這行(捕獲 loop variable)
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // ... 用 tt
    })
}

Go 1.22+ 修了 for-loop variable 語意,可省略 tt := tt。但為了 1.21 相容性建議還是寫。


Test Helpers 與 t.Helper

測試輔助函式時呼叫 t.Helper(),失敗訊息會印呼叫端行號(而非 helper 內行號)。

沒呼叫 t.Helper 的問題

func assertEqual(t *testing.T, got, want int) {
    if got != want {
        t.Errorf("got %d, want %d", got, want)
        // 失敗訊息會印這行的位置,不是測試實際失敗的呼叫位置
    }
}

加上 t.Helper

func assertEqual(t *testing.T, got, want int) {
    t.Helper() // ⭐ 告訴 framework 這是 helper
    if got != want {
        t.Errorf("got %d, want %d", got, want)
        // 失敗訊息會印呼叫 assertEqual 的那一行
    }
}

func TestSomething(t *testing.T) {
    val := compute()
    assertEqual(t, val, 42) // 失敗時這行被指出
}

Setup / Teardown 與 TestMain

單一測試的清理

func TestWithCleanup(t *testing.T) {
    f, err := os.CreateTemp("", "test")
    if err != nil {
        t.Fatal(err)
    }
    t.Cleanup(func() { // 註冊清理
        os.Remove(f.Name())
        f.Close()
    })

    // ... 用 f
}

t.Cleanup 是 LIFO,可註冊多個。比 defer 好的地方:在 helper 內也能註冊。

TestMain — 整個套件的 setup/teardown

func TestMain(m *testing.M) {
    // setup
    setupDB()

    // 跑所有 Test 函式
    code := m.Run()

    // teardown
    teardownDB()

    os.Exit(code) // 必須用 os.Exit
}

每個 package 最多一個 TestMain,會在所有 Test* 之前/之後執行。

t.TempDir 自動清理 temp dir

func TestFiles(t *testing.T) {
    dir := t.TempDir() // 測試結束自動刪
    err := os.WriteFile(filepath.Join(dir, "a.txt"), []byte("data"), 0o644)
    // ...
}

Benchmarks 基準測試

go test 也支援基準測試,函式以 Benchmark 開頭。

基本基準

func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(2, 3)
    }
}

執行:

go test -bench=. -benchmem ./...

輸出:

BenchmarkAdd-8    1000000000    0.30 ns/op    0 B/op    0 allocs/op
欄位 意義
BenchmarkAdd-8 名稱 + GOMAXPROCS=8
1000000000 迭代次數(Go 自動調整到統計穩定)
0.30 ns/op 每次操作平均 nanosecond
0 B/op 每次操作 allocate 多少 byte
0 allocs/op 每次操作幾次 allocation

b.N 是什麼?

b.N 不是固定值,Go 會反覆執行直到時間穩定(預設 1 秒)。你的程式碼必須能跑 N 次

func BenchmarkParse(b *testing.B) {
    input := []byte(`{"name":"alice"}`)
    for i := 0; i < b.N; i++ {
        Parse(input)
    }
}

b.ResetTimer 排除 setup 時間

func BenchmarkSearchBig(b *testing.B) {
    data := generateBigData() // 不該計入 benchmark 時間

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        Search(data, "key")
    }
}

Sub-benchmarks

func BenchmarkHash(b *testing.B) {
    inputs := []struct {
        name string
        data []byte
    }{
        {"small", make([]byte, 100)},
        {"medium", make([]byte, 10000)},
        {"large", make([]byte, 1000000)},
    }

    for _, in := range inputs {
        b.Run(in.name, func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                Hash(in.data)
            }
        })
    }
}

輸出對照各 size 的效能:

BenchmarkHash/small-8     ...
BenchmarkHash/medium-8    ...
BenchmarkHash/large-8     ...

比較 benchmark:benchstat

# 跑兩次(改 code 前後)
go test -bench=. -count=5 > old.txt
# 改 code 後
go test -bench=. -count=5 > new.txt

# 比較
go install golang.org/x/perf/cmd/benchstat@latest
benchstat old.txt new.txt

Coverage 覆蓋率

# 顯示覆蓋率
go test -cover ./...

# 輸出 coverage profile
go test -coverprofile=coverage.out ./...

# HTML 報告
go tool cover -html=coverage.out

# 命令列文字報告
go tool cover -func=coverage.out

Coverage mode

# 預設 set:每行至少一次
go test -covermode=set ./...

# count:每行幾次(給 hot path 分析)
go test -covermode=count ./...

# atomic:count 但是 thread-safe(給平行測試)
go test -covermode=atomic ./...

涵蓋多套件

go test -coverprofile=coverage.out -coverpkg=./... ./...

-coverpkg 控制哪些套件被計入。


Fuzzing 模糊測試

Go 1.18+ 內建 fuzzing — 自動產生隨機輸入找 edge case 與 panic。

基本範例

func FuzzParse(f *testing.F) {
    // seed corpus
    f.Add("a=1")
    f.Add("a=1&b=2")
    f.Add("")

    f.Fuzz(func(t *testing.T, input string) {
        result, err := Parse(input)
        if err != nil {
            return // 預期可能失敗
        }
        // 驗證不變式
        if result == nil {
            t.Errorf("Parse %q 回 nil result 但沒 error", input)
        }
    })
}

執行:

# 跑 1 分鐘
go test -fuzz=FuzzParse -fuzztime=1m ./...

如果發現 input 讓 Fuzz 內的測試失敗,Go 會把該 input 存到 testdata/fuzz/FuzzParse/<hash>,之後變成 regression test。


Mock 與 Stub 模式

用介面手刻 mock(推薦)

// 業務碼依賴介面
type UserRepo interface {
    Get(id string) (*User, error)
}

type Service struct {
    repo UserRepo
}

func (s *Service) GetName(id string) (string, error) {
    u, err := s.repo.Get(id)
    if err != nil {
        return "", err
    }
    return u.Name, nil
}

// 測試
type mockUserRepo struct {
    user *User
    err  error
}

func (m *mockUserRepo) Get(id string) (*User, error) {
    return m.user, m.err
}

func TestService_GetName(t *testing.T) {
    s := &Service{
        repo: &mockUserRepo{
            user: &User{Name: "Alice"},
        },
    }

    name, err := s.GetName("user-1")
    if err != nil {
        t.Fatal(err)
    }
    if name != "Alice" {
        t.Errorf("got %q, want Alice", name)
    }
}

用 gomock 自動產生

go install go.uber.org/mock/mockgen@latest

# 從介面產生 mock
mockgen -source=user_repo.go -destination=mocks/user_repo.go
import "go.uber.org/mock/gomock"

func TestService(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()

    mockRepo := mocks.NewMockUserRepo(ctrl)
    mockRepo.EXPECT().Get("user-1").Return(&User{Name: "Alice"}, nil)

    s := &Service{repo: mockRepo}
    name, _ := s.GetName("user-1")
    // ...
}

testify 套件(社群常用)

go get github.com/stretchr/testify
import (
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

func TestSomething(t *testing.T) {
    val, err := compute()

    require.NoError(t, err) // 像 t.Fatalf,會立刻停
    assert.Equal(t, 42, val) // 像 t.Errorf

    assert.Contains(t, []int{1, 2, 3}, 2)
    assert.Len(t, items, 3)
    assert.True(t, condition)
}
行為
assert.* 失敗 → 印錯但繼續(等於 t.Errorf
require.* 失敗 → 立刻停(等於 t.Fatalf

實戰範例

範例 1:完整 table-driven + helper

package strutil

import (
    "strings"
    "testing"
)

func ReverseString(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)
}

func TestReverseString(t *testing.T) {
    tests := map[string]struct {
        input string
        want  string
    }{
        "空字串":     {"", ""},
        "單字元":     {"a", "a"},
        "英文":      {"hello", "olleh"},
        "中文":      {"你好世界", "界世好你"},
        "混合 emoji": {"go😀", "😀og"},
        "回文":      {"racecar", "racecar"},
    }

    for name, tt := range tests {
        t.Run(name, func(t *testing.T) {
            got := ReverseString(tt.input)
            if got != tt.want {
                t.Errorf("ReverseString(%q) = %q, want %q",
                    tt.input, got, tt.want)
            }
        })
    }
}

// 確保跟 strings.Reverse 行為一致(如果有的話)
func TestReverseString_Property(t *testing.T) {
    // Reverse 兩次 = 原字串
    inputs := []string{"hello", "你好", "go😀"}
    for _, s := range inputs {
        t.Run(s, func(t *testing.T) {
            got := ReverseString(ReverseString(s))
            if got != s {
                t.Errorf("double reverse 不一致: got %q, want %q", got, s)
            }
        })
    }
    _ = strings.Builder{} // 避免 strings unused
}

範例 2:HTTP handler 測試

package handler

import (
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"
)

type Response struct {
    Status string `json:"status"`
}

func HealthHandler(w http.ResponseWriter, r *http.Request) {
    json.NewEncoder(w).Encode(Response{Status: "ok"})
}

func TestHealthHandler(t *testing.T) {
    req := httptest.NewRequest("GET", "/health", nil)
    w := httptest.NewRecorder()

    HealthHandler(w, req)

    res := w.Result()
    if res.StatusCode != http.StatusOK {
        t.Errorf("status = %d, want 200", res.StatusCode)
    }

    var body Response
    if err := json.NewDecoder(res.Body).Decode(&body); err != nil {
        t.Fatal(err)
    }
    if body.Status != "ok" {
        t.Errorf("status = %q, want %q", body.Status, "ok")
    }
}

範例 3:用 t.TempDir 測檔案 IO

package config

import (
    "os"
    "path/filepath"
    "testing"
)

func TestLoad(t *testing.T) {
    dir := t.TempDir()
    cfgPath := filepath.Join(dir, "config.json")

    err := os.WriteFile(cfgPath, []byte(`{"port":8080}`), 0o644)
    if err != nil {
        t.Fatal(err)
    }

    cfg, err := Load(cfgPath)
    if err != nil {
        t.Fatal(err)
    }
    if cfg.Port != 8080 {
        t.Errorf("port = %d, want 8080", cfg.Port)
    }
}

範例 4:Benchmark with sub-benchmarks

func BenchmarkJSON(b *testing.B) {
    sizes := []int{10, 100, 1000, 10000}
    for _, n := range sizes {
        b.Run(fmt.Sprintf("size=%d", n), func(b *testing.B) {
            data := generateData(n)
            b.ResetTimer()
            for i := 0; i < b.N; i++ {
                _, _ = json.Marshal(data)
            }
        })
    }
}

最佳實踐

1. 預設 table-driven

// ✅ 一個 Test 函式涵蓋所有 case
func TestAdd(t *testing.T) {
    tests := []struct{ ... }{...}
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) { ... })
    }
}

// ❌ 為每個 case 寫一個 Test 函式
func TestAdd_Positive(t *testing.T) { ... }
func TestAdd_Negative(t *testing.T) { ... }

2. 用「got, want」變數名

// ✅ Go 社群慣例
got := Add(2, 3)
want := 5
if got != want {
    t.Errorf("Add(2, 3) = %d, want %d", got, want)
}

// ❌ 不一致
result := Add(2, 3)
expected := 5

3. 失敗訊息含足夠 context

// ❌ 看不出哪個 case 壞了
t.Errorf("FAILED")

// ✅ 帶輸入跟期望
t.Errorf("Add(%d, %d) = %d, want %d", a, b, got, want)

4. 用 t.Fatal 跟 t.Error 分清

val, err := compute()
if err != nil {
    t.Fatalf("compute: %v", err) // val 不能用,停
}

// 多個獨立檢查
if val.A != expected.A {
    t.Errorf("A: got %v, want %v", val.A, expected.A)
}
if val.B != expected.B {
    t.Errorf("B: got %v, want %v", val.B, expected.B) // 還要看 B
}

5. Helper 加 t.Helper

func mustOpen(t *testing.T, path string) *os.File {
    t.Helper()
    f, err := os.Open(path)
    if err != nil {
        t.Fatalf("open %s: %v", path, err)
    }
    return f
}

6. 平行測試用 t.Parallel

func TestSlowThing(t *testing.T) {
    t.Parallel() // 跨 CPU 加速
    // ...
}

共享狀態的測試別 parallel(會 race)。

7. 介面隔離方便 mock

// ✅ 業務碼依賴小介面
type Clock interface {
    Now() time.Time
}

type Service struct {
    clock Clock
}

// 測試用假 clock
type fakeClock struct{ t time.Time }
func (f fakeClock) Now() time.Time { return f.t }

常見問題

問題 1:測試結果被快取

症狀:改了 code 但 go test 直接印 (cached),不重新跑

原因:Go 會 cache 通過的測試。如果 source 跟 input 都沒變,直接用快取

解決

go test -count=1 ./...
# 或清掉
go clean -testcache

問題 2:table-driven + parallel 全測同一 case

症狀:每個 subtest 用到的都是迴圈最後一個 tt

原因:Go 1.21 以前的 for-loop variable 是共享的

解決

for _, tt := range tests {
    tt := tt // 重新宣告
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        // ... 用 tt
    })
}

Go 1.22+ 不用這行。

問題 3:benchmark 結果不穩

檢查

  1. -count=5 跑多次,看變異
  2. 關閉背景程式(瀏覽器、IDE)
  3. benchstat 比較統計顯著性
  4. 接電源、設電源計畫為 Performance

問題 4:覆蓋率算到不該算的

症狀:generated code(protobuf、mock)拉低覆蓋率

解決

  • 用 build tag 排除
  • -coverpkg 指定要算的套件
go test -coverpkg=./internal/... ./...

問題 5:測試 panic 沒被 test framework 攔截

症狀:測試 panic 直接讓整個 test run 死,後面 test 沒跑

func TestSomething(t *testing.T) {
    var p *int
    fmt.Println(*p) // nil deref panic
}

預期:framework 會把 panic 標為 FAIL 並繼續其他 test 現象:通常 framework 會捕捉,但是 goroutine 內的 panic 不會被自動 recover

解決:goroutine 內加 recover

go func() {
    defer func() {
        if r := recover(); r != nil {
            t.Errorf("goroutine panic: %v", r)
        }
    }()
    // ...
}()

問題 6:mock 跑出來的呼叫順序不一致

症狀:mock 設定 EXPECT().X().Then(Y()) 但 fail

檢查

  • gomock 預設不檢查順序,要顯式 gomock.InOrder(...)
  • assert mock 的 EXPECT 是否真的呼到了(ctrl.Finish() 會檢查)

總結

核心要點

測試結構 = _test.go + TestXxx + table-driven + t.Run
工具 = go test (-v -cover -race -bench -fuzz)
模式 = 介面隔離 + 手刻 mock 或 gomock + 必要時用 testify

設計原則

  • ✅ 預設 table-driven
  • ✅ 失敗訊息含 input + got + want
  • ✅ Helper 加 t.Helper()
  • ✅ 用 t.TempDir / t.Cleanup 自動清理
  • ✅ Benchmark 用 -count=5 跟 benchstat 比較

速查表

# 跑測試
go test ./...                    # 跑全部
go test -v ./pkg                 # 詳細
go test -run TestX ./pkg         # 特定
go test -run TestX/sub ./pkg     # 特定 sub
go test -count=1 ./...           # 不快取
go test -race ./...              # race detector

# 覆蓋率
go test -cover ./...
go test -coverprofile=c.out ./...
go tool cover -html=c.out

# Benchmark
go test -bench=. -benchmem ./pkg
go test -bench=Name -count=5 -benchtime=3s ./pkg

# Fuzzing
go test -fuzz=FuzzX -fuzztime=1m ./pkg

測試函式形式速查

// 一般測試
func TestXxx(t *testing.T)

// 範例(也算測試,會驗證輸出)
func ExampleAdd() {
    fmt.Println(Add(1, 2))
    // Output: 3
}

// 基準
func BenchmarkXxx(b *testing.B)

// Fuzzing
func FuzzXxx(f *testing.F)

// 套件 setup/teardown
func TestMain(m *testing.M)

相關閱讀


參考資源


建立日期:2026-05-16 最後更新:2026-05-16

🔗相關文章