Golang 中使用 Mockey 和 GoConvey 编写单元测试
在 Go 项目中,一个业务方法往往会调用数据库、缓存或者其他服务。如果每次测试都依赖真实环境,不但准备数据麻烦,也很难稳定地复现超时、查询失败等情况。
单元测试可以把这些依赖替换成可控制的实现,让我们专注验证业务逻辑。本文通过一个查询用户资料的例子,介绍如何使用字节跳动的 Mockey 和 GoConvey 编写单元测试。
两者的职责不同:Mockey 负责打桩,GoConvey 负责组织测试场景和执行断言。测试入口和执行命令仍然使用 Go 自带的 testing 和 go test。
单元测试应该测试什么
假设我们要测试一个根据用户 ID 获取昵称的方法,通常需要考虑以下情况:
| 场景 | 期望行为 |
|---|---|
| ID 小于或等于 0 | 返回参数错误,不查询数据 |
| 查询依赖返回错误 | 返回错误,并保留原始错误供调用方判断 |
| 查询结果为空 | 返回用户不存在 |
| 用户昵称为空或只有空白 | 返回默认昵称 |
| 正常查询 | 返回去除首尾空白后的昵称 |
测试不应该只验证“执行时没有报错”,还要验证返回值、错误类型以及必要的依赖调用行为。比如参数非法时,即使最终返回了参数错误,如果仍然访问数据库,也可能意味着实现存在问题。
对于纯计算逻辑,直接使用真实实现即可。对于数据库、RPC 等外部依赖,可以在边界上打桩。真正的数据库查询语句、网络协议和连接配置,则需要集成测试单独验证。
创建示例项目
在博客仓库之外创建一个独立的 Go 示例目录,然后执行:
1 | mkdir go-unit-demo |
本文完整示例已在 Windows/amd64、Go 1.27.1、Mockey 1.4.8 和 GoConvey 1.8.1 的环境中运行通过,5 个场景共 17 条断言。
上面的命令会解析依赖并记录到 go.mod 和 go.sum。团队项目应提交这两个文件,固定实际验证过的依赖版本,同时统一本地和 CI 的 Go 工具链版本。
项目结构如下:
1 | go-unit-demo/ |
按照 Go testing 的约定,测试文件以 _test.go 结尾,测试函数采用 func TestXxx(t *testing.T) 的形式。本文测试与被测代码都放在 user 包中,因此可以直接访问未导出的函数。
准备业务代码
创建 user/service.go:
1 | package user |
这里需要验证的是 GetDisplayName 的业务行为。因此只替换 findUser,保留参数判断、错误包装、空结果处理和字符串处理的真实逻辑。
如果直接把 GetDisplayName 打桩为固定返回值,再验证它返回了这个值,就没有测试到业务实现。
使用 PatchConvey 组织测试
创建 user/service_test.go:
1 | package user |
最外层的 PatchConvey 接收 t,内层场景不需要重复传入。它连接 GoConvey 的场景组织能力,并管理当前作用域内的 Mockey 补丁;退出作用域时会自动释放补丁。相关规则可以参见 Mockey 官方说明。
每个分支都创建自己的桩,避免前一个场景的返回值影响下一个场景。GoConvey 的嵌套场景会沿不同执行路径重新运行外层准备逻辑,因此不要把它理解为“一次执行的普通嵌套代码块”,也不要在外层累计跨场景共享的计数。
上面的例子没有使用点导入,保留 mockey. 和 convey. 前缀,阅读时更容易区分各个 API 的来源。
理解打桩和断言
固定返回值与自定义替身
Mock(findUser).Return(...).Build() 用于固定返回值,Build() 才会让补丁生效。返回值需要与目标函数的数量、顺序和类型匹配,示例使用 (*User)(nil) 明确表达空用户指针。
需要检查输入参数或者根据输入构造结果时,可以使用 To 提供一个替身函数。本文的替身保留了原函数的完整签名,并在调用结束后断言收集到的参数。Mockey 的实现与 API还提供了调用计数等能力。
方法打桩
如果数据访问逻辑是结构体的指针接收器方法,例如 func (r *Repository) Find(...),通常使用方法表达式 mockey.Mock((*Repository).Find) 定位目标。使用 To 时,可以显式把 *Repository 接收器放在替身函数的第一个参数位置。
这种补丁会影响目标方法的其他实例调用,不能把它当成只替换某个对象的私有状态。为避免混淆,先确认调用发生在值接收器还是指针接收器方法上。
常用断言
GoConvey 的断言采用 So(实际值, 断言规则, 期望值) 的形式,部分规则不需要期望值。常见规则可参见官方断言文档:
| 规则 | 适用场景 |
|---|---|
ShouldEqual |
字符串、整数等值比较 |
ShouldResemble |
结构体、切片、Map 等深度比较 |
ShouldBeNil |
验证没有错误或结果为空 |
ShouldBeTrue |
验证布尔条件 |
ShouldBeEmpty |
验证字符串或容器为空 |
对于使用 %w 包装的错误,推荐先调用 errors.Is,再断言结果为真。仅比较错误字符串容易受到上下文文案变化的影响,也不能充分表达错误链的语义。
运行测试与查看覆盖率
Mockey 会修改运行时的函数指令。按照其官方文档,运行测试时需要关闭编译优化和内联:
1 | go test -gcflags="all=-N -l" -count=1 -v ./... |
其中 -N 关闭编译优化,-l 关闭内联;all= 将参数应用到参与构建的所有包。-count=1 用于禁用测试结果缓存,调试打桩问题时可以确保测试重新执行。
只运行本文的测试入口:
1 | go test -gcflags="all=-N -l" -count=1 -v -run '^TestGetDisplayName$' ./user |
-run 在这里匹配的是 Go 的测试函数名,内层的场景描述不是 t.Run 创建的子测试名,不能直接照搬子测试路径进行筛选。
生成覆盖率报告:
1 | go test -gcflags="all=-N -l" -count=1 -coverprofile=coverage.out ./... |
覆盖率可以帮助发现没有执行到的代码,但执行过某一行不代表断言充分。本文虽然打桩了 findUser,却没有验证真实查询实现,因此不能用业务方法的覆盖率证明数据库访问正确。
在项目中使用时需要注意什么
本地、IDE 和 CI 使用相同的测试参数
命令行通过而 IDE 运行失败时,首先检查 IDE 的 Go 测试运行配置是否也传入了 -gcflags="all=-N -l"。CI 也应该使用同样的参数,不要只在本地关闭优化。
Mockey 与 Go 工具链、操作系统和 CPU 架构有关。升级 Go 或依赖后,应在项目实际使用的组合上验证;不要把文档里的最低版本描述理解为对所有未来版本的兼容保证。
补丁必须及时恢复
本文统一使用 PatchConvey 管理补丁。如果使用普通的 testing 测试,也可以在创建补丁之后立即注册清理:
1 | patch := mockey.Mock(findUser).Return((*User)(nil), nil).Build() |
这段代码应放在普通测试函数内部。通常选择一种清理方式即可,不需要在同一段代码中同时混用自动清理、手动恢复和全局恢复。
避免相互干扰的并发测试
对同一函数的补丁可能影响同一测试进程中的其他调用。本文用例不要调用 t.Parallel(),也不要在补丁生效期间启动会访问同一目标函数的后台任务。
go test -parallel=1 也不能替代合理的并发管理,它不会停止业务代码自己创建的 goroutine。如果被测逻辑会启动 goroutine,应在补丁释放前等待它们结束,并对测试替身中记录的共享状态做好同步。本文示例的参数记录仅适用于同步调用。
不要对所有东西都打桩
稳定的纯函数通常不需要替换;业务核心逻辑应真实执行。对于新代码,可以通过小接口和构造函数注入依赖,再用简单的 fake 实现测试。对于已有代码中的包级函数、具体类型方法或较难重构的依赖,Mockey 能减少测试准备工作。
打桩测试验证的是“在这些依赖行为下,业务逻辑是否符合预期”。真实数据库、缓存和 RPC 的连通性与行为,仍应由对应的集成测试覆盖。