前言
由于项目时间比较紧, 我本来是没有打算写一篇文章来介绍mockery的, 但是无奈网上介绍mockery的文章比数量上较少(截至2023-04-27), 而且很多文章都过期了. 一方面由于golang更新比较快, 网上解释使用go get
安装mockery的, 到了go 1.6以后都安装不了. 另一方面mockery自身更新也比较快, 很多文章介绍的一些用法在新的版本中已经不灵了, 比如生成mock对象的命令选项-name
已经调整为--name
, -dir
的意义也发生了变化等等, 出现了很多差异的地方.
所以本着稳扎稳打的原则, 不得不放慢脚步, 停下来把golang mock这一块的知识库补充完整.
mockery介绍
Mockery是一个用于生成Golang接口的Mock的工具. Mockery可以帮助您在测试期间模拟依赖, 以便更轻松地测试代码. Mockery v2是Mocker的最新版本.
mockery 各版本之间的区别
Mockery v1是Mockery的最初版本, 它支持生成带有单个返回值的函数和方法的Mock. Mockery v2和v3支持生成带有多个返回值的函数和方法的Mock, Mockery v3还支持生成带有可变参数的函数和方法的Mock.
另外Mockery v2的CLI在v1的基础上做了一些增强, 以下是Mockery v2新增的一些命令和选项:
- –version: 显示Mockery的版本号.
- –debug: 启用调试模式, 以便在生成Mock时输出更多信息.
- –all: 生成所有接口的Mock, 而不仅仅是在命令行中指定的接口.
- –recursive: 递归查找指定目录中的所有接口, 并生成它们的Mock.
- –output: 指定生成Mock的输出目录.
- –Case: 指定生成Mock时使用的命名约定(例如, snake_case或camelCase)
此外, Mockery v2还提供了一些新的命令, 例如mockery init, 它可以帮助我们在项目中设置Mockery. 执行mockery init命令将在当前目录中创建一个名为.mockery.yml的文件, 该文件包含Mockery的默认配置选项. 您可以编辑此文件以自定义Mockery的行为和输出.
例如您可以使用.mockery.yml文件来指定生成Mock时使用的命名规范, 包名, 注释等. 你还可以使用.mockery.yml文件来指定要生成Mock的接口和结构体名称, 以及要生成Mock的目录和文件名. 在V2中我们可以将一些运行mockery时需要指定的选项配置到.mockery
相对于Mockery v2而言, Mockery V3对Golang新版本的一些新特性支持更好一些, 例如: 支持Go 1.17中引入的新特性, 如泛型, 嵌入式接口, 以及Go 1.18中引入的新特性泛型类型参数, 嵌入式结构体, 嵌入式接口和结构体的混合使用, 类型别名等等.
安装Mockery
安装mockery比较简单. 在Golang 1.16及以上的版本需要使用go install
安装prebuilt(也就是binary的程序)的Mockery工具, 如果使用的是golang 1.16以前的版本仍然使用go get
来安装.
go install
go install github.com/vektra/mockery/v2@v2.25.0
这里我安装的是mockery v2当前最新版本2.25.0版本, 版本信息可以在Mockery的release notes页面找到
Docker
Mockery也可以结合docker使用
下载docker image
docker pull vektra/mockery
使用Mockery生成Mock
docker run -v "$PWD":/src -w /src vektra/mockery --all
Homebrew
在macOS上可以使用Hombrew来安装, 安装方法如下:
brew install mockery
brew upgrade mockery
Mockery CLI的使用
前面我们讲了Mockery是一个生成Mock的工具, 那么如何使用它呢, 这里就讲一讲Mockery CLI的用法.
讲解的过程中我们遵循由浅入深的规则. 先从简单的示例开始.
为某个特定的接口创建mock
这里假设我们有一个GreetingService的接口, 我们要为其创建mock
mockery --name GreetingService
我们可以使用 –name来指定我们需要生成mock的interface 由于我们没有指定查找GreetingService的目录, 所有我们要切换到与GreetingService同级的目录执行该命令.
为多个接口生成mock
在项目中往往不只一个接口, 如果我们需要为多个接口生成mock应该怎么做呢? 下面即是使用mockery为多个接口生成mock的例子. 这里假设我们有两个接口GreetingService 和 OrderService 并且都处在项目根目录下.
mockery --name "GreetingService|OrderService"
同时我们也可以使用正则表达式来指导接口, 例如我们可以将上面的命令使用正则表达式简化一下, 因为它们的名字中都含有Service, 所以我们可以利用这个命名规范带来的便利. 正则表达式的语法不在本教程的讲解范围之内, 可以执行搜索相关主题了解.
mockery --name ".*Service"
甚至, 由于我们举的例子中只有两个接口, 在实际项目中我们也许会有这样的需求, 那就是为当前目录下所有的接口生成mock或更新mock. 那我们就可以这样做.
mockery --all
指定查找service的路径
上面我们有一个假定, 多个接口都处在同一个目录, 而且都在根目录下. 这显然不符合项目实际, 在真实项目中, 往往接口是有层次结构并按类别分类存放的.
这里假设GreetingService在目录下的greeting目录下, 而OrderService在order目录下. 那么我们可以使用–dir选项来指定查找路径.
mockery --dir greeting --dir order --name "GreetingService|OrderService"
当然如果接口一多, 项目层次变深, 命令会变得很冗长, 这时我们可以使用-r或–recursive在当前目录的所有子目录中递归查找接口, 例如
mockery -r --name "GreetingService|OrderService"
这样就可以很好的解决命令冗长琐碎的问题, 另外就我个人见解,实际上–recursive这种选项可以做成默认行为, 我不知道mockery为什么不这样做.
为依赖包中的接口生成mock
有时我们的项目不仅仅需要mock 项目自身的接口, 有时也需要mock依赖包中的接口. 例如我们需要模拟sql.Result 这个接口.
此时我们可以使用–srcpkg这个选项.
mockery --srcpkg database/sql --name=Result
修改输出目录
mockery默认的输出目录为项目根目录的mocks文件夹, 我们可以使用–output这个选项改变默认的output文件夹, 也可以使用–outpkg改变默认的包名
mockery -r --output mymock --name "GreetingService|OrderService"
改变默认package那么
mockery -r --output mymock --outpkg mymock --name "GreetingService|OrderService"
更多关于mockery使用, 可以使用mockery –help或查看官方文档.
mockery mock实战
这里依然以之前我的关于golang单元测试的中所使用的范例为例, 讲解使用mockery如何简化我们的测试.
实现代码
我们创建一个非常简单的服务,如下所示:
GreetingService是一个向用户打招呼的服务。其由两种问候方式: Greet()根据设置的语言向用户打招呼 GreetDefaultMessage()将使用默认消息向用户打招呼致意,不涉及到语言设置. 在GreetingService内部,Greet()将调用db.FetchMessage(lang),GreetDefaultMessage()将呼叫db.FetchDefaultMessage()。我们可以在真实场景想象的样子,db类是调用真实数据库的类。因此,我们需要在测试中使用mock来避免测试调用实际的数据库。golang中没有class的概念,但我们可以认为struct行为与类是等效的。
首先我们定义一个名为service包。然后,我们将创建一个dv结构及其接口,并将其命名为db。
DB.go
package service
type db struct{}
// DB is fake database interface.
type DB interface {
FetchMessage(lang string) (string, error)
FetchDefaultMessage() (string, error)
}
然后我们将创建GreetingService接口和实现一个调用DB接口的greeter struct。greeter struct构造函数第二个参数接收lang参数。
type greeter struct {
database DB
lang string
}
// GreetingService is service to greet your friends.
type GreetingService interface {
Greet() string
GreetInDefaultMsg() string
}
为了使数据库结构实现数据库接口,我们将添加所需的方法,并使用指针接收者。
func (d *db) FetchMessage(lang string) (string, error) {
// in real life, this code will call an external db
// but for this sample we will just return the hardcoded example value
if lang == "en" {
return "hello", nil
}
if lang == "es" {
return "holla", nil
}
return "bzzzz", nil
}
func (d *db) FetchDefaultMessage() (string, error) {
return "default message", nil
}
接下来,我们需要实现greeter的方法Greet()和GreetInDefaultMsg()。
func (g greeter) Greet() string {
msg, _ := g.database.FetchMessage(g.lang) // call database to get the message based on the lang
return "Message is: " + msg
}
func (g greeter) GreetInDefaultMsg() string {
msg, _ := g.database.FetchDefaultMessage() // call database to get the default message
return "Message is: " + msg
}
上面,greetiner方法将会调用DB以获取实际消息。 为Greeter和DB创建一个工厂方法用于创建greeter和db实例。
func NewDB() DB {
return new(db)
}
func NewGreeter(db DB, lang string) GreetingService {
return greeter{db, lang}
}
在实现的最后一部分,我们将编写一个主函数来运行服务。
package main
import (
"fmt"
"testify-mock/service"
)
func main() {
d := service.NewDB()
g := service.NewGreeter(d, "en")
fmt.Println(g.Greet()) // Message is: hello
fmt.Println(g.GreetInDefaultMsg()) // Message is: default message
g = service.NewGreeter(d, "es")
fmt.Println(g.Greet()) // Message is: holla
g = service.NewGreeter(d, "random")
fmt.Println(g.Greet()) // Message is: bzzzz
}
运行后的输出如下。
$ go run main.go
Message is: hello
Message is: default message
Message is: holla
Message is: bzzzz
Mock和测试
之前的博客中, 我们是手写Mock代码, 这次我们的Mock部分借助Mockery帮我们自动生成.
在生成Mock之前, 我们需要安装Mockery.
首先我们使用前面学到的知识为GreetingService生成mock
mockery -r --name "GreetingService|DB"
运行成功后, mockery帮我们生成了, 想要的mock如下
mocks/GreetingService.go
package mocks
import mock "github.com/stretchr/testify/mock"
// GreetingService is an autogenerated mock type for the GreetingService type
type GreetingService struct {
mock.Mock
}
// Greet provides a mock function with given fields:
func (_m *GreetingService) Greet() string {
ret := _m.Called()
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
// GreetInDefaultMsg provides a mock function with given fields:
func (_m *GreetingService) GreetInDefaultMsg() string {
ret := _m.Called()
var r0 string
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
return r0
}
type mockConstructorTestingTNewGreetingService interface {
mock.TestingT
Cleanup(func())
}
// NewGreetingService creates a new instance of GreetingService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewGreetingService(t mockConstructorTestingTNewGreetingService) *GreetingService {
mock := &GreetingService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
mocks/DB.go
package mocks
import mock "github.com/stretchr/testify/mock"
// DB is an autogenerated mock type for the DB type
type DB struct {
mock.Mock
}
// FetchDefaultMessage provides a mock function with given fields:
func (_m *DB) FetchDefaultMessage() (string, error) {
ret := _m.Called()
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func() (string, error)); ok {
return rf()
}
if rf, ok := ret.Get(0).(func() string); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FetchMessage provides a mock function with given fields: lang
func (_m *DB) FetchMessage(lang string) (string, error) {
ret := _m.Called(lang)
var r0 string
var r1 error
if rf, ok := ret.Get(0).(func(string) (string, error)); ok {
return rf(lang)
}
if rf, ok := ret.Get(0).(func(string) string); ok {
r0 = rf(lang)
} else {
r0 = ret.Get(0).(string)
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(lang)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
type mockConstructorTestingTNewDB interface {
mock.TestingT
Cleanup(func())
}
// NewDB creates a new instance of DB. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewDB(t mockConstructorTestingTNewDB) *DB {
mock := &DB{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
Mock无参方法
在上一节中, 我们使用mockery cli创建了一个DB的mock struct, 现在我们可以在测试中使用它了.
在DB interface上有一个不带参数的方法FetchDefaultMessage, 我们想要在测试中模拟它. 我们可以像下面这样创建一个模拟对象:
package service_test
import (
"mocks"
"service"
"testing"
"github.com/stretchr/testify/assert"
)
func TestMockMethodWithoutArgs(t *testing.T) {
theDBMock := &mocks.DB{} // create the mock
theDBMock.On("FetchDefaultMessage").Return("foofofofof", nil) // mock the expectation
g := service.NewGreeter(theDBMock, "en") // create greeter object using mocked db
assert.Equal(t, "Message is: foofofofof", g.GreetInDefaultMsg()) // assert what actual value that will come
theDBMock.AssertNumberOfCalls(t, "FetchDefaultMessage", 1) // we can assert how many times the mocked method will be called
theDBMock.AssertExpectations(t) // this method will ensure everything specified with On and Return was in fact called as expected
}
在上面的代码中, 我们创建了一个dbMock对象, 并使用On方法指定了要模拟的方法FetchDefaultMessage(). 然后, 我们使用Return方法指定了模拟方法的返回值. 当该方法被调用时, 将返回我们指定的模拟值.
5. Mock带参数的方法
在上一节中, 我们已经了解了如何模拟没有参数的方法. 在这一节中, 我们将学习如何模拟带有参数的方法.
在DB interface上有一个带参数的方法FetchMessage(lang string), 我们想要在测试中模拟它. 我们可以像下面这样创建一个模拟对象:
func TestMockMethodWithArgs(t *testing.T) {
theDBMock := &mocks.DB{}
theDBMock.On("FetchMessage", "sg").Return("lah", nil) // if FetchMessage("sg") is called, then return "lah"
g := service.NewGreeter(theDBMock, "sg")
assert.Equal(t, "Message is: lah", g.Greet())
theDBMock.AssertExpectations(t)
}
总结
在本文中我们介绍了mockery这个mock工具, 以及它的使用方法, 另外列出了两个mockery结合testify进行单元测试的实例, 希望对您有帮助.