编程

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。

四、几个提升测试质量的习惯

  1. 并行执行:无共享状态的测试加 t.Parallel(),CI 时间能明显缩短;同时它还能顺带暴露数据竞争(配合 -race)。
  2. 用 t.Cleanup 替代 defer:清理逻辑注册进测试框架,子测试并行时执行顺序才正确。
  3. testdata 目录:大的测试样本(JSON、二进制)放 testdata/,Go 工具链会自动忽略它,不会被编译进包。
  4. 黑盒测试包名:测试文件用 package foo_test,强制只测导出 API。想测未导出函数时先想想是不是抽象错了。
  5. 每次提交跑 -racego test -race ./...并发 bug 的性价比极高,别等出事才开。

总结

Go 测试的三层功力:table-driven 是写法层面的标准范式,让用例可读可扩展;面向接口的手写 fake 是设计层面的功力,mock 困难往往说明依赖设计有问题;覆盖率是反馈工具,用来找漏测分支而不是当考核指标。把这三件事做扎实,测试才真正成为重构和迭代的安全网,而不是 CI 里一段自我安慰的绿色输出。

发表评论

您的邮箱地址不会被公开。 必填项已用 * 标注