目錄
- 什麼是 Go 測試?
- 基本測試結構
- Table-driven 測試
- Subtests 與 t.Run
- Test Helpers 與 t.Helper
- Setup / Teardown 與 TestMain
- Benchmarks 基準測試
- Coverage 覆蓋率
- Fuzzing 模糊測試
- Mock 與 Stub 模式
- 實戰範例
- 最佳實踐
- 常見問題
- 總結
- 參考資源
什麼是 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 結果不穩
檢查:
- 用
-count=5跑多次,看變異 - 關閉背景程式(瀏覽器、IDE)
- 用
benchstat比較統計顯著性 - 接電源、設電源計畫為 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)
相關閱讀
- Go 錯誤處理 — 怎麼測試 error 路徑
- Go 型別與介面 — 介面用於 mock
- Go Context — 測試含 context 的函式
- Go 效能與除錯 — Benchmark 配合 pprof 找熱點
- Go 建置與發佈 — CI 整合測試
參考資源
- 官方 testing 套件:https://pkg.go.dev/testing
- Go Wiki - TableDrivenTests:https://go.dev/wiki/TableDrivenTests
- Fuzzing 教學:https://go.dev/security/fuzz/
- testify:https://github.com/stretchr/testify
- gomock:https://github.com/uber-go/mock
- benchstat:https://pkg.go.dev/golang.org/x/perf/cmd/benchstat
建立日期:2026-05-16 最後更新:2026-05-16