吾尝终日而思矣,不如须臾之所学也;吾尝跂而望矣,不如登高之博见也。登高而招,臂非加长也,而见者远;顺风而呼,声非加疾也,而闻者彰。假舆马者,非利足也,而致千里;假舟楫者,非能水也,而绝江河。君子生非异也,善假于物也。

-- 荀况 《劝学》


Go 语言对于单元测试是很重视的,且不说其他的作者的背景啊,开源库啊,第三方的支持之类的,有两点让我对 Go 语言关于单元测试的重视程度的有信心的点在于:

  1. Go 语言源代码和内置库自身的单元测试完备性
  2. Go 语言自带单元测试命令

从这两点,我认为测试在 Go 语言中具有非常重要的地位,所以在这篇文章中,我也尝试讲一些关于 Go 语言单元测试的东西。

编写 Go 单元测试代码

Go 的测试方法看上去相对比较低级,它依赖于命令 go test 和一些能用 go test 运行的测试函数的编写约定。但是,我认为这就是所谓的 Go 风格,用 Go 以来,我的感受是 Go 语言就是保持了 C 语言编程习惯的一门语言。

首先,为了开始这篇文章,我写写一个简单的函数用作后面要测试的例子,但是,考虑到后面可能要讲一些稍微复杂一点的内容,所以,这个例子我留有一些可以改变的地方,大家可以选择着看:

这个例子就是这么简单,将这个文件命名为 main.go,然后我们就应该编写测试代码了。测试代码的文件放置的位置可以随意,package 也可以随意写,但是,文件名必须以 _test 结尾,所以,我这里就命名为 main_test.go

这里编写测试函数,有几个需要注意的点:

  1. 每个测试文件必须以 _test.go 结尾,不然 go test 不能发现测试文件
  2. 每个测试文件必须导入 testing
  3. 功能测试函数必须以 Test 开头,然后一般接测试函数的名字,这个不强求

根据这些条件,我们可以写出一个测试文件:

测试文件写完之后,我们就应该执行测试了,打开命令行工具,敲入这条命令:go test main_test.go main.go -v -cover

然后就应该等待测试结果了,这里加了两个参数,分别是 -v-cover,如果不加上的话你会发现只有 Test Pass 的简单提示,而看不到我们加了参数的具体提示:

=== RUN   TestAdd
--- PASS: TestAdd (0.00s)
PASS
coverage: 50.0% of statements
ok      command-line-arguments  0.008s

基于表的测试方式

在 Go 语言中,有一种常用的测试套路,叫做基于表的测试方式,其核心就是我们需要针对不同的场景,其实也就是不同的输入和输出来验证一个功能。例如我们要验证的 Add 函数,我们需要验证的功能点有很多,例如:

那么,我们就可以使用 基于表的测试方式 了,代码可以这样写:

Mock 依赖

前面介绍的测试都是比较简单的,功能简单的话我们就可以直接给定输入,然后看输出是否符合预期,这样就可以很简单得写完单元测试了。但是,有的时候,由于业务逻辑的复杂性,功能代码并不会就这么直接,往往还会掺杂很多其他组件,这就给我们的测试工作带来很大的麻烦,我这里列举几个常见的依赖:

组件依赖和函数依赖是两种比较常见的依赖,但是,这两种依赖也是可以扩展开来说的,既可能来自于我们自己编写的组件/函数,也可能是引入其他人写的。但是,无妨,对于这些情况,我们都会做一些分析。

组件依赖处理

使用 Go 语言开发项目的时候,我们应该经常会抽离组件模块的,既然抽离了组件和模块,那么久离不开组件的依赖了,既然有依赖,在测试的时候我们很多时候都是希望屏蔽掉依赖组件的影响,从而更好得测试现有代码的细节;或者说,我们希望根据自己的测试目标,控制依赖组件的行为。但是,如果我们还想简单得通过控制输入和输出来控制依赖组件的行为,这个难度还是比较大的,所以,在这种情况下,我们一般会考虑传一个 Stub 组件进入,从而达到控制依赖组件行为的效果。

举一个例子先,例如我们比较常见的 Service 层和 DAO 层的操作,Service 处理完逻辑之后,交给 DAO 层进行持久化,或者需要调用 DAO 层从持久化中获取一些必要的数据;在测试的时候,我们很多时候不希望真的持久化或者从持久化中获取数据,那么就会对 DAO 层进行一些 Mock。首先,先给出一个运行的例子:

这里我们想要测试 Service 的正确性,但是又不想要真的持久化 DAO,所以,这个时候我们会自己创建一个 Stub,然后提供给 Service,同时,我们还能操作 DAO 的行为,达到运行得效果,例如:

这样我们就能测试到登录成功的代码了,登录成功的测试了,我们还希望测试一下登录失败的,那么也很简单,修改一下就可以了呗:

这里对测试代码稍微改了一下,可以发现,我们可以通过修改一个变量来控制 Stub 的输出,从而达到测试不同功能的效果,这就解决了组件依赖的问题。

函数依赖

函数依赖相比于组件依赖会更麻烦一点,因为我们在前面可以看到,组件依赖的话我们可以传递 Stub 进行,这样我们可以随意得控制 Stub 的行为,但是函数不行呀,这里我们又不能传函数进去,因为函数是被 import 进去的啊。问题就在这了,因为函数是被 import 进去的,所以可以理解为函数是全局的了,既然这样,那么我们为什么不修改一下函数呢?什么意思?我们先来看着正常的业务例子:

这里我是想表达的一个意思就是需要先登录,然后登录完之后我们才能回复消息,这里我们的登录逻辑是简单的,但是,在实际业务中可能这里的登录逻辑就设计到 DB 访问等等,我们希望不走真实的逻辑,而是自己来控制 Login 的行为。

首先,先分析一下我们的 UT 目的,我们的目的是测试 Reply 函数,我们期望是 Login 成功,那么 Reply 也应该是成功的;如果 Login 失败,那么 Reply 也应该是失败的。这个测试结论不应该被 Login 所影响,及时以后 Login 逻辑修改了,我们也应该是这个逻辑,不会受到影响,那么我们可以这么编写 UT:

这里可以发现,我们是修改了 Login 这个函数的代码,从而控制 Login 函数的返回值,这样我们就可以测试我们写的代码的逻辑是否正确了。

可能有心细的同学发现,第二个函数依赖里面的代码有个特别的地方,在于函数 Login 是个变量,为什么要用函数变量呢,不能直接使用函数吗?这里确实是一个麻烦得地方,因为如果使用直接函数的话,我们没得赋值,那么也就无法修改它了。如果代码不是我们自己写的,而是使用的其他同事的代码,那么问题就大了。这种情况下,我们可以怎么处理呢?

在 Reference 的中有一篇文章:Mocking functions in Go 介绍了一种方法,那就是将我们引用的函数赋值为函数变量再使用,从而达到同样的效果。

第三方库

在前面的介绍中,我们都是自己重新写了一个 Stub 类,但是同时问题暴露了,写起来比较复杂和繁琐,所以就有了一些第三方库可以方便我们编写这些东西,Gomock 就是一个,这里就简单演示一下 GoMock 的一些功能。

gomock 有两个组件,分别是 gomockmockgenmockgen 可以根据我们的 interface 生成对应的 Stub 对象,例如我们前面提到的 DAO,因为我们 DAO 的 Interface 已经写好了,所以我们可以很方便得生成 StubDao,只需要使用命令:

$ mockgen -source=main.go > mock_dao.go

然后我们查看一下 mock_dao.go 的内容:

可以发现两个函数都被实现了 ReadAllSaveData,但是,同时我们也发现,生成的 DAO 比我们预期的要复杂得多,至于为什么药这么复杂,看看接下来的使用就明白了。现在我们已经有了 DAO,那么下一步就是控制 DAO 的输出了。

这里可以看到,使用 gomock 可以让我们的控制输出更加简便,之前我们需要通过控制变量的方式来达到控制输出的目的,但是这里可以很方便得使用:

d.EXPECT().FuncXXX.Return(xxxx)

来指定函数应该返回什么结果,确实方便了,同时,还省去了我们自己编写 MockClass 的时间,这个还是值得一试的。

总结

OK,本文关于 Go 的 UT 就差不多这么多了,这里介绍了 Go 单元测试的编写套路,以及介绍了我们在项目中常见的一些场景的处理,最后再介绍了一款第三库用于加快 UT 的编写。在我另一篇文章:从开源项目看 Python 单元测试 中我曾经提到,UT 是我们都希望别人写,但是自己又不想写的东西,希望,Go 语言相对简单的编写套路和模式能够让大家对 UT 有所重视,并且愿意写起来。lol。

Reference

  1. Go程序设计语言
  2. Mocking functions in Go