Go 的测试哲学:测试代码也是一等公民。不需要复杂的框架,不需要额外的依赖——标准库 testing 包 + 几个约定就够了。这篇聊聊怎么写出”很 Go 风格”的测试。
Q1:Go 测试的基本规则是什么?
A: 三条铁律:
| 规则 |
说明 |
| 文件名 |
_test.go 结尾(如 user_test.go) |
| 函数名 |
TestXxx 开头(Xxx 首字母大写) |
| 参数 |
只接受 *testing.T |
1 2 3 4 5 6 7 8 9 10 11 12
| func Add(a, b int) int { return a + b }
func TestAdd(t *testing.T) { result := Add(2, 3) if result != 5 { t.Errorf("Add(2,3) = %d; want 5", result) } }
|
运行:
1 2 3 4
| go test ./... go test -v ./... go test -run TestAdd ./... go test -cover ./...
|
就这么简单。没有 @Test 注解,没有 class 继承,没有 setup/teardown 装饰器。
Q2:Table-Driven Test(表驱动测试)是什么?为什么说它是 Go 标志性写法?
A: 这是 Go 社区最推崇的测试模式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| func TestAdd_Positive(t *testing.T) { ... } func TestAdd_Negative(t *testing.T) { ... } func TestAdd_Zero(t *testing.T) { ... }
func TestAdd(t *testing.T) { tests := []struct { name string a, b int expected int }{ {"正数相加", 2, 3, 5}, {"负数相加", -1, -3, -4}, {"零", 0, 0, 0}, {"大数", 1000000, 2000000, 3000000}, {"正负混合", -10, 20, 10}, }
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) } }) } }
|
为什么好?
- 新增 case 只需加一行数据——不用写新函数
t.Run 子测试——每个 case 独立运行,失败时精确定位
- 输入输出一目了然——读测试就能理解函数行为
实际项目中的样子(slg-go 风格):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| func TestMatchmaking_PairPlayers(t *testing.T) { tests := []struct { name string players []Player expectErr bool pairCount int }{ { name: "偶数玩家正常配对", players: []Player{ {Rating: 1500}, {Rating: 1520}, {Rating: 1480}, {Rating: 1510}, }, expectErr: false, pairCount: 2, }, { name: "奇数玩家一人轮空", players: []Player{ {Rating: 1500}, {Rating: 1520}, {Rating: 1480}, }, expectErr: false, pairCount: 1, }, }
for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mm := NewMatchmaker() pairs, err := mm.Pair(tt.players) if (err != nil) != tt.expectErr { t.Fatalf("unexpected error: %v", err) } if len(pairs) != tt.pairCount { t.Errorf("got %d pairs, want %d", len(pairs), tt.pairCount) } }) } }
|
Q3:怎么 Mock 依赖?Go 没有 mock 框架吗?
A: 有框架但不推荐过度使用。Go 的方式是用接口做 mock:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
| type UserRepository interface { GetByID(ctx context.Context, id int) (*User, error) }
type DBUserRepo struct{ db *sql.DB } func (r *DBUserRepo) GetByID(ctx context.Context, id int) (*User, error) { ... }
type MockUserRepo struct { users map[int]*User }
func (m *MockUserRepo) GetByID(_ context.Context, id int) (*User, error) { u, ok := m.users[id] if !ok { return nil, ErrNotFound } return u, nil }
func TestUserService_GetUser(t *testing.T) { mockRepo := &MockUserRepo{ users: map[int]*User{ 1: {ID: 1, Name: "Izzy"}, }, } svc := NewUserService(mockRepo) user, err := svc.GetUser(context.Background(), 1) if err != nil || user.Name != "Izzy" { t.Errorf("unexpected result") } }
|
如果真的想用工具生成 mock:
1 2 3 4 5 6
| go install github.com/vektra/mockery/v2@latest mockery --all --dir=./repo
//go:generate mockgen -source=user_repo.go -destination=mock_user_repo.go
|
但说实话,手写的 mock 往往比生成的更清晰——尤其是你的接口不复杂的话。
Q4:Benchmark 怎么写?性能优化靠它
A:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| func BenchmarkAdd(b *testing.B) { for i := 0; i < b.N; i++ { Add(2, 3) } }
func BenchmarkJSONMarshal(b *testing.B) { data := User{Name: "Izzy", Age: 99} b.ResetTimer() for i := 0; i < b.N; i++ { json.Marshal(data) } }
func BenchmarkAddParallel(b *testing.B) { b.RunParallel(func(pb *testing.PB) { for pb.Next() { Add(2, 3) } }) }
|
运行:
1 2 3 4
| go test -bench=. -benchmem
|
关键指标:
- ns/op — 每次操作耗时
- B/op — 每次操作分配的内存
- allocs/op — 每次操作的内存分配次数
Q5:TestMain 和 Setup/Teardown 怎么做?
A:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| func TestMain(m *testing.M) { fmt.Println("=== 测试开始 ===") code := m.Run() fmt.Println("=== 测试结束 ===") os.Exit(code) }
func setupTestDB(t *testing.T) *sql.DB { t.Helper() db, err := sql.Open("sqlite3", ":memory:") if err != nil { t.Fatal(err) } db.Exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`) t.Cleanup(func() { db.Close() }) return db }
func TestCreateUser(t *testing.T) { db := setupTestDB(t) }
|
t.Helper() 让错误报告指向调用位置而非 helper 内部。
t.Cleanup() 是 Go 1.14+ 的特性,按 LIFO 顺序执行清理。
Q6:覆盖率怎么看?多少才算合格?
A:
1 2 3
| go test -coverprofile=coverage.out ./... go tool cover -html=coverage.out go tool cover -func=coverage.out
|
实际建议:
| 类型 |
目标覆盖率 |
| 核心业务逻辑 |
80%+ |
| 工具函数 |
90%+(应该好测) |
| main / 入口文件 |
不强求 |
| 生成代码(protobuf 等) |
不需要测 |
别追求 100% 覆盖率! 为了覆盖而写的测试往往是无意义的(比如测试 getter/setter)。关注关键路径和边界情况更重要。
Q7:Go 测试的最佳实践清单
A:
1 2 3 4 5 6 7 8 9 10
| □ 用 table-driven test 组织多个测试用例 □ 每个包至少有一个 _test.go 文件 □ 测试函数名描述被测行为(TestAdd_WhenNegative_ReturnsSum) □ 用 t.Run 做子测试,失败时快速定位 □ 外部依赖用接口 mock,不要连真实数据库 □ 性能敏感代码写 benchmark □ 关键路径覆盖率 >80% □ 不要测试私有函数(测试公有行为即可) □ 测试之间相互独立,不依赖执行顺序 □ 使用 testify/assert 减少重复的 if err != nil 判断(可选)
|
本阶段回顾
| 篇 |
核心话题 |
关键收获 |
| go-05 |
Error 处理 |
%w 包装、sentinel error、panic 边界、API 层错误设计 |
| go-06 |
Context 包 |
WithTimeout/WithCancel、传播规则、WithValue 正确用法、常见反模式 |
| go-07 |
Testing |
Table-driven test、接口 mock、benchmark、t.Cleanup、覆盖率目标 |
接下来进入最后一站:并发进阶篇——Worker Pool、sync 深挖、以及从零搭一个 HTTP 服务把所有知识串起来。
TODO: 选你项目里一个核心函数,给它补上 table-driven test——你会发现写测试的过程本身就是对代码的一次审查,很多 bug 就是在构造测试 case 时发现的。