Go 单元测试深度实战——testing 包、table-driven tests、mock、testify
2026/4/30大约 7 分钟
Go 单元测试深度实战——testing 包、table-driven tests、mock、testify
适读人群:Go开发者、想建立测试习惯和测试能力的工程师 | 阅读时长:约18分钟 | 核心价值:Go的测试体系是语言内置的,掌握它能让你的代码质量大幅提升
一、小郑的「上线即报警」故事
小郑在一家金融公司做Go后端,有段时间他的代码改动频繁出bug,每次上线都触发报警,搞得他压力很大。
我帮他看了一圈代码,发现整个项目连单元测试都没有。每次改动后,就靠手动测试几个主路径就上线了。
他说:「写测试太费时间了,我们赶进度来不及。」
我说:「你现在每次排查生产bug花多少时间?」
他想了想:「平均一两个小时。」
「写一个单元测试大概5分钟,能提前发现问题。你现在的策略是不花5分钟,然后线上花一两个小时。算一算哪个划算?」
他沉默了。这篇把Go测试的核心能力系统讲一遍。
二、Go测试的特点
Go的测试是语言内置的,不需要额外的测试框架:
- 测试文件以
_test.go结尾 - 测试函数以
Test开头,参数是*testing.T - 用
go test ./...运行所有测试 - 没有测试注解、没有测试类,就是普通Go函数
Java对比: Java需要JUnit框架,有 @Test 注解,有 @Before/@After。Go的测试更简洁,但也缺少一些JUnit的便利功能(比如参数化测试的语法糖)。
三、基础测试用法
// math.go
package math
func Add(a, b int) int {
return a + b
}
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("除数不能为零")
}
return a / b, nil
}// math_test.go
package math
import (
"fmt"
"testing"
)
func TestAdd(t *testing.T) {
result := Add(2, 3)
if result != 5 {
t.Errorf("Add(2, 3) = %d, want 5", result)
}
}
func TestDivide(t *testing.T) {
result, err := Divide(10, 2)
if err != nil {
t.Fatalf("不应该报错,got: %v", err)
}
if result != 5.0 {
t.Errorf("Divide(10, 2) = %f, want 5.0", result)
}
}
func TestDivideByZero(t *testing.T) {
_, err := Divide(10, 0)
if err == nil {
t.Error("除以零应该返回错误")
}
}常用的testing.T方法:
t.Error(msg)/t.Errorf(format, args):记录错误,继续执行t.Fatal(msg)/t.Fatalf(format, args):记录错误,立即停止当前测试t.Log(msg)/t.Logf(format, args):输出日志(只在-v模式下显示)t.Skip(msg):跳过当前测试
四、Table-Driven Tests:Go测试的标准模式
Table-driven test是Go社区最推荐的测试模式,一次编写多组测试用例:
package main
import (
"fmt"
"testing"
)
// 被测函数
func FizzBuzz(n int) string {
switch {
case n%15 == 0:
return "FizzBuzz"
case n%3 == 0:
return "Fizz"
case n%5 == 0:
return "Buzz"
default:
return fmt.Sprintf("%d", n)
}
}
func TestFizzBuzz(t *testing.T) {
// 测试用例表
tests := []struct {
name string
input int
expected string
}{
{"普通数字", 1, "1"},
{"3的倍数", 3, "Fizz"},
{"5的倍数", 5, "Buzz"},
{"15的倍数", 15, "FizzBuzz"},
{"不是任何倍数", 7, "7"},
{"大数", 99, "Fizz"},
{"边界0", 0, "FizzBuzz"}, // 0是15的倍数
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := FizzBuzz(tt.input)
if result != tt.expected {
t.Errorf("FizzBuzz(%d) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}t.Run 创建子测试,可以单独运行:
go test -run TestFizzBuzz/3的倍数五、testify:让断言更简洁
标准库的断言很啰嗦(需要手写 if 判断)。testify 提供了更简洁的断言:
package main
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type User struct {
ID int
Name string
Age int
}
func TestUserWithTestify(t *testing.T) {
user := User{ID: 1, Name: "老张", Age: 30}
// assert:失败后继续执行
assert.Equal(t, 1, user.ID)
assert.Equal(t, "老张", user.Name)
assert.True(t, user.Age >= 18, "用户应该是成年人")
assert.NotEmpty(t, user.Name)
// require:失败后立即停止(类似Fatal)
require.NotNil(t, &user)
require.Equal(t, 30, user.Age)
// 错误断言
_, err := Divide(10, 0)
assert.Error(t, err)
assert.EqualError(t, err, "除数不能为零")
// nil断言
result, err2 := Divide(10, 2)
require.NoError(t, err2) // 有错就停止后续测试
assert.InDelta(t, 5.0, result, 0.001) // 浮点数比较
}
func Divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("除数不能为零")
}
return a / b, nil
}六、Mock:隔离外部依赖
测试时不应该真正调用数据库、外部API。Mock就是用假实现替代真实依赖。
手写Mock(简单场景)
package service
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
// 接口定义
type UserRepository interface {
FindByID(id int64) (*User, error)
Save(user *User) error
}
type User struct {
ID int64
Name string
Age int
}
// 业务逻辑
type UserService struct {
repo UserRepository
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) CanBuyAlcohol(userID int64) (bool, error) {
user, err := s.repo.FindByID(userID)
if err != nil {
return false, fmt.Errorf("查询用户失败: %w", err)
}
return user.Age >= 18, nil
}
// 手写Mock
type MockUserRepository struct {
users map[int64]*User
err error
}
func NewMockUserRepo() *MockUserRepository {
return &MockUserRepository{users: make(map[int64]*User)}
}
func (m *MockUserRepository) FindByID(id int64) (*User, error) {
if m.err != nil {
return nil, m.err
}
user, ok := m.users[id]
if !ok {
return nil, fmt.Errorf("用户不存在")
}
return user, nil
}
func (m *MockUserRepository) Save(user *User) error {
m.users[user.ID] = user
return m.err
}
// 测试
func TestCanBuyAlcohol(t *testing.T) {
tests := []struct {
name string
users map[int64]*User
userID int64
mockErr error
expected bool
wantErr bool
}{
{
name: "成年用户可以买酒",
users: map[int64]*User{1: {ID: 1, Name: "老张", Age: 30}},
userID: 1,
expected: true,
},
{
name: "未成年用户不能买酒",
users: map[int64]*User{2: {ID: 2, Name: "小明", Age: 16}},
userID: 2,
expected: false,
},
{
name: "用户不存在",
users: map[int64]*User{},
userID: 999,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := NewMockUserRepo()
repo.users = tt.users
repo.err = tt.mockErr
svc := NewUserService(repo)
result, err := svc.CanBuyAlcohol(tt.userID)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expected, result)
}
})
}
}testify/mock(复杂场景)
package service
import (
"testing"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/assert"
)
// 用testify/mock自动生成Mock
type MockRepo struct {
mock.Mock
}
func (m *MockRepo) FindByID(id int64) (*User, error) {
args := m.Called(id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*User), args.Error(1)
}
func (m *MockRepo) Save(user *User) error {
args := m.Called(user)
return args.Error(0)
}
func TestWithMockify(t *testing.T) {
mockRepo := new(MockRepo)
// 设置期望:当FindByID(1)被调用时,返回指定用户
mockRepo.On("FindByID", int64(1)).Return(
&User{ID: 1, Name: "老张", Age: 30}, nil,
)
svc := NewUserService(mockRepo)
result, err := svc.CanBuyAlcohol(1)
assert.NoError(t, err)
assert.True(t, result)
// 验证期望被满足(FindByID被调用了一次)
mockRepo.AssertExpectations(t)
}七、测试覆盖率
# 运行测试并生成覆盖率报告
go test -coverprofile=coverage.out ./...
# 查看覆盖率概要
go tool cover -func=coverage.out
# 用浏览器查看覆盖率详情(绿色=已覆盖,红色=未覆盖)
go tool cover -html=coverage.out覆盖率目标: 不要盲目追求100%,核心业务逻辑(计算、状态转换、错误处理)要达到80%以上,工具函数和边界情况重点覆盖。
八、常用测试技巧
测试辅助函数(TestMain)
package main
import (
"fmt"
"os"
"testing"
)
// TestMain:测试的总入口,可以做全局setup/teardown
func TestMain(m *testing.M) {
// 所有测试前执行(初始化数据库、启动测试服务器等)
fmt.Println("测试前置工作...")
setup()
// 运行所有测试
code := m.Run()
// 所有测试后执行(清理)
fmt.Println("测试清理工作...")
teardown()
os.Exit(code)
}
func setup() { fmt.Println("setup") }
func teardown() { fmt.Println("teardown") }Benchmark测试
func BenchmarkFizzBuzz(b *testing.B) {
for i := 0; i < b.N; i++ {
FizzBuzz(i)
}
}
// 运行:go test -bench=BenchmarkFizzBuzz -benchmem
// 输出:BenchmarkFizzBuzz-8 50000000 25.3 ns/op 8 B/op 1 allocs/op测试辅助函数(helper)
func TestComplex(t *testing.T) {
user := createTestUser(t, "老张", 30) // 提取为辅助函数
assert.Equal(t, "老张", user.Name)
}
// t.Helper()标记该函数是辅助函数,失败时显示调用方的行号
func createTestUser(t *testing.T, name string, age int) *User {
t.Helper()
user := &User{Name: name, Age: age}
// 如果创建失败
if user == nil {
t.Fatal("创建测试用户失败")
}
return user
}九、总结
Go测试的核心要点:
- Table-driven test:一次写一组测试,覆盖多个case,是Go测试的标准模式
- testify:让断言更简洁,
assertvsrequire的选择很重要 - 接口驱动设计:依赖接口而不是具体实现,测试时才能用Mock替换
- 不测实现,测行为:测试的是输入/输出,而不是内部实现细节
- 覆盖率是手段不是目的:核心路径必须覆盖,边角代码别为了数字硬写测试
小郑后来建立了一个原则:「新写的函数不超过一个自然日,必须有单元测试。」三个月后,他的代码上线后报警从每周三四次降到了几乎为零。
测试是给未来的自己的礼物。
