持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第19天,点击查看活动详情
mock 在单元测试中的重要性无需多言,广义的 mock 本质是对三方依赖构建一个平替(Test Double),可以是完整接口的模拟实现(fake),也可以是对局部接口,方法的替换(mock/stub)。
业界常见的 mock 派系分为两类:
-
monkey patch:基于修改内存态方法区汇编码,将需 mock 的方法修改为 jmp 指令,从而实现对全局方法的实现的替换。
-
codegen:使用工具生成 interface 的 mock 实现,其 mock 实现将接口方法代理到基于反射的一套 mock api 上。
今天我们来看看字节跳动开源的 mock 工具:mockey,看看它有什么能力,怎么用。
mockey
Mockey is a simple and easy-to-use golang mock library, which can quickly and conveniently mock functions and variables. At present, it is widely used in the unit test writing of ByteDance services. The bottom layer is monkey patch realized by rewriting function instructions at runtime.
mockey 按照上面的标准来划分,属于 monkey patch 的类别。从 github 上看,对自己的定位是一个简单易用的 Golang mock 库,能够对函数和变量进行 mock。目前广泛用于字节内部的单测实践。mockey 的底层原理还是在运行时重写函数指令。
简单说,可以理解为和此前我们介绍过的 gomonkey 干的事情是一样的,大家可以简单回顾下我们上一篇文章 解析 Golang 测试(8)- gomonkey 实战。
mock 和 assertion 经常是分不开的,mockey 则是旗帜鲜明地建议大家用 goconvey 来与之搭配,这样容易写出树形的测试结构,语义更清晰。不熟悉的同学可以看下前一篇文章 解析 Golang 测试(3)- goconvey 实战。
mockey 支持的功能如下:
- mock 函数和方法
- 基础功能
- 普通/可变参数函数
- 普通/可变参数方法
- 嵌套结构体方法
- 私有类型的导出方法(不同包下)
- 高级功能
- mock 后执行原函数
- goroutine 条件过滤
- 增量改变 mock 行为
- 获取原函数执行次数
- 获取 mock 函数执行次数
- 基础功能
- mock 变量
- 普通变量
- 函数变量
对 Go 1.13 以上的版本都支持,这一点也是很友好的,相信大家都或多或少能见到很多团队,还在用 1.13, 1.14 这样的老版本。而且 mockey 支持了 MacOS(Darwin), Linux, Windows 三种平台,以及 AMD64, ARM64 两种架构。
快速上手
首先 go get 添加 mockey 依赖:
go get github.com/bytedance/mockey@latest
我们来看一下官方给出的针对:
- 函数
- 方法
- 变量
三种 mock 对象应该怎样处理:
import (
"fmt"
"testing"
. "github.com/bytedance/mockey"
. "github.com/smartystreets/goconvey/convey"
)
func Foo(in string) string {
return in
}
type A struct{}
func (a A) Foo(in string) string { return in }
var Bar = 0
func TestMockXXX(t *testing.T) {
PatchConvey("TestMockXXX", t, func() {
Mock(Foo).Return("c").Build() // mock函数
Mock(A.Foo).Return("c").Build() // mock方法
MockValue(&Bar).To(1) // mock变量
So(Foo("a"), ShouldEqual, "c") // 断言`Foo`成功mock
So(new(A).Foo("b"), ShouldEqual, "c") // 断言`A.Foo`成功mock
So(Bar, ShouldEqual, 1) // 断言`Bar`成功mock
})
// `PatchConvey`外自动释放mock
fmt.Println(Foo("a")) // a
fmt.Println(new(A).Foo("b")) // b
fmt.Println(Bar) // 0
}
熟悉 goconvey 的同学可能已经发现了,这次我们没有用 Convey() 而是用了 PatchConvey,这就是 mockey 封装的 API,大家可以近似认为和 Convey 类似,同样需要传入 *testing.T 对象,在这里以树形结构写各个分支的测试代码。
区别在于它能够帮助我们自动释放当前convey内部的patch,免去 defer 的苦恼。
还记得么?如果是 gomonkey,我们是需要 Reset 的:
now := time.Now()
var p = gomonkey.ApplyFunc(time.Now, func() time.Time {
return now
})
defer p.Reset()
而在这里,只要我们使用 PatchConvey,mockey 自动帮我们处理了释放 patch 的问题。
func PatchConvey(items ...interface{}) {
for i, item := range items {
if reflect.TypeOf(item).Kind() == reflect.Func {
items[i] = reflect.MakeFunc(reflect.TypeOf(item), func(args []reflect.Value) []reflect.Value {
gMocker = append(gMocker, make(map[uintptr]mockerInstance))
defer func() {
for _, mocker := range gMocker[len(gMocker)-1] {
mocker.unPatch()
}
gMocker = gMocker[:len(gMocker)-1]
}()
return tool.ReflectCall(reflect.ValueOf(item), args)
}).Interface()
}
}
convey.Convey(items...)
}
下面两段代码是等价的,大家可以感受一下,本质是把 defer mock3.UnPatch()
这一句给省了。尤其是 mock 比较多的时候,这个简化还是很方便的。
PatchConvey("test return", func() {
mock3 := Mock(Fun).Return("c").Build()
r := Fun("a")
convey.So(r, convey.ShouldEqual, "c")
convey.So(mock3.Times(), convey.ShouldEqual, 1)
})
Convey("test return", func() {
mock3 := Mock(Fun).Return("c").Build()
defer mock3.UnPatch()
r := Fun("a")
convey.So(r, convey.ShouldEqual, "c")
convey.So(mock3.Times(), convey.ShouldEqual, 1)
})
断言还是沿用 goconvey 的 So,很适合用作 BDD 场景的判断。其实大家会发现,核心的 Mock 用法就是这三行:
Mock(Foo).Return("c").Build() // mock函数
Mock(A.Foo).Return("c").Build() // mock方法
MockValue(&Bar).To(1) // mock变量
这里的 Mock 函数是源自 github.com/bytedance/mockey
包的,由于我们全部导入,可以直接使用。
我们只需要在 Mock 函数里传入希望 mock 的 function 或 method,指定 Return 值,调用 Build 即可完成 mock,非常简单。而且由于在 PatchConvey 里,也不需要 defer 来释放。
API
Mock 函数和方法
Mock
func Mock(target interface{}) *MockBuilder {
tool.AssertFunc(target)
return &MockBuilder{
target: target,
}
}
- API:Mock(target interface{}) *MockBuilder
- 参数:target 需要mock的函数
注意,我们上面示例中的 Mock 函数,接受我们需要 mock 的 target,返回了一个 MockBuilder,我们可以根据需求可以调不同的方法实现相关功能。
func Fun(a string) string {
fmt.Println(a)
return a
}
type Class struct {}
func (*Class) FunA(a string) string {
fmt.Println(a)
return a
}
func TestMock(t *testing.T) {
Mock(Fun) 对于普通函数使用这种
Mock((*Class).FunA) 对于class使用这种方式
}
设置条件
有些时候我们并不希望针对一个 target 全部给 mock,而是希望在某些特定条件下才 mock,这个时候可以用 MockBuilder 的 When 方法来处理:
- API:When(when interface{}) *MockBuilder
- 参数:when 函数指针。表示在何种条件下调用mock函数返回mock结果。
- 函数原型: when(args...) bool,这里 args与Mock 函数参数一致,一般通过args来判断是否需要执行 mock
- 返回值: bool ,是true的时候执行 mock
这里支持链式调用,返回的还是一个 MockBuilder。示例:
func TestMock(t *testing.T) {
// 对于普通函数使用这种
Mock(Fun).When(func(p string) bool { return p == "a" })
// 对于class使用这种方式
Mock((*Class).FunA).When(func(self *Class, p string) bool { return p == "a" })
}
设置结果
mockey 支持两种设置结果的方式:直接通过 Return 设置结果,以及使用 mock 函数。
- 直接设置结果
- API: Return(results ...interface{}) *MockBuilder
- 参数: results 参数列表需要完全等同于需要mock的函数返回值列表
- mock 函数
- API: To(hook interface{}) *MockBuilder
- 参数: hook 参数与返回值需要与mock函数完全一致
示例代码:
func Fun(a string) string {
fmt.Println(a)
return a
}
mock := func(p string) string {
fmt.Println("b")
return "b"
}
Mock(Fun).To(mock)
创建 Mocker
调用 MockBuilder 的 Build 方法可以创建出一个 Mocker
func (builder *MockBuilder) Build() *Mocker {
mocker := Mocker{target: reflect.ValueOf(builder.target), builder: builder}
mocker.buildHook(builder)
mocker.Patch()
return &mocker
}
和上面我们看到的用法一样,在我们通过 Mock 函数获取到一个 MockBuilder,并通过链式方法指定了 mock 结果后,我们可以调用 Build 拿到 Mocker。
mock3 := Mock(Fun).Return("c").Build()
Mocker 常用方法
- 统计调用次数
Times() int
被mock函数调用次数
func (mocker *Mocker) Times() int {
return mocker.times
}
MockTimes() int
hook函数调用次数
func (mocker *Mocker) MockTimes() int {
return mocker.mockTimes
}
注意区分这两个指标,有两个概念:【原函数】,【mock函数】。
我们通过 Times()
获取到的是【原函数】,或者说被 mock 函数的被调用次数,而通过 MockTimes()
获取到的是实实在在提供的 mock 函数调用次数。
如果我们是完全 Mock,没有通过 When 指定什么时候走 mock 函数的话,二者应该是相同的。看个示例:
PatchConvey("test return", func() {
mock3 := Mock(Fun).When(func(p string) bool { return p == "a" }).Return("c").Build()
r := Fun("c")
convey.So(r, convey.ShouldEqual, "a")
convey.So(mock3.Times(), convey.ShouldEqual, 1)
convey.So(mock3.MockTimes(), convey.ShouldEqual, 0)
})
这里只有在 p == a 的时候才走 mock 函数,所以 convey 断言时,Times 为 1,但 MockTimes 为 0
- 手动启动/关闭 mock代理
其实我们通过 Build 方法拿到 Mocker 后,Patch 就自动生效了,但有时候我们希望更细粒度控制,可以通过 Patch 和 UnPatch 方法来调整,二者的返回值都还是 Mocker:
func (mocker *Mocker) Patch() *Mocker {
mocker.lock.Lock()
defer mocker.lock.Unlock()
if mocker.isPatched {
return mocker
}
mocker.patch = monkey.PatchValue(mocker.target, mocker.hook, reflect.ValueOf(mocker.proxy))
mocker.isPatched = true
addToGlobal(mocker)
return mocker
}
func (mocker *Mocker) UnPatch() *Mocker {
mocker.lock.Lock()
defer mocker.lock.Unlock()
if !mocker.isPatched {
return mocker
}
mocker.patch.Unpatch()
mocker.isPatched = false
removeFromGlobal(mocker)
mocker.times = 0
mocker.mockTimes = 0
return mocker
}
Mock 变量
开始mock
func MockValue(targetPtr interface{}) *MockerVar {
tool.AssertPtr(targetPtr)
return &MockerVar{
target: reflect.ValueOf(targetPtr).Elem(),
origin: reflect.ValueOf(targetPtr).Elem().Interface(),
targetType: reflect.TypeOf(targetPtr).Elem(),
}
}
target 需要mock的变量的地址。MockValue 函数返回了一个 MockerVar,这是针对变量 mock 的起点,后续也提供了一系列链式调用的方法,我们下来看一下:
设置变量
func (mocker *MockerVar) To(value interface{}) *MockerVar {
var v reflect.Type
if value == nil {
mocker.hook = reflect.Zero(mocker.targetType)
v = mocker.targetType
} else {
mocker.hook = reflect.ValueOf(value)
v = reflect.TypeOf(value)
}
tool.Assert(v.AssignableTo(mocker.targetType), "value type:%s not match target type:%s", v.Name(), mocker.targetType.Name())
mocker.Patch()
return mocker
}
通过 To
可以设置 mock 的新值,立刻生效。示例如下:
func TestVarPatchConvey(t *testing.T) {
b := 1
a := 10
PatchConvey("test mock2", t, func() {
PatchConvey("test mock3", func() {
MockValue(&a).To(20)
So(a, ShouldEqual, 20)
PatchConvey("test mock4", func() {
MockValue(&a).To(30)
MockValue(&b).To(40)
So(b, ShouldEqual, 40)
So(a, ShouldEqual, 30)
})
So(b, ShouldEqual, 1)
})
So(b, ShouldEqual, 1)
So(a, ShouldEqual, 10)
PatchConvey("test mock5", func() {
MockValue(&a).To(30)
So(a, ShouldEqual, 30)
})
So(a, ShouldEqual, 10)
})
}
手动取消mock 或再次 mock
其实和上面的 Mocker 下的 Patch 和 UnPatch 一样,mockey 对变量 mock 也提供了手动操作的能力。
func (mocker *MockerVar) Patch() *MockerVar {
mocker.lock.Lock()
defer mocker.lock.Unlock()
if !mocker.isPatched {
mocker.target.Set(mocker.hook)
mocker.isPatched = true
addToGlobal(mocker)
}
return mocker
}
func (mocker *MockerVar) UnPatch() *MockerVar {
mocker.lock.Lock()
defer mocker.lock.Unlock()
if mocker.isPatched {
mocker.isPatched = false
if mocker.origin == nil {
mocker.target.Set(reflect.Zero(mocker.targetType))
} else {
mocker.target.Set(reflect.ValueOf(mocker.origin))
}
removeFromGlobal(mocker)
}
return mocker
}
禁用内联和编译优化
所有 monkey patch 这一类 mock 库都需要大家禁止内联,否则函数替换可能不成功。执行 go test
命令时可以使用:go test -gcflags="all=-l -N" -v ./...
函数长度
目标函数小于一行,导致编译后机器码太短,这个时候无法mock生效,一般两行及以上不会有这个问题。
不要重复mock
在最小单位的PatchConvey
中重复 mock 同一个函数会报错,如果确实有这种需求可以尝试获取Mocker
后重新 mock。同时也需要注意,如果大家选定了 mockey,不要同时也还在用 gomonkey 等其他 monkey patch 去 mock 同一个函数。
结语
其实 mockey 旗帜鲜明地和 goconvey 结合也是凸显了作者的品味,通过 convey 树状结构把单测的层次划分清楚还是很关键的。但有的时候我们的单测非常简单,这时候也可以考虑使用更简单直接的框架来实现。
相较于 gomonkey,我觉得 mockey 额外提供的很有用的工具在于判断 mock 函数的调用次数,这样可以辅助我们做一些判断,还是非常有用的。从 API风格上来说,mockey 的链式API 相较于 gomonkey 每个功能独立 API 也会友好一些,不需要使用者记忆太多。
总之还是很推荐大家感受一下 mockey 的能力,建议大家上手写几次,就更能理解 mock + convey 的好处在哪里了。感谢阅读!