1. 轻松玩转Golang单元测试

add: 2021-10-27

最近一年多基于云原生方案开始做海外直播平台的建设,也一直在思考golang的单元测试实践方案。通过查阅相关文档书籍,调研了单元测试工程实践落地经验,发现golang单元测试方案五花八门,也各有千秋。本文主要介绍了golang单元测试框架中涉及的go test命令和testing包之间的关系及使用方式;针对项目代码编写特点,灵活运用第三方测试辅助工具包,给出了各个场景下的单元测试实践案例作为参考。

1.1. 单元测试的含义及设计原则

单元测试是指软件的单个单元或组件被测试,目的是验证软件代码的每个单元是否按照预期执行。单元测试是对单个单元行为的测试,我们编写测试时,假设除了该单元之外的所有东西都正常工作。在当前项目后台Golang代码中单元一般是指单个函数/成员方法/接口方法。

单元测试的测试用例是从各种边界条件"举例子"的方式来进行测试这个函数,思考数据结构,并合理地设计如何根据合适的输入(包括异常输入)得到输出是编写单元测试的关键。通过用例也可以很方便理解和重构代码,有些代码分支走到的概率很小(如文件IO),单元测试能有效检查代码逻辑在极端情况下的运行情况。要记住:"测试旨在发现bug,而不是证明其不存在"。无论多少测试都无法证明一个包是没有bug的,所以单元测试只能增强我们的信心,说明这些代码是可以在已经验证过的重要的场景下使用。

1.2. 单元测试框架:testing包与go test命令

testing包为Go语言提供单元测试框架,它依赖go test命令搭配对应选项一起配合完成整个测试。二者配合的编写约定是:

  1. 在一个包目录中,以_test.go结尾的文件是单元测试文件,也是go test编译的目标,go test扫描_test.go文件来寻找其中用来测试的特殊函数,并生成一个临时的main包来调用它们,然后编译和运行,并汇报测试的结果,最后清空临时文件。
  2. 每一个以test.go结尾的测试文件必须导入testing包,上述用来测试的特殊函数以Test开头,以Xxx结尾(注意Xxx的第一个字母必须为大写或为下划线)。函数形式如:func TestXxx(t testing.T),参数t用于管理测试状态并支持格式化测试日志。测试日志会在执行测试的过程中不断累积,并在测试完成时转储至标准输出。

看一个简单的例子,新建一个unit_test module,写一个calc.go的源代码文件:

img

vscode中右键选择Go:Generate Unit Tests For Function,在同级目录下自动生成了一个calc_test.go文件,并在// TODO: Add test cases.下面写两个测试用例:

img

使用go test命令测试:go test -v ./...,输出如下:

img

可以看到go单元测试流程非常简洁,程序员只需要构造测试用例数据,然后t.Run就会运行一个测试用例来执行一次子测试(subtest),最后使用t.Erroff将失败的结果输出即可。go test命令有丰富的选项来控制这个过程,上面-v选项可以输出包中每个测试用例的名称和执行的时间。可以通过go help test和go help testflag查找命令选项的详细信息。此外,除了t.Errorf,也可以使用其他方法灵活控制输出,例如使用t.Skipf来直接报告这个子测试的错误,而不再执行下面流程。

上面例子中calc_test.go的包声明是main,和calc.go文件的包名相同,这也被称为内部测试包。内部测试包容易引起循环引用,考虑下面例子:在unit_test/calc/calc.go中有一个函数Add,它调用unit_test/calchelper/calchelper.go中的公共辅助函数AddHelp,在unit_test/calchelper/calchelper_test.go中AddHelp函数测试代码如下:

img

在测试用例的结果比对中,除了比对AddHelp的结果,还将其与Add函数的结果做比对,但是遗憾的是由于Add函数调用了calchelper.AddHelp,在calchelper包中就不可能再调用calc.Add,因为这样形成了包的循环引用,无论是在vscode还是运行go test都会提示import cycle not allowed in test。解决的办法是在calchelper_test.go文件中将包声明改成calchelper_test,使这个测试文件属于外部测试包。这样calc和calchelper包就成了这个外包测试包的依赖包:

img

一个测试文件的包声明后缀_test文件会被go test工具单独编译成一个包后被运行,所以带_test后缀的文件可以像一个单独的程序一样,自由导入其他的包,而不会出现循环导入。

这里有一个需要注意的点是:由于外部测试包可以看成是一个单独的程序,所测试的包对它来说是一个"外部包",那么它就不能直接调用待测试源文件包的非导出函数。这种非导出函数的测试问题可以参考golang自带的fmt包(/usr/local/go1.13.4/go/src/fmt)中fmt_test.go文件,在fmt_test.go文件中声明的是package fmt_test,是一个外部测试包,其中TestIsSpace便是测试fmt/scan.go中的isSpace函数,其实现的方式是通过在fmt/export_test.go中添加这个函数的声明(var IsSpace = isSpace)来导出函数来实现的,其中export_test.go的包声明为package fmt。这样就巧妙的实现了只有测试包才能访问这个非导出函数的目的。如果一个*_test.go文件存在的唯一目的就在于此,并且自己不包含任何测试,它们一般称作export_test.go。

使用go list -f={{.GoFiles}} 包名/go list -f={{.TestGoFiles}}包名/go list -f={{.XTestGoFiles}}包名,分别列出一个包的源文件/内部测试包文件/外部测试包文件。

1.3. 单元测试最佳实践

1.3.1. Table-Driven

在上面自动生成_test.go文件的测试函数代码中就是基于TableDriven生成的,// TODO: Add test cases.下的测试用例数组就是测试用例表,里面的每个元素都是一个完整的测试用例,有输入和预期的结果,还包含一些附件信息,比如测试名称使得测试输出易于阅读。

t.Errorf提供了详细的错误消息:提供了待测试函数的运行结果和预期的结果;当测试失败时,不需要阅读测试代码就可以清楚地看到哪些测试失败了,为什么失败了。

在源码库/usr/local/go1.13.4/go/src/strconv/itoa_test.go中有相当多的例子采用TableDriven生成,例如TestUitoa:将用例表提取到一个单独的变量(函数)中,测试函数只关注测试逻辑和输出。

img

当测试数据过多时,可以将被测数据放入文件并放在testdata文件夹下,除了go test命令,Go的其他工具命令(tool)会直接忽略名为"testdata"的目录。所以go build会忽略名为testdata的文件夹;并且当ge test运行时,它将当前目录设置为包目录,这使得可以使用相对路径testdata目录作为存储和加载数据的地方,具体使用testdata的例子可以参考go源码库/usr/local/go1.13.4/go/src/net/dnsclient_unix_test.go中对同级目录testdata下hosts文件的读取和使用。

1.3.2. GoConvey

IDE自动生成的测试函数中,如果待测试的用例业务逻辑复杂,需要判断的条件有嵌套,使用t.Errorf打印的地方也很多,这会让if和t.Errorf之间的嵌套看起来非常乱,也会给后续用例的管理和维护带来不便,所以对于有多个分支层次的测试函数推荐使用GoConvey包。GoConvey包易于使用(只有Convey和So两个函数),与testing包和go test命令可以直接配合,并且输出的测试结果更加可读,还有WebUI供可视化使用(可选),使用样例如下:

img

可以看到使用goconvey包使得测试函数简洁优雅,每个测试用例必须使用Convey函数包裹起来,Convey语句可以无限嵌套,以体现测试用例之间的层次关系。这里推荐大家跟示例写法保持一致:一个测试函数最外层只有一个Convey,里面可以嵌套多层Convey,同级的Convey表示不同条件下的测试场景。Convey函数第一个参数为用例的描述,只有最外层的Convey第二个参数是测试函数的入参(*Testing),第三个参数为用例判断函数(习惯使用闭包),最后通过So函数完成断言判断即可。So函数第一个参数为输入的值,第二个参数为断言条件,第三个参数为期望值,goconvey包定义了大部分的基础断言条件,如果有需要,也可以自己定义。注意:层级相同的子Convey的执行策略是并行的,但是一个Convey下的子So执行是串行的。

1.3.3. GoStub&Monkey(GoMock&GoSqlmock&HttpMock)

IDE自动生成的测试函数中的用例都是静态数据组成的数组,但是实际要测试的函数往往要调用其他函数/结构体方法/接口,并可能使用多个全局变量。这就需要对这些全局变量/函数/结构体方法/接口进行打桩mock,

1. 全局变量使用GoStub来mock,只需要使用Stub函数对全局变量进行打桩,下面示例中stubs是GoStub框架的函数接口Stub返回的对象,该对象有Reset操作,即将全局变量的值恢复为原值,在测试执行期间可更改变量(v1)值或给更多变量(b2)打桩。

img

2. 函数/结构体成员方法使用monkey来mock,monkey包通过在运行时用汇编指令修改跳转的函数地址来实现对一个函数/结构体成员方法的mock。monkey包的API十分简单直接,调用monkey.Patch(, )函数就可以实现对目的函数的替换,使用monkey.Unpatch()来恢复目的函数。例如想对fmt.Println进行mock:

img

调用monkey.PatchInstanceMethod(, , )函数实现对结构体成员方法的替换,第一个参数是reflect.TypeOf(实例),第二个参数是方法名,第三个参数把方法绑定到类型上,使用monkey.UnpatchInstanceMethod(, )来恢复目的函数。例如对原生net包下func (d *Dialer) Dial(network, address string) (Conn, error)的mock示例如下(注意PatchInstanceMethod是对所有实例的这个成员方法的一次mock):

img

注意:Monkey包不能在函数内联的情形下patch,所以使用go test -gcflags=-l选项关闭内联。Monkey中的函数也不是线程安全的。另外Patch和PatchInstanceMethod返回*monkey.PatchGuard类型变量,可以直接调用其Unpatch方法来恢复目的函数,也可以调用monkey.UnpatchAll来恢复所有mockeypathches。

3. 接口使用go mock来mock,go mock是官方自带的接口gomock包,这个包完成对桩对象生命周期的管理,GoMock还包含一个mockgen命令行工具,用来自动生成interface对应的Mock类源文件。由于项目中interface使用较少,GoMock以及SqlMock和HttpMock的使用不再详细叙述,查询包github相关文档即可。

1.3.4. 单元测试实践总结

编写单元测试函数总体思路就是通过TableDriven的方式,在构造测试用例数据时,根据测试数据规模和用例复杂度灵活运用上面所说的方法来构造,对于静态数据可以考虑用数据表或者放置testdata文件夹下,对于外部依赖的数据/函数/方法/接口使用3.3节介绍的外部工具包构造。

另外业务的公共代码本身单元测试后也成为了别人的依赖,测试代码也是计入复杂度的,会出现重复代码,如果不分离,复杂度也会上升。因此,公用变量/函数的mock数据可以放在一个单独的mockdata文件夹下,其他调用函数编写单元测试时就可以方便调用,最后用Goconvey进行各个用例场景层次的测试断言即可

本文摘自: 轻松玩转Golang单元测试

results matching ""

    No results matching ""