《分布式对象存储》作者手把手教你写 GO 语言单元测试!

第一部分:如何写Go语言单元测试

Go语言内建了单元测试(Unit Test)框架。这是为了从语言层面规范写UT的方式。
Go语言的命名规则会将以_test.go结尾的go文件视作单元测试代码。
当我们用go build构建可执行程序时,这些_test.go文件被排除在构建范围之外。
而当我们用go test来进行单元测试时,这些_test.go文件则会参与构建,且还会提供一个默认的TestMain函数作为UT的起始入口。
接下来,就让我们通过一个例子来看看如何写Go语言的单元测试。

一个例子

首先让我们来看这样一段代码:

package db
//db包实现了一个DB结构体用来封装对某个数据库的访问

import (
	"someDB"
	//someDB提供了对实际数据库的
	//insert/get/delete等函数
)
...

type DB struct {
//DB结构体的内部细节忽略
...
}

//DB结构体提供了Put/Get/Delete
//三个方法,具体实现略
func (d *DB)Put(key, value string) error {
...
	someDB.insert(...)
...
}

func (d *DB)Get(key string) (string, error) {
...
	return someDB.get(...)
}

func (d *DB)Delete(key string) error {
...
	someDB.delete(...)
...
}

从上面的代码可以看到我们在db.go中实现了一个DB结构体用来抽象对某个数据库的访问。
现在,要为DB写UT,我们通常会将测试代码放在db_test.go中。
(虽然Go语言本身并不要求文件名的一一对应,但是这种约定俗成的命名规则能带给我们更好的可读性)。

package db
//UT的用例必须和代码在同一个包内

import (
	"testing"
	//testing包提供了测试函数
	//必须用到的数据结构
	...
)

//我们会为DB结构体的每一个
//方法都写一个测试函数
//这里先列出各测试函数的签名
//具体实现后面会给出
func TestPut(t *testing.T) {
...
}

func TestGet(t *testing.T) {
...
}

func TestDelete(t *testing.T) {
...
}

为了让Go语言的测试框架能够自动发现我们所有的测试用例,
测试函数的签名也要遵循其特定的规则:

  1. 函数名必须以Test开头,后面通常是待测方法的函数名
  2. 参数必须是*testing.T,它提供了Error,Fatal等方法用来报错和终止测试的运行

这些测试用例会在测试框架下并发地执行,并发度由go test时的-parallel参数指定。

具体的UT实现

TestPut

func TestPut(t *testing.T) {
	//为了进行测试,我们首先要创建
	//一个DB结构体的实例,具体参数略
	d := NewDB(...)
	
	//我们调用待测方法Put
	//将一些数据写入数据库
	err := d.Put("testputkey", "value")
	//必须检查返回的错误,确保返回nil
	if err != nil {
		//用Error来打印错误信息
		t.Error(err)
	}

	//接下来我们用someDB的get接口
	//来获取这些数据,这里注意尽量
	//避免用待测的DB.Get方法
	//原因见下
	value, _ := someDB.get(...)
	//校验数据
	if value != "value" {
		t.Error("some msg")
	}
}

在获取数据的时候不建议使用另一个待测方法Get,这样可以避免测试污染。
所谓测试污染是指由非待测函数导致的失败,比如TestPut的待测函数是DB.Put,如果我们使用DB.Get方法来获取数据,那么DB.Get如果出错就会导致测试用例失败,而此时我们需要额外的信息来判断究竟是Put出了问题还是Get出了问题。而someDB.get方法在someDB包里已经经过了测试,通常被认为是可信的。
我们会在后面的测试用例中看到类似的处理。

TestGet

func TestGet(t *testing.T) {
	d := NewDB(...)
	//首先测试Get不存在的key
	//尽可能让参数名字自解释
	_, err := d.Get("testgetnonexist")
	if err != ErrNotFound {
		t.Error("some msg")
	}

	//用someDB的insert接口
	//来写入一些测试数据
	err = someDB.insert(...)
	if err != nil {
		t.Fatal("some msg")
	}

	//然后调用待测方法Get读取这些数据
	value, err := d.Get("testgetkey")
	if err != nil {
		t.Error("some msg")
	}

	//校验数据
	if value != "value" {
		t.Error("some msg")
	}
}

Fatal和Error的区别在于Fatal在报错后会立即终止当前用例继续运行,如果insert失败,则后续的Get也没有意义,所以用Fatal终止。

TestDelete

func TestDelete(t *testing.T) {
	d := NewDB(...)
	//首先用someDB的insert接口
	//来写入一些测试数据
	err := someDB.insert(...)
	if err != nil {
		t.Fatal("some msg")
	}

	//然后调用待测方法Delete
	//删除这些数据
	err = d.Delete("testdeletekey")
	if err != nil {
		t.Error("some msg")
	}

	//用someDB的Get接口
	//来验证数据的删除
	_, err := someDB.get(...)
	if err != ErrNotFound {
		t.Error("some msg")
	}
}

运行测试的常见命令

  • 运行go test命令即可在编译并执行当前目录下的所有测试用例
  • 如果需要执行当前目录以及所有子目录中的测试用例,则运行命令go test ./...
  • 如果需要执行某个测试用例,比如单单执行TestGet用例则运行go test -run TestGet
  • 运行go test -help可查看详细的参数列表,比如之前提到的-parallel参数等

第二部分:如何写好GO语言单元测试

我们在第一部分已经见过了基本的单元测试框架,会写自己的单元测试了。
可是要想写出好的单元测试还不是那么简单,有很多要素需要注意。

用断言来代替原生的报错函数

让我们看这样一个例子:

if XXX {
	t.Error("msg")
}

if AAA != BBB {
	t.Error("msg2")
}

Go语言提供的Error太不友好了,判断的if需要写在前头。
这对于我们这些写UT行数还要超过功能代码的Go语言程序员来说,增加的代码量是非常恐怖的。
使用断言可以让我们省略这个判断的if语句,增强代码的可读性。
Go语言本身没有提供assert包,不过有很多开源的选择。比如使用https://github.com/stretchr/testify,上面的例子可以简化为:

assert.True(t, XXX, "msg")
assert.Equal(t, AAA, BBB, "msg2")

除了True和Equal之外当然还有很多其它断言,这就需要我们自己看代码或文档去发现了

避免随机结果

让我们看这样一个例子:

a := rand.Intn(100)
b := rand.Intn(10)
result := div(a, b)
assert.Equal(t, a/b, result)

UT的结果应当是决定性(decisive)的,当我们使用了随机的输入值来进行UT时,我们让自己的测试用例变得不可控。
当一切正常时,我们还不会意识到这样的坏处,然而当糟糕的事情发生时,随机的结果让我们难以debug。
比如,上例在大多数时候都能正常运行,唯有当b随机到0时会crash。在上例,比较正确的做法是:

result := div(6, 3)
assert.Equal(t, 2, result)

避免无意义重复

让我们看这样一个例子:

n := 10000
for i:=0; i<n; i++ {
	doSomeThing()
	assertSomeThing()
}

在设计UT时,我们要问问自己,重复执行doSomeThing多次会带来不同的结果吗,如果总是同样的结果,那么doSomeThing只做一次就足够了。
如果确实会出现不同的结果,那简单重复10000次不仅浪费了有限的CPU等资源,也比不上精心设计的不同断言能给我们带来的更多好处。
在上例,比较正确的做法是:

doSomeThing()
assertSomeThing()
doSomeThing()
//断言我们在第二次doSomeThing时
//发生了不同的故事
assertSomeThingElse()

尽量避免断言时间的结果

让我们看这样一个例子:

start := time.Now()
doSomeThing()
assert.WithinDuration(t, time.Now(), start, time.Second)

即便我们很笃定doSomeThing()一定确定以及肯定能在1秒内完成,这个测试用例依然有很大可能在某个性能很差的容器上跑失败。
除非我们就是在测试Sleep之类跟时间有关的函数,否则对时间的断言通常总是能被转化为跟时间无关的断言。
一定要断言时间的话,断言超时比断言及时更不容易出错。
比如上面的例子,我们没办法断言它一定在1秒内完成,但是大概能断言它在10微秒内完不成。

尽量避免依赖外部服务

即使我们十分确信某个公有云服务是在线的,在UT中依赖它也不是一个好主意。
毕竟我们的UT不仅会跑在自己的开发机上,也会跑在一些沙盒容器里,我们可无法知道这些沙盒容器一定能访问到这个公有云服务。如果访问受限,那么测试用例就会失败。
要让我们的测试用例在任何情况下都能成功运行,写一个mock服务会是更好的选择。
不过有些外部服务是必须依赖且无法mock的,比如测试数据库驱动时必须依赖具体的数据库服务,对于这样的情况,我们需要在开始UT之前设置好相应的环境。
此时也有一些需要注意的地方,见下节。

优雅地实行前置和后置任务

为了设置环境或者为了避免测试数据污染,有时候有必要进行一定的前置和后置任务,比如在所有的测试开始的前后清空某个测试数据库中的内容等。
这样的任务如果在每个测试用例中都重复执行,那不仅是的代码冗余,也是资源的浪费。
我们可以让TestMain来帮我们执行这些前置和后置任务:

func TestMain(m *testing.M) {
	doSomSetup()
	r := m.Run()
	doSomeClear()
	os.Exit(r)
}

TestMain函数是Go测试框架的入口点,运行m.Run会执行测试。
TestMain函数不是必须的,除非确实有必要在m.Run的前后执行一些任务,我们完全可以不实现这个函数。

测试用例之间相互隔离

TestA,TestB这样的命名规则已经帮我们在一定程度上隔离了测试用例,但这样还不够。
如果我们的测试会访问到外部的文件系统或数据库,那么最好确保不同的测试用例之间用到的文件名,数据库名,数据表名等资源的隔离。
用测试函数的名字来做前缀或后缀会是一个不错的方案,比如:

func TestA(t *testing.T) {
	f, err := os.Open("somefilefortesta")
	...
}

func TestB(t *testing.T) {
	f, err := os.Open("somefilefortestb")
	...
}

这样隔离的原因是所有的测试用例会并发执行,我们不希望我们的用例由于试图在同一时间访问同一个文件而互相影响。

面向接口编程

这是典型的测试倒逼功能代码。
功能代码本身也许完全不需要面向接口编程,一个具体的结构体就足够完成任务。
可是当我们去实现相应的单元测试时,有时候会发现构造这样一个具体的结构体会十分复杂。
这种情况下,我们会考虑在实际代码中使用接口(interface),并在单元测试中用一个mock组件来实现这个接口。
考虑如下代码:

type someStruct struct {
	ComplexInnerStruct
}

我们要为这个someStruct写UT,就不得不先构造出一个ComplexInnerStruct。
而这个ComplexInnerStruct可能依赖了几十个外部服务,构造这样一个结构体会是一件十分麻烦的事情。
此时我们可以这样做,首先我们修改实际的代码,让someStruct依赖某个接口而不是某个具体的结构体

type someStruct struct {
	someInterface
}
type someInterface interface {
	//只适配那些被用到的方法
	someMethod()
}

接下来我们的UT就可以用一个mock结构体来代替那个ComplexInnerStruct:

type mockStruct struct {}

func (m *mockStruct) someMethod() {
	...
}

s := &someStruct{
	someInterface: &mockStruct{},
}

这样,我们就帮自己省去了在UT中创建一个ComplexInnerStruct的繁杂工作。

结语

在工作中,我们一般都会将UT加入编译job作为代码提交流程的一部分。
有时我们会发现自己或其他同事写的UT换个环境就冒出一些难以调查的随机失败。
重启编译job并向程序员之神祈祷有时候确实可以让一些随机失败不再重现,但这只是掩盖了失败背后真正的问题。
作为一个有钻研精神的程序员,我们不妨仔细调查错误的可能成因,改良代码和UT的写法,让自己的生活更美好。

posted @ 2019-01-23 11:27  七牛云  阅读(679)  评论(0编辑  收藏  举报