我们经常在做 Go 单测的时候,会用到两种库,gomonkey or mocker,然后在做单测的时候会通过一些所谓的 mock 方法。这里说明下,我们平时大家都习惯统一用 mock 这个词来沟通,代表的其实就是一种模拟替换的能力,用来代替要测试的原始方法。不知道大家有没有想过,Go 的单测,为何能够 mock 住呢?具体是怎么实现的呢?然后这个 mock 的真正含义又是什么呢?
Go 单测的一些基本使用就不讲了,关于 Go 单测的基本介绍和使用可以查看我的另外两篇入门文章:
从我的角度来看,其实我更想知道一些内在的原理。于是,网上找了一圈,发现这些答案都是零零散散在各个文章中,并且有些原理和实践还没有找到。于是乎,我整理了一篇文章。如下
这里总结下,现在一般我们常见的都是 stub 和 mock 这两种类型了,因此我们也重点关注下 go 里面这两种类型的原理和差异。
gomonkey 库:https://github.com/agiledragon/gomonkey
早期我们使用 gomonkey 库非常多,但是后面经过内部团队的讨论,最终因为 gomonkey 存在的一些问题,转而开始使用 mock 的方式。即便如此,在业界,使用 gomonkey 还是依然非常多
桩,或称桩代码,是指用来代替关联代码或者未实现代码的代码。如果函数B用B1来代替,那么,B称为原函数,B1称为桩函数。打桩就是编写或生成桩代码。打桩的目的主要有:隔离、补齐、控制。
一般来说,桩函数要具有与原函数完全一致的原形,仅仅是实现不同,这样测试代码才能正确链接到桩函数。用于实现隔离和补齐的桩函数一般比较简单,只需把原函数的声明拷过来,加一个空的实现,能通过编译链接就行了。比较复杂的是实现控制功能的桩函数,要根据测试的需要,输出合适的数据
gomonkey 其实不是 mock 的方式,是通过打桩的方式,支持的打桩方式包括:
gomonkey 是为函数、变量打桩,但是对于函数以及方法的模拟替换,在 Go 这种静态强类型语言中不太容易,因为我们的代码逻辑已经是声明好的,因此,我们很难通过编码的方式将其替换掉。
所以,gomonkey 提供了让我们在运行时替换原函数/方法的能力。虽然说我们在语言层面很难去替换运行中的函数体,但是代码最终都会转换成机器可以理解的汇编指令,因此,我们可以通过创建汇编指令来改写函数。
在 gomonkey 打桩的过程中,其核心函数其实是 ApplyCore。不管是对函数打桩还是对方法打桩,实际上最后都会调用这个 ApplyCore 函数,如下:
ApplyCore 函数的具体实现如下:
func (this *Patches) ApplyCore(target, double reflect.Value) *Patches {
this.check(target, double)
if _, ok := this.originals[target]; ok {
panic("patch has been existed")
}
this.valueHolders[double] = double
original := replace(*(*uintptr)(getPointer(target)), uintptr(getPointer(double)))
this.originals[target] = original
return this
}
可以看到,获取到传入的原始函数和替换函数做了一个 replace 的操作,这里就是替换的逻辑所在了。replace 函数原型如下:
func replace(target, double uintptr) []byte {
code := buildJmpDirective(double)
bytes := entryAddress(target, len(code))
original := make([]byte, len(bytes))
copy(original, bytes)
modifyBinary(target, code)
return original
}
buildJmpDirective 构建了一个函数跳转的指令,把目标函数指针移动到寄存器 rdx 中,然后跳转到寄存器 rdx 中函数指针指向的地址。之后通过 modifyBinary 函数,先通过 entryAddress 方法获取到原函数所在的内存地址,之后通过 syscall.Mprotect 方法打开内存保护,将函数跳转指令以 bytes 数组的形式调用 copy 方法写入到原函数所在内存之中,最终达到替换的目的。此外,这里 replace 方法还保留了原函数的副本,方便后续函数 mock 的恢复。
gomonkey 作为一个打桩的工具,使用场景还是比较广泛,可以使用我们大部分的应用场景。但是,它依然还是有很多限制,它必须要找到该方法对应的真实的类(结构体):
'-gcflags=all=-N -l'
来禁用内联优化。mocker:https://pkg.go.dev/github.com/travisjeffery/mocker
gomock : github.com/golang/mock , 需要 mockgen 工具配合 github.com/golang/mock/mockgen
Mock 是在测试过程中,对于一些不容易构造/获取的对象,创建一个Mock 对象来模拟对象的行为。Mock 最大的功能是帮你把单元测试进行解耦通过 mock 模拟的机制,生成一个模拟方法,然后替换调用原有代码中的方法,它其实是做一个真实的环境替换掉业务本需要的环境。
通过 mock 可以实现:
Go 官方有一个 github.com/golang/mock/gomock 和 https://github.com/travisjeffery/mocker,但是只能模拟 interface 方法,这就要求我们业务编写代码的时候具有非常好的接口设计,这样才能顺利生成 mock 代码。
mock 的大致原理是,在编译阶段去确定要调用的对象在 runtime 时需要指向的 mock 类,也就是改变了运行时函数指针的指向。对于接口 interface 的 mock,我们通过 gomock or mocker 库来帮我们自动生成符合接口的类并产生对应的文件,然后需要通过 gomock or mocker 约定的 API 就能够对 interface 中的函数按我们自己所需要的方式来模拟。这样,runtime 运行时其实就可以指向 mock 的 interface 实现来满足我们的单测诉求。
到这里,我们就可以很清晰的知道了,为啥 go 单测的时候,可以 mock 住了。因为我们要么是通过打桩的方式,将原函数通过桩函数替换了。要么是通过 mock 的方式,来模拟了一个原方法。
stub 和 mock 是两种单测中最常见的替身手段,它们都能够用来替换要测试的对象,从而实现对一些复杂依赖的隔离,但是它们在实现和关注点上又有所区别。参考《从头到脚说单测——谈有效的单元测试》一文和 difference-between-stub-and-mock 一文,mock 这里其实是包含了 stub,stub 可以理解为 mock 的子集,mock 更强大一些。如果我们发现自己的代码里面不能使用 mock 必须使用 stub,就是代码设计上肯定有问题,应该及时为'可测试性'做出调整。
在 Go 中,如果要用 stub,其实是是侵入式的。因为我们必须将我们的代码设计成可以用 stub 方法替换的形式。所以,相对来说,mock 的使用会更广泛。
当然,另外一种思路就是将 Mock 和 Stub 结合使用,比如,可以在 mock 对象的内部放置一个可以被测试函数 stub 替换的函数变量,我们可以在我们的测试函数中,根据测试的需要,手动更换函数实现。
这篇文章首发在我微信公众号【后端系统和架构】中,点击这里可以前往公众号查看原文链接,如果对你有帮助,欢迎前往关注,更加方便快捷的接收最新优质文章
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。