在编写单元测试的过程中,如果被测试代码有外部依赖,为了便于测试,我们就要想办法来解决这些外部依赖问题。在做 Web 开发时,MySQL 存储就是一个非常常见的外部依赖,本文就来探讨在 Go 语言中编写单元测试时,如何解决 MySQL 存储依赖。
HTTP 服务程序示例
假设我们有一个 HTTP 服务程序对外提供服务,代码如下:
main.go
1 | package main |
这个服务监听 8000
端口,分别提供了两个 HTTP 接口:
POST /users
用来创建用户。
GET /users/:id
用来获取指定 ID 对应的用户信息。
UserHandler
是一个结构体,它依赖外部存储接口 store.UserStore
,这个接口定义如下:
store/store.go
1 | package store |
store.UserStore
定义了两个方法,分别用来创建、获取用户信息。
User
模型定义如下:
store/model.go
1 | type User struct { |
store.userStore
结构体则实现了 store.UserStore
接口。
store.userStore
结构体又依赖了 GORM 库的 *gorm.DB
类型,表示一个数据库连接对象。
我们可以使用 NewMySQLDB
建立数据库连接得到 *gorm.DB
对象:
store/mysql.go
1 | func NewMySQLDB(host, port, user, pass, dbname string) (*gorm.DB, error) { |
至此,这个 HTTP 服务程序整体逻辑就基本介绍完了。
其目录结构如下:
1 | $ tree |
为了保证业务的正确性,我们应该对 (*UserHandler).CreateUser
和 (*UserHandler).GetUser
这两个 Handler 进行单元测试。
这两个 Handler 定义如下:
1 | func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { |
不过,由于文章篇幅所限,我这里仅以测试 (*UserHandler).GetUser
方法为例,演示如何在测试过程中解决 MySQL 依赖问题,对 (*UserHandler).CreateUser
方法的测试就当做作业留给你自己来完成了(当然,你也可以到我的 GitHub 上查看我的实现)。
Fake 测试
我们要为 (*UserHandler).GetUser
方法编写单元测试,首先就要分析下这个方法的外部依赖。
1 | func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { |
UserHandler
结构本身依赖了 store.UserStore
,这是一个接口,定义了创建和获取用户信息的两个方法。
我们使用实现了 store.UserStore
接口的 store.userStore
结构体来初始化 UserHandler
:
1 | func NewUserHandler(db *gorm.DB) *UserHandler { |
store.userStore
结构体会使用 GORM 来完成对 MySQL 数据库的操作。所以,我们分析出 GetUser
方法的第一个外部依赖实际上就是 MySQL 存储。
GetUser
方法还接收三个参数,它们都属于 HTTP 网络相关的外部依赖,你可以在我的另一篇文章《在 Go 语言单元测试中如何解决 HTTP 网络依赖问题》中找到解决方案,就不在本文中进行讲解了。
所以,我们现在重点要关注的就只有一个问题,如何解决 MySQL 存储依赖。
我们来整理下 MySQL 外部依赖的程序调用链:
可以发现,store.UserStore
接口是 UserHandler
和 store.userStore
结构体建立连接的桥梁,我们可以将它作为突破口,实现一个 Fake object,来替换 store.userStore
结构体。
所谓 Fake object,其实就是我们同样要定义一个结构体,并实现 Create
和 Get
两个方法,以此来实现 store.UserStore
接口。
1 | type fakeUserStore struct{} |
与 store.userStore
结构体不同,fakeUserStore
并不依赖 *gorm.DB
,也就不涉及 MySQL 数据库操作了,这样就解决了 MySQL 外部存储依赖。
(*fakeUserStore).Create
方法没做任何操作,直接返回 nil
,(*fakeUserStore).Get
方法则根据传进来的 id
返回固定的 User
信息。这也是 Fake object 的特点,为真实对象实现一个简化版本。
这样,我们在编写测试代码时,只需要取代 store.userStore
结构体,使用 fakeUserStore
来实例化 UserHandler
,就可以避免与 MySQL 数据库打交道了。
1 | handler := &UserHandler{store: &fakeUserStore{}} |
为 (*UserHandler).GetUser
方法编写的单元测试完整代码如下:
1 | func TestUserHandler_GetUser_by_fake(t *testing.T) { |
现在被测试的 (*UserHandler).GetUser
方法中通过 h.store.Get(uid)
从数据库中获取用户信息时,就不用再去查询 MySQL 了,而是由 (*fakeUserStore).Get
方法直接返回 Fake 数据。
使用 go test
来执行测试函数:
1 | $ go test -v -run="TestUserHandler_GetUser_by_fake" |
测试通过。
可以发现,使用 Fake 测试来解决 MySQL 外部依赖还是比较简单的,我们仅需要参考 store.userStore
实现一个简化版本的 fakeUserStore
,然后在测试过程中,使用简化版本的 fakeUserStore
对象替换掉 store.userStore
即可。
Mock 测试
前文中,我们使用 fakeUserStore
来替换 store.userStore
,以此来接口 MySQL 依赖问题。
不过,这种使用 Fake object 来解决外部依赖的方式存在两个较为常见的弊端:
一个是使用 Fake object 需要手动编写大量代码,这里的 store.UserStore
接口仅定义了两个方法还好,但一个线上的复杂业务,可能有几十个接口,每个接口又有几十个方法,此时如果还是手动来编写这些代码,需要消耗大量时间。
另一个是 Fake object 返回结果比较固定,如果想测试其他情况,比如查询的 User
不存在,需要报错的情况,就得在 (*fakeUserStore).Get
方法中编写更多的逻辑,这增加了实现 Fake object 的复杂度。
那么有没有一种替代方案,来弥补 Fake object 的这两个弊端呢?
答案是使用 Mock 测试。
Mock 和 Fake 类似,本质上都是使用一个对象,去替代另一个对象。Fake 测试是实现了一个真实对象(store.userStore
)的简化版本(fakeUserStore
),Mock 测试则是使用模拟对象来断言真实对象被调用时的输入符合预期,然后通过模拟对象返回指定输出。
在 Go 中,我们可以使用 gomock 来实现 Mock 测试。
gomock
项目起源于 Google 的 golang/mock 仓库。不幸的是,谷歌不再维护这个项目了。幸运的是,这个项目由 Uber fork 了一份,并继续维护。
gomock
包含两个部分:gomock
包和 mockgen
命令行工具。gomock
包用来完成对被 Mock 对象的生命周期管理,mockgen
工具则用来自动生成 Mock 代码。
可以通过如下方式来安装 gomock
包和 mockgen
工具:
1 | $ go get go.uber.org/mock/gomock@latest |
注意:在项目根目录下通过
go get
命令获取gomock
包后,不要急着执行go mod tidy
,因为现在gomock
包属于indirect
依赖,还没有被使用。当通过mockgen
工具生成了 Mock 代码以后,再来执行go mod tidy
,go.mod
文件中才不会丢失gomock
依赖。
要想使用 gomock
来模拟 store.UserStore
接口的实现,我们先要使用 mockgen
工具来生成 Mock 代码:
1 | $ mockgen -source store/store.go -destination store/mocks/gomock.go -package mocks |
-source
参数指明需要 Mock 的接口文件路径,即 store.UserStore
接口所在文件。
-destination
参数指明生成的 Mock 文件路径。
-package
参数指明生成的 Mock 文件包名。
在项目根目录下执行 mockgen
命令,即可生成 Mock 文件:
1 | // Code generated by MockGen. DO NOT EDIT. |
提示:生成的 mocks 包代码你无需全部看懂,仅知道它大概生成了什么内容,如何使用即可。
可以发现,mockgen
为我们生成了 mocks.MockUserStore
结构体,并且实现了 Create
、Get
两个方法,即实现了 store.UserStore
接口。
现在,我们就可以使用生成的 Mock 对象来编写单元测试代码了:
1 | func TestUserHandler_GetUser_by_mock(t *testing.T) { |
gomock.NewController(t)
用来创建一个 Mock 控制器,该对象可以控制整个 Mock 生命周期。
ctrl.Finish()
用来断言 Mock 对象使用 EXPECT()
方法设置的期待执行方法会被调用,一般使用 defer
语句来调用,防止最后忘记。不过,如果你使用的 Go 版本大于 1.14,则可以不必显式调用 ctrl.Finish()
。
mocks.NewMockUserStore(ctrl)
使用 Mock 控制器创建了 *mocks.MockUserStore
对象,有了它,我们就可以模拟调用 store.UserStore
接口对应方法的逻辑了:
1 | mockUserStore.EXPECT().Get(2).Return(&store.User{ |
mockUserStore
对象就相当于我们前文中实现的 fakeUserStore
。
Mock 对象的 EXPECT()
方法用来设置预期被调用的方法,以及被调用方法所期望的输入,它支持链式调用,.Get(2)
表示期望在测试中调用 Mock 对象 mockUserStore
的 Get
方法时,输入参数是 2
,Return
方法用来设置输出,即返回值内容。
这就相当于,我们实现了 fakeUserStore
的 Get
方法。
我们可以使用 mockUserStore
来实例化 UserHandler
对象。
在 req
请求中,我们设置请求的用户 ID 值为 2
,即 mockUserStore
对象断言中的参数,二者参数匹配,Mock 对象才能生效。
单元测试最后,断言了返回结果为 {"id":2,"name":"user2"}
,即 mockUserStore
对象期望的返回结果。
现在我们就可以测试 (*UserHandler).GetUser
方法了。
使用 go test
来执行测试函数:
1 | $ go test -v -run="TestUserHandler_GetUser_by_mock" |
测试通过。
使用 Mock 测试来解决 MySQL 外部依赖问题,我们无需手动编写 Mock 对象的代码,可以使用 mockgen
工具为我们自动生成,简化了 Fake 测试中编写 fakeUserStore
的过程。
并且,如果想要测试其他情况,仅需要再次使用 Mock 对象的 EXPECT()
方法来设置 Get
方法的期望输入和输出即可。
比如设置预期查询 ID 为 3
的用户信息时,返回 user not found
错误:
1 | mockUserStore.EXPECT().Get(3).Return(nil, errors.New("user not found")) |
Mock 测试更方便我们测试不同业务场景。
gomock 更多用法
gomock
还有一些使用技巧值得分享。
mockgen
前文中,我们使用 mockgen
通过指定源码文件形式生成了 Mock 代码:
1 | $ mockgen -source store/store.go -destination store/mocks/gomock.go -package mocks |
mockgen
工具还支持通过反射模式来生成 Mock 代码:
1 | $ mockgen -package mocks -destination store/mocks/gomock.go github.com/jianghushinian/blog-go-example/test/mysql/store UserStore |
命令最后的两个参数分别代表需要生成 Mock 代码的包的导入路径和逗号分隔的接口列表。
执行以上命令同样能够成功生成 Mock 代码。
此外,我们还可以将 mockgen
命令写到 Go 文件中,然后使用 Go generate
工具来生成 Mock 代码:
store/generate.go
1 | package store |
这次我们的 mockgen
命令又有所不同,包的导入路径仅为一个 .
,表示当前目录,这也是被支持的。
这时候,我们只需要在项目根目录下执行 go generate ./...
命令即可生成 Mock 代码。./...
表示查找项目下全部文件,go generate
会自动找到带有 //go:generate
注释的命令并执行。
如果我们有多个源码文件要生成 Mock 代码,go generate
方式就非常合适,仅需要在 Go 文件中分多行依次写出 mockgen
命令即可使用一条命令一次全部生成。
gomock
前文中,我们使用了 Mock 对象 mockUserStore
的 EXPECT()
方法来设置 Get
方法所期待的输入和输出。
1 | mockUserStore.EXPECT().Get(2).Return(&store.User{ |
有时候,EXPECT()
所作用的方法可能存在多个参数,且有些参数不容易模拟,比如最常见的 context.Context
参数,针对这些情况,gomock
提供了更多的参数匹配方法:
gomock.Any()
表示匹配任意参数,适合参数模拟困难的情况。
gomock.Eq(x)
表示匹配与 x
相等的参数。
gomock.Not(x)
表示匹配与 x
不想等的参数。
gomock.Nil()
表示匹配 nil
参数。
gomock.Len(i)
表示匹配长度为 i
的参数。
gomock.All(ms)
表示传入的所有参数都想等才能匹配。
以上这些参数匹配方法都可以像如下这样使用:
1 | mockUserStore.EXPECT().Get(gomock.Eq(2)).Return(&store.User{ |
此外,我们可以约束 EXPECT()
所作用方法的执行次数:
1 | .Return(xxx).Times(2) // 预期方法会被调用 2 次 |
还可以约束 EXPECT()
所作用方法的执行顺序:
1 | .Return(xxx).After(preReq) // 当前预期方法在 preReq 预期方法执行完成之后执行 |
以上便是我认为 gomock
中比较常用的功能讲解,更多功能可参考官方文档。
总结
本文向大家介绍了在 Go 中编写单元测试时,如何解决 MySQL 外部依赖的问题。
我们分别使用了 Fake object 和 Mock 两种方式,来替换原有的外部依赖。
Web 服务的代码不是随意设计的,有意将 UserHandler
依赖的类型设为 store.UserStore
接口,而不是 store.userStore
结构体,是为了解耦。通过使用接口,解决了 UserHandler
与 store.userStore
结构体强绑定的问题,这就给我们使用 fakeUserStore
或 mockUserStore
来替代 store.userStore
创造了机会。
可以发现,本文介绍的两种方法其实不仅能够用于解决 MySQL 外部依赖问题。任何使用接口编写的代码,在测试时都可以使用这两种方式来替换依赖。这就是 Go 面向接口编程的好处。
本文完整代码示例我放在了 GitHub 上,欢迎点击查看。
希望此文能对你有所帮助。
联系我
- 微信:jianghushinian
- 邮箱:jianghushinian007@outlook.com
- 博客地址:https://jianghushinian.cn
参考
- gomock 源码:https://github.com/golang/mock/
- Uber gomock 源码:https://github.com/uber/mock
- Uber gomock 文档:https://pkg.go.dev/go.uber.org/mock/gomock