Go 单元测试完整实战——testing 包、table-driven tests、testify 断言
Go 单元测试完整实战——testing 包、table-driven tests、testify 断言
适读人群:Go 开发工程师、后端工程师、测试工程师 | 阅读时长:约 14 分钟 | 核心价值:掌握 Go 单元测试全套工具链,写出可维护、可扩展的高质量测试
小陈是我带过的一个 Go 开发,技术基础扎实,业务代码写得很利索。有一次他来找我,说他们组新来的技术 leader 要求每个 PR 必须带单测,覆盖率不能低于 80%。他一脸苦恼地问我:"老张,我会写测试,但总感觉写得很丑——要么全是重复代码,要么 assert 写得乱七八糟,PR 一 review 就被说设计差。你有没有什么系统的方法?"
我问他平时怎么写测试,他打开一个文件给我看:
func TestAdd(t *testing.T) {
result := Add(1, 2)
if result != 3 {
t.Errorf("expected 3, got %d", result)
}
result2 := Add(0, 0)
if result2 != 0 {
t.Errorf("expected 0, got %d", result2)
}
result3 := Add(-1, 1)
if result3 != 0 {
t.Errorf("expected 0, got %d", result3)
}
}我一眼看出问题了:重复代码堆砌,没有用 table-driven 结构,断言也只是简单的 if 判断。这在小项目里勉强能用,但一旦测试用例多起来,维护成本会急剧上升。
我说:"你这个问题很典型,我正好准备系统讲一期 Go 测试,今天先从基础打牢。"
1. testing 包:Go 原生测试能力边界
Go 的 testing 包是标准库自带的测试框架,不依赖任何第三方。理解它的边界,才能知道什么时候需要引入 testify 这类扩展。
1.1 测试函数命名规范
Go 对测试函数有强约束:
- 单元测试:
TestXxx(t *testing.T) - 基准测试:
BenchmarkXxx(b *testing.B) - 示例测试:
ExampleXxx() - 模糊测试:
FuzzXxx(f *testing.F)(Go 1.18+)
文件必须以 _test.go 结尾,这是编译器识别测试文件的唯一依据。
1.2 t.Error vs t.Fatal
这是很多新手踩的第一个坑:
func TestUserCreate(t *testing.T) {
user, err := CreateUser("张三", -1) // 年龄非法
if err == nil {
t.Fatal("expected error but got nil") // Fatal 会立即停止当前测试
}
if user != nil {
t.Error("expected nil user") // Error 继续执行后续断言
}
}t.Error / t.Errorf:标记失败但继续执行,适合后续断言相互独立的场景。
t.Fatal / t.Fatalf:标记失败并立即停止,适合后续逻辑依赖前置条件的场景(比如对象为 nil 后再操作会 panic)。
1.3 t.Helper():让错误定位更准确
func assertNoError(t *testing.T, err error) {
t.Helper() // 关键!告诉 testing 框架这是辅助函数
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}如果不加 t.Helper(),报错时定位到的是辅助函数内部,而不是调用处。加了之后,堆栈追踪会直接指向调用 assertNoError 的那一行——这在测试用例多的时候非常重要。
1.4 TestMain:测试套件的生命周期钩子
func TestMain(m *testing.M) {
// 全局 setup:初始化数据库连接、加载配置等
setup()
code := m.Run() // 运行所有测试
// 全局 teardown:关闭连接、清理临时文件等
teardown()
os.Exit(code)
}每个包只能有一个 TestMain。这是做集成测试初始化最常用的入口。
2. Table-Driven Tests:Go 社区的黄金范式
Table-driven test 是 Go 社区公认的最佳实践,源于 Go 官方博客和标准库的大量使用。核心思想是:把测试数据和测试逻辑分离。
2.1 基础结构
package calculator_test
import (
"testing"
"github.com/yourorg/calculator"
)
func TestDivide(t *testing.T) {
tests := []struct {
name string
dividend float64
divisor float64
want float64
wantErr bool
}{
{
name: "正常除法",
dividend: 10,
divisor: 2,
want: 5,
wantErr: false,
},
{
name: "除以零",
dividend: 10,
divisor: 0,
want: 0,
wantErr: true,
},
{
name: "负数除法",
dividend: -9,
divisor: 3,
want: -3,
wantErr: false,
},
{
name: "小数结果",
dividend: 1,
divisor: 3,
want: 0.3333333333333333,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := calculator.Divide(tt.dividend, tt.divisor)
if (err != nil) != tt.wantErr {
t.Errorf("Divide() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !tt.wantErr && got != tt.want {
t.Errorf("Divide() = %v, want %v", got, tt.want)
}
})
}
}t.Run 的作用是创建子测试,可以单独运行:go test -run TestDivide/除以零
2.2 并行子测试
当测试用例之间相互独立时,可以并行跑:
for _, tt := range tests {
tt := tt // 捕获循环变量!这是 Go 1.21 以前必须做的事
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // 声明此子测试可并行
// ...
})
}踩坑记录 1:循环变量捕获
Go 1.22 之前,for 循环变量是共享的。如果在 t.Run 里用 goroutine 或 t.Parallel(),必须在循环体开头用 tt := tt 复制一份。不然所有并行子测试共享同一个 tt,测试结果会混乱。Go 1.22 修复了这个问题,但存量代码要注意。
3. testify:让断言更表达力
标准库的断言能力太弱,if got != want { t.Errorf(...) } 写多了又丑又烦。testify 是 Go 生态最流行的测试辅助库,核心包有三个:
github.com/stretchr/testify/assert:断言失败后继续执行github.com/stretchr/testify/require:断言失败后立即停止github.com/stretchr/testify/suite:测试套件
3.1 assert vs require
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCreateOrder(t *testing.T) {
order, err := CreateOrder("user-001", 99.9)
require.NoError(t, err) // 失败则停止,后续 order 操作不会 panic
require.NotNil(t, order) // 同上
assert.Equal(t, "user-001", order.UserID)
assert.Equal(t, 99.9, order.Amount)
assert.NotEmpty(t, order.ID)
assert.Equal(t, OrderStatusPending, order.Status)
}选择原则:前置条件用 require,并列断言用 assert。
3.2 常用断言一览
// 相等/不等
assert.Equal(t, expected, actual)
assert.NotEqual(t, expected, actual)
// nil 检查
assert.Nil(t, obj)
assert.NotNil(t, obj)
// 错误断言
assert.NoError(t, err)
assert.Error(t, err)
assert.EqualError(t, err, "expected error message")
assert.ErrorIs(t, err, ErrNotFound) // 推荐,Go 1.13+ errors.Is
assert.ErrorAs(t, err, &myErr) // 推荐,Go 1.13+ errors.As
// 字符串
assert.Contains(t, "hello world", "world")
assert.HasPrefix(t, "hello", "he") // 注意:testify 没有这个,用 strings 包
// 集合
assert.Len(t, slice, 3)
assert.Empty(t, slice)
assert.NotEmpty(t, slice)
assert.ElementsMatch(t, []int{1,2,3}, []int{3,1,2}) // 顺序无关
// 数值比较
assert.Greater(t, 5, 3)
assert.GreaterOrEqual(t, 5, 5)
assert.InDelta(t, 3.14, 3.141, 0.01) // 浮点数近似比较
// panic 断言
assert.Panics(t, func() { divide(1, 0) })
assert.NotPanics(t, func() { divide(1, 1) })踩坑记录 2:assert.Equal 的参数顺序
testify 的约定是 assert.Equal(t, expected, actual),expected 在前,actual 在后。写反了不影响测试结果,但错误信息会变成"expected X, got Y"反过来,debug 时容易迷惑。我见过不少团队把这个搞反,还以为是 bug。
3.3 自定义错误消息
所有 assert 方法都支持追加 msgAndArgs 参数:
assert.Equal(t, want, got, "用户 %s 的订单金额不匹配", userID)这在多个类似断言并排时,能快速定位哪个用例出了问题。
4. testify/suite:组织复杂测试
当一组测试共享初始化逻辑时,suite 能大幅减少重复代码:
package service_test
import (
"testing"
"github.com/stretchr/testify/suite"
"your/project/service"
"your/project/repository"
)
type UserServiceTestSuite struct {
suite.Suite
repo *repository.MockUserRepo
service *service.UserService
}
// 每个测试方法前执行
func (s *UserServiceTestSuite) SetupTest() {
s.repo = repository.NewMockUserRepo()
s.service = service.NewUserService(s.repo)
}
// 每个测试方法后执行
func (s *UserServiceTestSuite) TearDownTest() {
s.repo.Reset()
}
func (s *UserServiceTestSuite) TestCreateUser_Success() {
s.repo.On("Save", mock.Anything).Return(nil)
user, err := s.service.CreateUser("李四", 25)
s.Require().NoError(err)
s.Assert().Equal("李四", user.Name)
s.Assert().Equal(25, user.Age)
}
func (s *UserServiceTestSuite) TestCreateUser_InvalidAge() {
_, err := s.service.CreateUser("王五", -1)
s.Assert().Error(err)
s.Assert().ErrorIs(err, service.ErrInvalidAge)
}
// 入口函数——不能少!
func TestUserServiceTestSuite(t *testing.T) {
suite.Run(t, new(UserServiceTestSuite))
}Suite 的生命周期钩子:
| 方法 | 触发时机 |
|---|---|
SetupSuite | 整个 suite 开始前,仅一次 |
TearDownSuite | 整个 suite 结束后,仅一次 |
SetupTest | 每个测试方法前 |
TearDownTest | 每个测试方法后 |
BeforeTest(suiteName, testName string) | 每个测试方法前,带方法名 |
AfterTest(suiteName, testName string) | 每个测试方法后,带方法名 |
5. 测试辅助技巧
5.1 t.Cleanup:优雅的资源清理
func TestWithTempFile(t *testing.T) {
f, err := os.CreateTemp("", "test-*.txt")
require.NoError(t, err)
t.Cleanup(func() {
f.Close()
os.Remove(f.Name())
})
// 后续操作 f,不用担心清理问题
// 即使测试 panic,Cleanup 也会执行
}比 defer 更好的地方:t.Cleanup 在测试函数结束后、子测试全部完成后才执行,而 defer 只在当前函数栈返回时执行。
5.2 golden file 测试
对于复杂输出(如 JSON、HTML),可以用 golden file 模式:
func TestGenerateReport(t *testing.T) {
got := GenerateReport(testData)
goldenFile := filepath.Join("testdata", t.Name()+".golden")
if *update { // go test -update 时更新 golden file
os.WriteFile(goldenFile, []byte(got), 0644)
return
}
want, err := os.ReadFile(goldenFile)
require.NoError(t, err)
assert.Equal(t, string(want), got)
}
var update = flag.Bool("update", false, "update golden files")踩坑记录 3:testdata 目录里的文件不会被 go build 打包
Go 工具链有个约定:testdata 目录下的文件只在测试时可见,不会被打包进二进制。但如果你的测试用到了相对路径(testdata/xxx.json),要确保测试的工作目录是包目录,不然路径会找不到。在 TestMain 里加一句 os.Chdir 可以解决跨目录调用的问题。
5.3 跳过耗时测试
func TestIntegrationWithDB(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
// ... 真实数据库操作
}运行:go test -short ./... 跳过所有标记了 Short 的测试,适合 CI 中的快速验证阶段。
6. 完整项目示例
下面是一个完整的 UserService 单元测试示例,综合运用了上述所有技巧:
package service_test
import (
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"example.com/app/service"
"example.com/app/model"
)
// --- Mock Repository ---
type mockUserRepo struct {
users map[string]*model.User
saveErr error
}
func newMockUserRepo() *mockUserRepo {
return &mockUserRepo{users: make(map[string]*model.User)}
}
func (m *mockUserRepo) FindByID(id string) (*model.User, error) {
if u, ok := m.users[id]; ok {
return u, nil
}
return nil, service.ErrUserNotFound
}
func (m *mockUserRepo) Save(u *model.User) error {
if m.saveErr != nil {
return m.saveErr
}
m.users[u.ID] = u
return nil
}
// --- Test Suite ---
type UserServiceSuite struct {
suite.Suite
repo *mockUserRepo
svc *service.UserService
}
func (s *UserServiceSuite) SetupTest() {
s.repo = newMockUserRepo()
s.svc = service.NewUserService(s.repo)
}
// --- Table-Driven Tests ---
func (s *UserServiceSuite) TestCreateUser() {
tests := []struct {
name string
input service.CreateUserInput
wantErr error
}{
{
name: "正常创建",
input: service.CreateUserInput{Name: "老张", Age: 35, Email: "zhang@example.com"},
},
{
name: "姓名为空",
input: service.CreateUserInput{Name: "", Age: 35, Email: "zhang@example.com"},
wantErr: service.ErrInvalidName,
},
{
name: "年龄非法",
input: service.CreateUserInput{Name: "老张", Age: -1, Email: "zhang@example.com"},
wantErr: service.ErrInvalidAge,
},
{
name: "邮箱格式错误",
input: service.CreateUserInput{Name: "老张", Age: 35, Email: "not-an-email"},
wantErr: service.ErrInvalidEmail,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
user, err := s.svc.CreateUser(tt.input)
if tt.wantErr != nil {
s.Require().Error(err)
s.Assert().True(errors.Is(err, tt.wantErr),
"expected error %v, got %v", tt.wantErr, err)
s.Assert().Nil(user)
return
}
s.Require().NoError(err)
s.Require().NotNil(user)
s.Assert().NotEmpty(user.ID)
s.Assert().Equal(tt.input.Name, user.Name)
s.Assert().Equal(tt.input.Age, user.Age)
s.Assert().WithinDuration(time.Now(), user.CreatedAt, time.Second)
})
}
}
func (s *UserServiceSuite) TestGetUser_NotFound() {
_, err := s.svc.GetUser("non-existent-id")
s.Assert().ErrorIs(err, service.ErrUserNotFound)
}
func (s *UserServiceSuite) TestCreateUser_RepoError() {
s.repo.saveErr = errors.New("database connection lost")
_, err := s.svc.CreateUser(service.CreateUserInput{
Name: "测试用户", Age: 20, Email: "test@example.com",
})
s.Assert().Error(err)
s.Assert().Contains(err.Error(), "database connection lost")
}
// 套件入口
func TestUserServiceSuite(t *testing.T) {
suite.Run(t, new(UserServiceSuite))
}7. 常用命令速查
# 运行当前包测试
go test ./...
# 显示详细输出
go test -v ./...
# 运行指定测试
go test -run TestDivide ./...
go test -run TestDivide/除以零 ./...
# 查看覆盖率
go test -cover ./...
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
# 跳过耗时测试
go test -short ./...
# 超时控制
go test -timeout 30s ./...
# 并行度
go test -parallel 4 ./...
# 竞态检测
go test -race ./...8. 测试文件组织规范
Go 社区对测试文件的组织有一套约定俗成的最佳实践,了解这些规范能让你的测试代码更易于阅读和维护。
8.1 黑盒测试 vs 白盒测试
Go 的包测试有两种模式,区别在于测试文件的 package 声明:
// 白盒测试:包名和被测包相同,可以访问包内部的未导出符号
// file: service/order_test.go
package service
func TestInternalHelper(t *testing.T) {
// 可以访问 service 包的私有函数和变量
result := internalCalculate(10, 20) // 假设这是私有函数
assert.Equal(t, 30, result)
}// 黑盒测试:包名加 _test 后缀,只能访问导出的公共 API
// file: service/order_test.go
package service_test
func TestPublicAPI(t *testing.T) {
// 只能使用 service 包导出的函数
svc := service.NewOrderService(...)
order, err := svc.CreateOrder(...)
}推荐做法:优先写黑盒测试(package xxx_test),这能确保你的测试只依赖公共 API,不依赖内部实现细节。当内部实现重构时,黑盒测试不需要修改。只有当你确实需要测试未导出的核心逻辑时,才写白盒测试。
8.2 测试文件命名约定
service/
├── order.go # 生产代码
├── order_test.go # order.go 的测试(命名对应)
├── user.go
├── user_test.go
└── testdata/ # 测试数据目录(go build 不打包)
├── fixtures/
│ └── test_orders.json
└── golden/ # golden file 测试数据
└── TestGenerateReport.golden8.3 testify 的 mock 子包
如果你用 testify/mock(区别于 gomock):
// mock 对象
type MockEmailSender struct {
mock.Mock
}
func (m *MockEmailSender) Send(to, subject, body string) error {
args := m.Called(to, subject, body)
return args.Error(0)
}
// 使用
func TestNotifyUser(t *testing.T) {
mockEmail := new(MockEmailSender)
// 设置期望
mockEmail.On("Send",
"user@example.com",
mock.AnythingOfType("string"),
mock.Anything,
).Return(nil).Once()
svc := service.NewNotificationService(mockEmail)
err := svc.NotifyUser("user@example.com", "测试通知")
require.NoError(t, err)
mockEmail.AssertExpectations(t) // 验证所有期望都被满足
}9. 常见测试反模式
了解什么是"坏测试",和了解什么是"好测试"同样重要。
9.1 测试了实现而非行为
// 反模式:测试内部实现细节
func TestOrderService_SaveCalled(t *testing.T) {
// 这个测试在验证"repo.Save 被调用了"
// 而不是验证"下单功能是否正确工作"
mockRepo.EXPECT().Save(gomock.Any()).Times(1)
svc.CreateOrder(...)
}
// 正确:测试行为和结果
func TestOrderService_CreateOrder_Success(t *testing.T) {
order, err := svc.CreateOrder(input)
require.NoError(t, err)
assert.Equal(t, input.Amount, order.Amount)
assert.Equal(t, OrderStatusPending, order.Status)
// 关注结果,不关注内部如何实现
}9.2 测试用例间有状态依赖
// 反模式:测试用例依赖执行顺序
var globalOrder *Order
func Test1_CreateOrder(t *testing.T) {
globalOrder, _ = svc.CreateOrder(...)
}
func Test2_GetOrder(t *testing.T) {
// 依赖 Test1 创建的 globalOrder
order, _ := svc.GetOrder(globalOrder.ID)
}Go 的 testing 包不保证测试函数的执行顺序(实际上默认按文件顺序,但这是实现细节)。每个测试应该完全独立,在 SetupTest 里创建自己需要的数据。
9.3 断言过于宽松
// 反模式:只检查有没有 error,不检查内容
order, err := svc.CreateOrder(input)
assert.NoError(t, err)
// 不断言 order 的任何字段——测试价值几乎为零
// 正确:检查关键字段
require.NoError(t, err)
require.NotNil(t, order)
assert.NotEmpty(t, order.ID)
assert.Equal(t, input.UserID, order.UserID)
assert.Equal(t, input.Amount, order.Amount)
assert.Equal(t, OrderStatusPending, order.Status)
assert.WithinDuration(t, time.Now(), order.CreatedAt, 2*time.Second)10. 测试的工程文化:从"写测试"到"测试思维"
很多工程师学会了 table-driven 写法,也知道了 testify 的断言语法,但还是觉得"写测试很费时间,价值不高"。这种感觉背后的原因,不是技术问题,而是工程思维的问题——他们把测试看成是"写完代码后要做的额外工作",而不是"开发过程的有机组成部分"。
当你真正把测试内化为思维方式时,写测试的感受会完全不同。在设计一个函数的接口时,你会同时思考"怎么测试它"——如果很难测试,说明设计需要调整。在修复一个 Bug 时,你会先写一个能复现这个 Bug 的测试,再修复代码——这样既验证了修复有效,又防止了 Bug 回归。在做代码审查时,你会关注"这个功能有测试覆盖吗,覆盖了哪些场景"。
这种思维转变,需要时间和实践积累,不是读几篇文章就能实现的。但有几个具体的习惯可以加速这个转变:
养成"先测后码"的习惯。不一定要严格遵循 TDD,但在写主要逻辑之前,先想清楚"这个函数需要验证什么",草拟几个测试用例。这个过程会倒逼你把函数的输入输出边界想清楚,写出来的代码往往更简洁。
保持测试和代码同步提交。不要把测试留到最后统一补,这样补出来的测试通常质量很差——只是让覆盖率达标,而不是真正在测试业务逻辑。把测试和代码放在同一个 PR 里,让 reviewer 同时看到"这段逻辑是怎么测试的",是更诚实的工程实践。
关注测试的可读性。一个好的测试用例,应该让不熟悉代码的人也能理解"这里测试了什么场景,期望什么结果"。测试名字要有业务语义,错误信息要描述清楚失败原因,不要只写 assert.True(t, result),而要写 assert.True(t, result, "下单金额为0时应该返回false")。
小陈最终形成了自己的测试习惯。他跟我说,最大的改变是"写代码之前会想测试",不再是"代码写完了再补测试"。这个顺序的改变,让他的代码设计变得更清晰,Bug 率也明显下降了。这才是写单元测试真正的长期价值——不只是回归检测,更是设计思维的训练。
11. 如何把测试习惯推广到整个团队
很多工程师问我:我自己写测试了,但团队里其他人不写,代码 review 的时候我要不要强制要求测试?
答案是:要,但方式比要求更重要。强制要求往往产生应付性的测试——有测试,但没有价值。更有效的方式是从正面激励开始:分享一个"因为测试提前发现了问题,避免了一次线上故障"的具体案例,让大家看到测试的实际价值。把测试纳入 code review 的标准:"这段逻辑有测试覆盖吗?这个边界场景测到了吗?"不是批评,是帮助团队建立共同的质量标准。
最重要的是:让写好测试变得容易。如果测试基础设施不完善(启动复杂、速度慢、容易出错),大家会有很强的阻力。作为有经验的工程师,花时间把测试基础设施做好,让新人写第一个测试的门槛降到最低,是影响团队的最有效方式。
好的测试代码应该是一种文档——读懂了测试,就理解了代码的预期行为。这不是理想,而是可以实现的工程标准,需要对测试命名和断言消息投入认真的注意力。
单元测试的真正价值,体现在三个月后、六个月后,当代码经历了多次重构和功能叠加之后——那时候,完善的测试套件是工程师最坚实的安全网,让重构从"胆战心惊"变成"信心十足"。这个长期价值,值得今天多花时间把测试写好。
写在最后
小陈听完这一套之后,表情从苦恼变成了若有所思:"原来写好测试也是有方法论的,不是随手堆 if 就完事了。"
我跟他说:测试代码和生产代码一样,需要设计,需要考虑可读性和可维护性。table-driven 是骨架,testify 是工具,suite 是组织方式——三者结合,能让你的测试代码既简洁又全面。
测试代码的质量直接反映了一个工程师的技术成熟度。我见过很多技术能力很强的工程师,业务代码写得行云流水,但测试代码写得一塌糊涂——这说明他们还没有真正把测试内化为日常工程实践的一部分。
好的单元测试应该具备四个特质:快速(秒级内完成)、独立(不依赖外部环境和其他测试)、可重复(每次运行结果一致)、自描述(测试名字就说明了测试目的)。
这四个特质,正是 table-driven + testify 组合能天然帮你实现的。
下一篇我们聊 Mock——当你的代码依赖外部系统时,怎么做到真正的"单元"隔离。
