Go 单测高级篇:Golang 单测原理深入理解
我们经常在做 Go 单测的时候,会用到两种库,gomonkey or mocker,然后在做单测的时候会通过一些所谓的 mock 方法。这里说明下,我们平时大家都习惯统一用 mock 这个词来沟通,代表的其实就是一种模拟替换的能力,用来代替要测试的原始方法。不知道大家有没有想过,Go 的单测,为何能够 mock 住呢?具体是怎么实现的呢?然后这个 mock 的真正含义又是什么呢?
Go 单测的一些基本使用就不讲了,关于 Go 单测的基本介绍和使用可以查看我的另外两篇入门文章:
- • 《Go 单测入门篇:Golang 单元测试基本使用》
- • 《Go 单测入门篇:单元测试类型和 Golang 单元测试框架》
从我的角度来看,其实我更想知道一些内在的原理。于是,网上找了一圈,发现这些答案都是零零散散在各个文章中,并且有些原理和实践还没有找到。于是乎,我整理了一篇文章。如下
一、单测中常见的 5 种测试替身
1-1、5 种测试替身
- • Dummy Object
- • 指在测试中必须传入的对象,而传入的这些对象实际上并不会产出任何作用,仅仅是为了能够调用被测对象而必须传入的一个东西。
- • Test Stub
- • 打桩的方式,通过桩代码来实现替换原有代码逻辑,这样我们可以自由返回 stub 所替换的代码的返回。
- • Test Spy
- • 将内部的间接输出返回给测试案例,由测试案例进行验证,Test Spy只负责获取内部情报,并把情报发出去,不负责验证情报的正确性。有点类似“间谍”的作用。
- • Mock Object
- • mock 就是对对象的一个封装,外部的测试案例总是会信任 Mock Object 的结果。
- • Fake Object
- • 我们经常会把 Fake Object 和 Test Stub 搞混,因为它们都和外部没有交互,对内部的输入输出也不进行验证。
- • Fake Object并不关注SUT内部的间接输入(indirect inputs)或间接输出(indirect outputs),它仅仅是用来替代一个实际的对象,并且拥有几乎和实际对象一样的功能,保证SUT能够正常工作。实际对象过分依赖外部环境,Fake Object可以减少这样的依赖
1-2、最常见的 stub、mock
这里总结下,现在一般我们常见的都是 stub 和 mock 这两种类型了,因此我们也重点关注下 go 里面这两种类型的原理和差异。
二、Go 常见单测方式
2-1、gomonkey(stub) 的打桩
gomonkey 库:https://github.com/agiledragon/gomonkey
早期我们使用 gomonkey 库非常多,但是后面经过内部团队的讨论,最终因为 gomonkey 存在的一些问题,转而开始使用 mock 的方式。即便如此,在业界,使用 gomonkey 还是依然非常多
桩的原理
桩,或称桩代码,是指用来代替关联代码或者未实现代码的代码。如果函数B用B1来代替,那么,B称为原函数,B1称为桩函数。打桩就是编写或生成桩代码。打桩的目的主要有:隔离、补齐、控制。
- • 隔离的基本方法就是打桩,将测试任务之外的,并且与测试任务相关的代码,用桩来代替,从而实现分离测试任务。例如函数A调用了函数B,函数B又调用了函数C和D,如果函数B用桩来代替,函数A就可以完全割断与函数C和D的关系。
- • 补齐是指用桩来代替未实现的代码,例如,函数A调用了函数B,而函数B由其他程序员编写,且未实现,那么,可以用桩来代替函数B,使函数A能够运行并测试。补齐在并行开发中很常用。
- • 控制是指在测试时,人为设定相关代码的行为,使之符合测试需求。例如:
一般来说,桩函数要具有与原函数完全一致的原形,仅仅是实现不同,这样测试代码才能正确链接到桩函数。用于实现隔离和补齐的桩函数一般比较简单,只需把原函数的声明拷过来,加一个空的实现,能通过编译链接就行了。比较复杂的是实现控制功能的桩函数,要根据测试的需要,输出合适的数据
gomonkey 的打桩方式
gomonkey 其实不是 mock 的方式,是通过打桩的方式,支持的打桩方式包括:
- • 为函数打一个桩
- • 为成员方法打一个桩
- • 为全局变量打一个桩
- • 为函数变量打一个桩
- • 为函数打一个特定的桩序列
- • 为成员方打一个特定的桩序列
gomonkey 的工作原理(桩的原理)
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 桩的限制
gomonkey 作为一个打桩的工具,使用场景还是比较广泛,可以使用我们大部分的应用场景。但是,它依然还是有很多限制,它必须要找到该方法对应的真实的类(结构体):
- • gomonkey 必须禁用 golang 编译器的内联优化,不然函数被内联之后,就找不到接缝了,stub 无法进行。一般我们是通过 go test 的时候带上
'-gcflags=all=-N -l'
来禁用内联优化。
- • 内联优化一般用于能够快速执行的函数,因为在这种情况下函数调用的时间消耗显得更为突出,同时内联体量小的函数也不会明显增加编译后的执行文件占用的空间。Go 中,函数体内包含:闭包调用,select ,for ,defer,go 关键字的的函数不会进行内联。并且除了这些,还有其它的限制。
- • gomonkey 需要很高的系统权限,因为在运行时替换函数入口是一个权限要求较高的事情,在一个安全的系统上,比如在10.15+的macos上,这一点就是很难做到的。
- • gomonkey 不支持异包未导出函数的打桩、不支持同包未导出方法的打桩
2-2、mocker(mock) 的模拟
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 模拟的机制,生成一个模拟方法,然后替换调用原有代码中的方法,它其实是做一个真实的环境替换掉业务本需要的环境。
通过 mock 可以实现:
- • 验证这个对象的某些方法的调用情况,调用了多少次,参数是什么,返回值是什么等等
- • 指定这个对象的某些方法的行为,返回特定的值,或者是执行特定的动作等等
Go 常见的 mock 库
Go 官方有一个 github.com/golang/mock/gomock 和 https://github.com/travisjeffery/mocker,但是只能模拟 interface 方法,这就要求我们业务编写代码的时候具有非常好的接口设计,这样才能顺利生成 mock 代码。
mock 的原理
mock 的大致原理是,在编译阶段去确定要调用的对象在 runtime 时需要指向的 mock 类,也就是改变了运行时函数指针的指向。对于接口 interface 的 mock,我们通过 gomock or mocker 库来帮我们自动生成符合接口的类并产生对应的文件,然后需要通过 gomock or mocker 约定的 API 就能够对 interface 中的函数按我们自己所需要的方式来模拟。这样,runtime 运行时其实就可以指向 mock 的 interface 实现来满足我们的单测诉求。
2-3、为何测试代码可以 mock 住 ?
到这里,我们就可以很清晰的知道了,为啥 go 单测的时候,可以 mock 住了。因为我们要么是通过打桩的方式,将原函数通过桩函数替换了。要么是通过 mock 的方式,来模拟了一个原方法。
2-4、stub vs mock
stub 和 mock 是两种单测中最常见的替身手段,它们都能够用来替换要测试的对象,从而实现对一些复杂依赖的隔离,但是它们在实现和关注点上又有所区别。参考《从头到脚说单测——谈有效的单元测试》一文和 difference-between-stub-and-mock 一文,mock 这里其实是包含了 stub,stub 可以理解为 mock 的子集,mock 更强大一些。如果我们发现自己的代码里面不能使用 mock 必须使用 stub,就是代码设计上肯定有问题,应该及时为'可测试性'做出调整。
- • Stub:桩的方式。在测试用例中创建一个模拟的方法(函数),用于替换原有自己代码中的方法(函数)
- • stub 一般就是在运行时替换了外部依赖返回的结果,并且结果不能调整(成本很高、不容易维护)。
- • stub 不需要把外部依赖 interface 化,可以通过运行时函数指针的替换来实现,实现途径很多。
- • stub 一般是为一个特定的测试用例来编写特定的桩代码,它是硬编码对应的期望返回数据,很难在其他用例中直接复用
- • Mock:模拟的方式。在测试用例中创建一个结构体,用例满足某个外部依赖的接口 interface{}
- • mock 对象能动态调整外部依赖的返回结果,
- • mock 技术一般通过把外部依赖 interface 化来实现,interface 化之后才能做到
- • mock 增加了配置手段,可以在不同的测试阶段设置不同的预期值,虽然看起来可能更复杂,但是可复用性更高
在 Go 中,如果要用 stub,其实是是侵入式的。因为我们必须将我们的代码设计成可以用 stub 方法替换的形式。所以,相对来说,mock 的使用会更广泛。
当然,另外一种思路就是将 Mock 和 Stub 结合使用,比如,可以在 mock 对象的内部放置一个可以被测试函数 stub 替换的函数变量,我们可以在我们的测试函数中,根据测试的需要,手动更换函数实现。