Go 单测高级篇:Golang 单测原理深入理解

简介: Go 单测高级篇:Golang 单测原理深入理解

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 替换的函数变量,我们可以在我们的测试函数中,根据测试的需要,手动更换函数实现。


相关文章
|
25天前
|
存储 安全 测试技术
GoLang协程Goroutiney原理与GMP模型详解
本文详细介绍了Go语言中的Goroutine及其背后的GMP模型。Goroutine是Go语言中的一种轻量级线程,由Go运行时管理,支持高效的并发编程。文章讲解了Goroutine的创建、调度、上下文切换和栈管理等核心机制,并通过示例代码展示了如何使用Goroutine。GMP模型(Goroutine、Processor、Machine)是Go运行时调度Goroutine的基础,通过合理的调度策略,实现了高并发和高性能的程序执行。
78 29
|
23天前
|
负载均衡 算法 Go
GoLang协程Goroutiney原理与GMP模型详解
【11月更文挑战第4天】Goroutine 是 Go 语言中的轻量级线程,由 Go 运行时管理,创建和销毁开销小,适合高并发场景。其调度采用非抢占式和协作式多任务处理结合的方式。GMP 模型包括 G(Goroutine)、M(系统线程)和 P(逻辑处理器),通过工作窃取算法实现负载均衡,确保高效利用系统资源。
|
26天前
|
存储 Cloud Native Shell
go库介绍:Golang中的Viper库
Viper 是 Golang 中的一个强大配置管理库,支持环境变量、命令行参数、远程配置等多种配置来源。本文详细介绍了 Viper 的核心特点、应用场景及使用方法,并通过示例展示了其强大功能。无论是简单的 CLI 工具还是复杂的分布式系统,Viper 都能提供优雅的配置管理方案。
|
26天前
|
Unix Linux Go
go进阶编程:Golang中的文件与文件夹操作指南
本文详细介绍了Golang中文件与文件夹的基本操作,包括读取、写入、创建、删除和遍历等。通过示例代码展示了如何使用`os`和`io/ioutil`包进行文件操作,并强调了错误处理、权限控制和路径问题的重要性。适合初学者和有经验的开发者参考。
|
3月前
|
Go
golang语言之go常用命令
这篇文章列出了常用的Go语言命令,如`go run`、`go install`、`go build`、`go help`、`go get`、`go mod`、`go test`、`go tool`、`go vet`、`go fmt`、`go doc`、`go version`和`go env`,以及它们的基本用法和功能。
65 6
|
3月前
|
存储 Go
Golang语言基于go module方式管理包(package)
这篇文章详细介绍了Golang语言中基于go module方式管理包(package)的方法,包括Go Modules的发展历史、go module的介绍、常用命令和操作步骤,并通过代码示例展示了如何初始化项目、引入第三方包、组织代码结构以及运行测试。
55 3
|
4月前
|
数据库连接 Go API
Golang中的25个常见错误:更好地进行go编程的综合指南
Golang中的25个常见错误:更好地进行go编程的综合指南
|
4月前
|
算法 NoSQL 关系型数据库
熔断原理与实现Golang版
熔断原理与实现Golang版
|
4月前
|
存储 关系型数据库 Go
SOLID原理:用Golang的例子来解释
SOLID原理:用Golang的例子来解释
|
4月前
|
缓存 Java 编译器
Go 中的内存布局和分配原理
Go 中的内存布局和分配原理