Go 单元测试实战:table-driven、mock 与覆盖率的正确姿势
写 Go 的人没有不会跑 go test 的,但测试写得好不好,差距非常大。见过太多项目:测试文件一堆,覆盖率看着不低,真出了 bug 却一个都拦不住——因为测的全是”正常路径”。这篇文章总结我在生产项目里沉淀下来的 Go 测试写法:table-driven 模式、接口 mock、以及覆盖率的正确用法。
一、Table-Driven:Go 测试的标准范式
Go 官方和社区几乎一致推荐表驱动测试。核心思路:把”输入 → 期望输出”整理成一张表,用一个循环跑完所有用例。
func TestParseAmount(t *testing.T) {
tests := []struct {
name string
input string
want int64
wantErr bool
}{
{"正常金额", "12.34", 1234, false},
{"零", "0", 0, false},
{"负数", "-1.00", -100, false},
{"非法字符", "12a.3", 0, true},
{"空字符串", "", 0, true},
{"超两位小数", "1.999", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseAmount(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr)
}
if got != tt.want {
t.Errorf("got %d, want %d", got, tt.want)
}
})
}
}
三个细节值得注意:
- 每个用例必须有 name。用
t.Run(tt.name, ...)跑子测试,失败时直接定位到”哪一行数据”,而不是一个笼统的函数名。 - 边界用例占比应过半。上面 6 个用例只有 2 个是正常路径。空值、负数、溢出、非法输入才是 bug 的高发区。
- 失败该 Fatal 就 Fatal。err 不符合预期时后续断言没有意义,用
t.Fatalf立即终止当前子测试;值不相等这类”还能继续查”的用t.Errorf。
二、Mock:面向接口,而不是面向框架
Go 的 mock 不需要重型框架,前提是代码本身面向接口设计。假设业务层依赖一个用户存储:
type UserStore interface {
GetUser(ctx context.Context, id int64) (*User, error)
}
type UserService struct {
store UserStore
}
func (s *UserService) DisplayName(ctx context.Context, id int64) (string, error) {
u, err := s.store.GetUser(ctx, id)
if err != nil {
return "", fmt.Errorf("get user %d: %w", id, err)
}
if u.Nickname != "" {
return u.Nickname, nil
}
return u.Name, nil
}
测试时手写一个假实现即可,十行以内:
type fakeStore struct {
users map[int64]*User
err error
}
func (f *fakeStore) GetUser(_ context.Context, id int64) (*User, error) {
if f.err != nil {
return nil, f.err
}
u, ok := f.users[id]
if !ok {
return nil, ErrNotFound
}
return u, nil
}
什么时候才需要 gomock/mockery 这类工具?我的判断标准:接口方法超过 5 个、且需要断言”调用次数与参数”时再上工具。大部分场景,手写 fake 更直观、无生成代码、重构时编译器直接报错,维护成本反而低。
一个反模式:为了测试给
*sql.DB、*redis.Client这类具体类型做 mock。正确做法是在业务层定义窄接口(只声明用到的方法),具体客户端在组装层注入。接口属于使用方,这是 Go 的惯例。
三、覆盖率:指标是工具,不是目标
两条命令看覆盖率:
go test -coverprofile=cover.out ./...
go tool cover -html=cover.out # 浏览器里逐行看哪些没测到
关于覆盖率的几个务实观点:
| 做法 | 评价 |
|---|---|
| 核心业务逻辑(计费、状态机、解析)要求 85%+ | 值得,bug 代价高 |
| 全仓库一刀切要求 90% | 不值得,会逼人写凑数测试 |
| 用 HTML 报告找”漏测的分支” | 覆盖率最有价值的用法 |
| 只看总数字不看分布 | 数字好看,该漏的还是漏 |
覆盖率衡量的是”代码被执行过”,不是”行为被验证过”。一个没有断言的测试也能刷高覆盖率。所以覆盖率的正确用法是找出没测到的分支,而不是当 KPI。
四、几个提升测试质量的习惯
- 并行执行:无共享状态的测试加
t.Parallel(),CI 时间能明显缩短;同时它还能顺带暴露数据竞争(配合-race)。 - 用 t.Cleanup 替代 defer:清理逻辑注册进测试框架,子测试并行时执行顺序才正确。
- testdata 目录:大的测试样本(JSON、二进制)放
testdata/,Go 工具链会自动忽略它,不会被编译进包。 - 黑盒测试包名:测试文件用
package foo_test,强制只测导出 API。想测未导出函数时先想想是不是抽象错了。 - 每次提交跑 -race:
go test -race ./...抓并发 bug 的性价比极高,别等出事才开。
总结
Go 测试的三层功力:table-driven 是写法层面的标准范式,让用例可读可扩展;面向接口的手写 fake 是设计层面的功力,mock 困难往往说明依赖设计有问题;覆盖率是反馈工具,用来找漏测分支而不是当考核指标。把这三件事做扎实,测试才真正成为重构和迭代的安全网,而不是 CI 里一段自我安慰的绿色输出。