本篇是对单元测试的一个总结,通过完整的单元测试手把手教学,能够让刚接触单元测试的开发者从整体上了解一个单元测试编写的全过程。最终通过两个问题,也能让写过单元测试的开发者收获单测执行时的一些底层细节知识。
引入
随着工程化开发在司内大力的推广,单元测试越来越受到广大开发者的重视。在学习的过程中,发现网上针对 Golang 单元测试大多从理论角度出发介绍,缺乏完整的实例说明,晦涩难懂的 API 让初学接触者难以下手。
本篇不准备大而全的谈论单元测试、笼统的介绍 Golang 的单测工具,而将从 Golang 单测的使用场景出发,以最简单且实际的例子讲解如何进行单测,最终由浅入深探讨 go 单元测试的两个比较细节的问题。
在阅读本文时,请务必对 Golang 的单元测试有最基本的了解。
一段需要单测的 Golang 代码
这是一段典型的有 I/O 的功能代码,主体功能是传入用户名,校验合法性之后通过 redis 获取信息,之后校验获取值内容的合法性后并返回。
后台服务单测场景
对于一个传统的后端服务,它主要有以下几点的职责和功能:
- 接收外部请求,controller 层分发请求、校验请求参数
- 请求有效分发后,在 service 层与 dao 层进行交互后做逻辑处理
- dao 层负责数据操作,主要是数据库或持久化存储相关的操作
因此,从职责出发来看,在做后台单测中,核心主要是验证 service 层和 dao 层的相关逻辑,此外 controller 层的参数校验也在单测之中。
细分来看,对于相关逻辑的单元测试,笔者倾向于把单测分为两种:
- 无第三方依赖,纯逻辑代码
- 有第三方依赖,如文件、网络 I/O、第三方依赖库、数据库操作相关的代码
注:单元测试中只是针对单个函数的测试,关注其内部的逻辑,对于网络/数据库访问等,需要通过相应的手段进行 mock。
Golang 单测工具选型
由于我们把单测简单的分为了两种:
assertmock
assertmock
完善测试用例
这里我们开始对示例代码中的函数做单元测试。
生成单测模板代码
_test.go
TestGetPersonDetail
由 Goland 生成的单测模板代码使用的是官方的 testing 框架,为了更方便的断言,我们把 testing 改造成 testify 的断言方式。
TestGetPersonDetail
分析代码生成测试用例
checkUsernamecheckEmailcheckEmail
使用 gomonkey 打桩
GetPersonDetailgetPersonDetailRedisPersonDetail
所谓的“桩”,也叫做“桩代码”,是指用来代替关联代码或者未实现代码的代码。
对于函数、成员方法或者是变量的打桩,我们通常使用 gomonkey 来进行打桩。具体 API 请参考:https://pkg.go.dev/github.com/agiledragon/gomonkey
GetPersonDetailgetPersonDetailRedisgetPersonDetailRedis
所谓的函数“桩序列”指的是提前指定好调用函数的返回值序列,当该函数多次调用时候,能够按照原先指定的返回值序列依次返回。
当使用桩序列时,要分析好单元测试用例和序列值的对应关系,保证最终被测试的代码块都能被完整覆盖。
使用 gomock 打桩
getPersonDetailRedis
getPersonDetailRedisclientDoclientConnClient
可见,如果接口实现的方法更多,那么打桩需要手写的代码会更多。因此这里需要一种能自动根据原接口的定义生成接口的 mock 代码以及更方便的接口 mock 方式。于是这里我们使用 gomock 来解决这个问题。
本地安装 gomock
生成 gomock 桩代码
Conn
在当前代码目录下执行以下指令,这里我们只对某个特定的接口生成 mock 代码。
生成的代码参考
完善 gomock 相关逻辑
getPersonDetailRedis
redis.ConnmockConnredis.Dial
这里面同时使用了 gomock、gomonkey 和 testify 三个包作为压测工具,日常使用中,由于复杂的调用逻辑带来繁杂的单测,也无外乎使用这三个包协同完成。
查看单测报告
单元测试编写完毕之后,我们可以调用相关的指令来查看覆盖范围,帮助我们查看单元测试是否已经完全覆盖逻辑代码,以便我们及时调整单测逻辑和用例。本文中完整的单测代码参考:
使用 go test 指令
go test_test.go-v-cover
-run ${test文件内函数名}
go test-gcflags=all=-l
go test -v -cover -gcflags=all=-l -coverprofile=coverage.out
生成覆盖报告
go tool cover -html=coverage.out

可以看到待测的代码覆盖率达到 100% 了,完整的代码仓库可以参考:https://github.com/xunan007/go_unit_test
go test
思考
上面我们已经详细的介绍了如何对 go 代码进行单元测试。下面探讨两个问题,帮助我们深入理解 go 单元测试的过程。
Q1:桩代码在单测中是如何执行的
在上面的案例中,针对 interface 我们通过 gomock 来帮我们自动生成符合接口的类后,只需要通过 gomock 约定的 API 就能够对 interface 中的函数按期望和需要来模拟,这个很好理解。
对于函数以及方法的 mock,由于本身代码逻辑已经声明好(go 是静态强类型语言),我们很难通过编码的方式将其 mock 掉,这对我们做单元测试提供了很大的挑战。实际上 gomonkey 提供了让我们在运行时替换原函数/方法的能力。虽然说我们在语言层面很难去替换运行中的函数体,但是本身代码最终都会转换成机器可以理解的汇编指令,我们可以通过创建指令来改写函数。
ApplyCore
ApplyCore
replace
buildJmpDirectivemodifyBinaryentryAddresssyscall.Mprotectcopyreplace
buildJmpDirective
afa

leaffa
mova
最后,调用 rbx 里面的内容,其实也就是执行函数体。
因此,我们想改写函数,只要想办法把需要跳转的函数的地址加载到 rdx 寄存器中,之后使用指令跳转执行。
最终,把汇编指令翻译成 go 能够识别的版本。
这其实也是汇编里面很常见的热补丁,多用于进程中函数的替换。
Q2:执行 -gcflags=all=-l 具体有什么作用
-gcflagsallGOPATH-l
通俗来讲,内联指的是把简短的函数在调用它的地方展开。由于函数调用有固定的开销(栈和抢占检查),在编译过程中,编译器可以针对代码进行内联,减少函数调用开销。内联优化是高性能编程的一种重要手段。
在 go 中,编译器不会对所有简单函数进行内联优化。go 在决策是否要对函数进行内联时有一个标准:函数体内包含:闭包调用,select ,for ,defer,go 关键字的的函数不会进行内联。并且除了这些,还有其它的限制。当解析 AST 时,Go 申请了 80 个节点作为内联的预算。每个节点都会消耗一个预算。当一个函数的开销超过了这个预算,就无法内联。( 参考自:https://juejin.cn/post/6924888439577903117 )
下面我们通过一段简短的代码来理解 go 编译过程的内联优化过程。我们从 gomonkey 关于内联的 issue 摘取了一段代码:
mainGgGG2G2"G2"
Gmain
GG2
go run -gcflags="-m -m" main.go
G2\G\fmt.Println
上面提到了 gomokey 打桩的逻辑,它是在函数调用的时候通过机器指令将函数的指向替换了。由于函数编译后被内联,实际上不存在函数的调用,导致单测执行不通过,这也是内联导致 gomonkey 打桩无效的问题所在。