Golang的测试用例不好写怎么办啊啊啊。。。
背景
最近工作中使用Golang开发了一个从主业务中拆分的微服务。业务逻辑不是很复杂,代码量也不是很多。主要涉及到三个SDK的整合,外加一些很基础的处理逻辑。
因为之前有过很长一段时间使用Ruby on Rails进行开发,习惯了测试驱动开发的模式,习惯用测试用例保证自己代码的质量;因此在使用Golang进行业务开发时,总想着为业务代码编写出完备的测试用例,一来保证代码的正确性,方便代码进行回归测试;二来通过测试用例展示业务代码的正确使用方式。
于是想探究一番,Golang中测试用例的正确编写姿势到底是什么样子。
测试用例的场景
理想中的测试
理想中,测试用例场景应该是这样的:我们编写了一个A函数,为了确保A的正确性,我们写了一个ATest函数,专门对A进行测试;ATest函数PASS了,说明A函数是按照期望运行的。
比如下面的MyLoveAge
函数,我们假设固定返回18岁,为了保证MyLoveAge
的正确性,再写一个TestMyLoveAge
的函数。
// MyLoveAge returns mylover's age
func MyLoveAge() int {
return 18
}
下面的TestMyLoveAge
断言了MyLoveAge
函数的输出,如果是18岁测试用例就通过,否则就失败。
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMyLoveAge(t *testing.T) {
assert.Equal(t, 18, MyLoveAge())
}
实际中的测试用例
真实场景应该是这样的:我们写了一个M函数,M调用了N方法;为了确保M的正确性,我们写一个MTest函数,专门对M进行测试;但是因为N是带状态的,为了编写MTest函数,我们需要想办法仿造(Mock)一个假的N,来保证MTest能运行通过。
但是,造一个假的N往往没那么容易。
import "math/rand"
// MyLoveAge2 returns mylover's age
func MyLoveAge2() int {
return 18 + TimeFlying()
}
// TimeFlying returns the Time flying.
func TimeFlying() int {
if rand.Intn(10) == 0 {
return 0
}
panic("NO!!!")
}
// MyLoveAge2的测试文件
import (
"testing"
"github.com/stretchr/testify/assert"
)
//需要事先进行一些mock操作,下面的函数才能一直跑成功
func TestMyLoveAge2(t *testing.T) {
assert.Equal(t, 18, MyLoveAge2())
}
比如,MyLoveAge2
调用了TimeFlying
函数,这时候如果想使得TestMyLoveAge2
能一直保持正常工作,我们有两种选择:1)Mock一个TimeFlying
函数,让整个函数永远返回0;2)Mock一个rand.Intn
函数,让它永远返回0。很可惜,对于Golang来说,这两种方式实施起来都没有那么容易,甚至无法实现。
当代码量进一步增加,各个函数的依赖关系更为复杂时,实现起来将更为棘手。
Ruby V.S. Golang
那么,相对于Golang来说,为什么编写Ruby的测试用例能方便一些呢?这真的要归功于Ruby的元编程语言特性。
Ruby的语言特性,决定了它的灵活性:1)一切皆对象;2)任何对象均可以在运行时拆解并允许重新组装。比如上面提到的TimeFlying
函数,Ruby很方便对其进行Mock(本质是对Module中的方法进行Mock,当然Module已被看做一个对象)。
和Ruby类似的思想,Golang中出现了一个gostub的工具,它的思想就是想办法把Golang中一切想Mock的东西变成变量,然后就可以对这个变量进行赋值仿造了。但是因为其需要对业务代码进行改造(比如需要把函数声明成变量),而改造以后就不像是Go的风格,因此并没有被普遍接受。
据说有个测试覆盖率100%的代码库
在我们做基础服务时,发现了jaeger这个开源组件,使用Golang进行开发,且测试用例的覆盖率是100%。在Golang的测试用例那么难写的条件下,它是如何实现100%测试覆盖率的呢?
通过查看其源码,大概能得到一些启示:
- 当一个函数(或方法)在运行过程中没有新状态(传入的参数不算新状态)引入时,这个函数的测试用例编写起来是简单的。比如
MyLoveAge
函数,简单地返回一个年龄值,没有其他状态引入,因此测试用例写起来很简单。 - 当一个函数(或方法)在运行过程中引入新状态时,需要对这个状态进行Mock,测试用例写起来会比较复杂。
- 如果一个C函数在运行过程中引入了新的状态,而另一个A函数调用了C,那么A也会变成一个带状态的函数,需要对C函数或者C函数中引入的新状态进行Mock才能实现测试用例的编写。
# 调用链,A调用了B和C,B调用了M,其中‘↓’代表引入的新状态
|-A(↓)
|---B---C(↓)
|---M
比如对于上面的调用关系链所展示的,对M和B编写测试用例是很直观的,对C和A编写测试用例则需要对新状态或引入的新状态进行Mock。
jaeger项目中大部分的函数在运行过程中,尽量避免引入新的状态,大大方便了其测试用例的编写;少有一些带
可测试代码最佳实践
首先应该知道,有些场景根本无法避免新状态的引入,比如编写Web后端时,和数据库进行交互的函数。但即使这样,也要继续坚持写代码,不然房子还买不买了。。
目前能想到的实践包括:
- 把引入的新状态变成参数,以参数传入的方式使用。这个参数可以保存在最顶层的调用函数中,或者保存在包的全局变量中,可以根据数量和规模进行选择;
- 受到Golang语言特性的限制,现存的一些第三方测试框架,均没有解决Mock的问题,可以不必偏执某个测试框架,选择顺手的即可;
- 官方的GoMock本质上是对变量的Mock,如果想使用它,需要做到第一条;
- 使用VsCode编写Golang,多编写
myTestXxx
的测试函数,这种函数在go test
时不会运行,如果想打断点进行调试,临时删除my
留下TestXxx
即可。(对此项不要太依赖,尽量编写能通过的测试用例!)
参考
- GoMock框架使用指南
- GoConvey框架使用指南
- GoStub框架使用指南
- GoStub框架二次开发实践
- Golang单元测试之httptest使用
- 亿级用户日活千万的社交平台探探,如何用Go支撑后端工程实践
- smartystreets/goconvey Golang测试框架
- agiledragon/gostub GoStub框架二次开发实践对应的代码
- jaegertracing/jaeger 测试覆盖100%的代码库