当前位置: 首页 > 工具软件 > Testify > 使用案例 >

如何写好测试用例以及go单元测试工具testify简单介绍

潘英豪
2023-12-01

背景

​ 最近在工作和业余开源贡献中,和单元测试接触的比较频繁。但是在这两个场景之下写出来的单元测试貌似不太一样,即便是同一个代码场景,今天写出来的单元测试和昨天写的也不是很一样,我感受到了对于单元测试,我没有一个比较统一的规范和一套单元测试实践的方法论。在写了一些单元测试之后我开始想去了解写单元测试的一些最佳实践和技巧。(其实后来我反思的时候觉得,我应该先去学习单元测试相关的最佳实践,现有一个大致的概念,再去实操会好一些。)在这里总结成一篇文章分享给大家,希望读者朋友们有所收获。

1. 为什么要写单元测试

​ 单元测试是一个优秀项目必不可少的一部分,在一个频繁变动和多人合作的项目中显得尤为关键。站在写程序的人的角度出发,其实很多时候你并不能百分之百确定你的代码就是一点问题都没有的,在计算机的世界里其实不确定的因素很多,比如我们可能不确定代码中的一些依赖项,在实际代码执行的过程中他会符合我们的预期,我们也不能确定我们写的逻辑是否可以涵盖所有的场景,比如可能会存在写了if没有写else的情况。所以我们需要写自测去自证我们的代码没有问题,当然写自测也并不可以保证代码就完完全全没有问题了,只能说可以做到尽可能的避免问题吧。其次对于一个多人参与的项目来说,开源项目也好,工作中多人协作也好,如果要看懂一段逻辑是干嘛的,或者要了解代码是怎么运作的,最好的切入点往往是看这个项目的单元测试或者参与编写这个项目的单元测试。我个人要学习一个开源项目也是首先从单元测试入手的,单元测试可以告诉我一段逻辑这段代码是干什么的,他的预期是输入是什么,产出是什么,什么场景会报错。

2. 如何写好单元测试

​ 这一章节将会介绍为什么一些代码比较难以测试,以及如何写一个比较好的测试。在这里会结合一些我看过的一些开源项目的代码进行举例讲述。

2.1 什么代码比较难测试

​ 其实不是所有的代码都是可以测试的,或者说有的代码其实是不容易测试的,有时候为了方便测试,需要把代码重构成容易测试的样子。但是很多时候在写单元测试之前,你都不知道你写的代码其实是不可以测的。这里我举go-mysql的一些代码例子来阐述不可测或者不容易测的因素都有哪些。

go-mysql 是pingcap首席架构师唐刘大佬实现的一个mysql工具库,里面提供了一些实用的工具,比如canal模块可以消费mysql-binlog数据实现mysql数据的复制,client模块是一个简单的mysql驱动,实现与mysql的交互等等,其他功能可以去github上看readme详细介绍。最近由于工作需要看了大量这个库的源码,所以在这里拿一些代码出来举举例子。

2.1.1 代码依赖外部的环境

​ 在我们实际些代码的时候,实际一部分代码会比较依赖外部的环境,比如我们的一些逻辑可能会需要连接到mysql,或者你会需要一个tcp的连接。比如下面这段代码:

/*
	Conn is the base class to handle MySQL protocol.
*/
type Conn struct {
	net.Conn
	bufPool *BufPool
	br      *bufio.Reader
	reader  io.Reader
	copyNBuf []byte
	header [4]byte
	Sequence uint8
}

​ 这个是go-msyql处理网络连接的结构体,我们可以看到的是这个结构体里面包裹的是一个net.Conn接口,并不是某一个具体的实现,这样子提供了很灵活的测试方式,只需要mock一个net.Conn的实现类就可以测试他的相关方法了,如果这里封装的是net.Conn的具体实现比如TCPConn,这样就变得不好测试了,在写单元测试的时候你可能需要给他提供一个TCP的环境,这样子其实比较麻烦了。

​ 第二个例子来自go-mysql canal这个模块,这个模块的主要功能通过消费mysql binlog的形式来复制mysql的数据,那么这里的整体逻辑怎么测试呢,这个模块是伪装成mysql的从节点去复制数据的,那么主节点在哪里呢,这里就要切切实实的mysql环境了。我们可以看看作者是怎么测试的,这里代码太长我就不贴出来了,把GitHub的代码链接贴在这里,感兴趣的读者可以去看点击这里看github代码。作者在CI环境里弄了一个mysql的环境,然后在测试之前通过执行一些sql语句来构建测试的环境,在测试的过程中也是通过执行sql的方式来产生对应的binlog去验证自己的逻辑。

2.1.2 代码太过冗余

​ 有时候写代码可能就是图个爽快,一把梭哈把所有的逻辑都放在一个函数里面,这样就会导致过多的逻辑堆积在一起,测试的时候分支可能过多,所以为了单元测试看起来比较简洁可能需要我们把这样的逻辑进行拆分,把专门做一件事情的逻辑放在一起,去做对应的测试。然后对整段逻辑做整体测试就好。

2.2 如何写好一个单元测试

​ 为了方便去描述这个一些内容,这里我简单的提供一个这样的函数。这个函数逻辑比较简单,就是输入一个名字,然后返回一个跟你打招呼的信息。

func Greeter(name string) string {
	return "hi " + name
}

​ 那么如何写这个函数的测试呢。我理解有两个关键的点,一是单元测试的命名,二是单元测试的内容架构。

2.2.1 单元测试的命名

​ 命名其实也是有讲究的,我理解单元测试也是给别人看的,所以当我看你写的单元测试的时候,最好在命名上有:测试对象,输入,预期输出。这样可以通过名字知道这个单元测试大致内容是什么。

2.2.2 测试内容架构

​ 测试的内容架构主要是这几件事情:

  1. 测试准备。在测试之前可能需要准备一些数据,mock一些入参。
  2. 执行。执行需要测试的代码。
  3. 验证。验证我们的逻辑对不对,这里主要做的是执行代码之后预期的返回和实际返回之间的一个比对。

所以综合上面两点,比较好的实践是这样的。

// 比较详细的写法,测试的是什么(Greeter), 入参是什么(elliot), 预期结果是什么(hi elliot)
func Test_Greeter_when_param_is_elliot_get_hi_Elliot(t *testing.T) {
  // 准备
	name := "elliot"
  // 执行
	greet := Greeter(name)
  // 验证
	assert.Equal(t, "hi elliot", greet)
}

// 比较省略的写法,测试的是什么(Greeter), 入参是name, 预期结果是一个打招呼的msg,GreetMsg
func Test_Greeter_name_greetMsg(t *testing.T) {
  // 准备
	name := "elliot"
  // 执行
	greet := Greeter(name)
  // 验证
	assert.Equal(t, "hi elliot", greet)
}

这里要注意一个问题,尽量避免执行和验证的代码写在一起,比如写成这样子:

assert.Equal(t, "hi elliot", Greeter("elliot"))

这样子其实在功能上是一样的,但是会影响代码的可读性。不是特别推荐。

3. 什么是好的测试

​ 在讲了如何写一个单元测试之后,我们来说说什么的测试才是好的测试。我个人认为一个好的测试应该具备一下三点:

  1. 可信赖。首先我们写的单元测试的作用是测试某一段逻辑的正确性,如果我们的写的单元测试都是不值得信赖的,那么又如何保证测试的对象是值得信赖的呢?有时候一些单元测试也有可能时好时坏,比如一个单元测试依赖一个随机数去做一些逻辑,那么本身这个随机数就是不可控的,可能这下执行是好的,下一次执行就过不了了。
  2. 可维护。业务逻辑会不断的迭代,那么单元测试也会跟着不断的迭代,如果每次改单元测试都要花很多时间,那这个单元测试的可维护性就比较差了。其实把所有逻辑都塞在一个函数里,我个人认为这样子的代码对应的单元测试可维护性是比较差的,全部堆在一块意味着每次的改动所带来的单元测试的改动都需要兼顾全局的影响。如果尽可能的拆分开来,可以实现单元测试的按需改动。
  3. 可读性。最后的也是最重要的 就是单元测试的代码的可读性了,一个无法让人理解的单元测试其实和没写没什么区别,无法理解基本也以为着不可信赖和不可维护。我代码都看不懂怎么信任你呢?所以保障代码的可读性是很重要的。

​ 其实讲了一些概念之后对怎么样写好一个测试我们还是没什么印象的,那么可以从一些不好的case去入手,我们知道了那些实践是不好的之后,就会对好的实践有一个大致的认识。

  1. 可读性低的测试,上面写到的对greeter函数的单元测试中,其实这段代码写的不是很好的,可读性比较低。因为对于读这段代码的人来说,我都不知道这个“hi elliot”是什么,他为什么会出现在这里。如果把他稍微命名成一个变量的话可读性会高一些。
// 可读性比较低,因为读者并不知道这个“hi elliot”是什么
assert.Equal(t, "hi elliot", greet)

// 这样就会好一些
expectedGreetMsg := "hi elliot"
assert.Equal(t, expectedGreetMsg, greet)
  1. 带有逻辑的测试。作为一个单元测试,应该尽量避免里面带有逻辑,如果有过多的逻辑在里面,那么就会演变成他本身也是需要测试的代码,因为过多的逻辑带来了更多的不可信赖。
  2. 有错误处理的测试。在单元测试中不要带有错误处理的逻辑,因为单元测试本身就是用来发现程序中的一些错误的,如果我们直接把panic给捕获了,那么也不知道代码是在哪里错的。另外对于单元测试来说错误应该也是一种预期的结果。
  3. 无法重现的测试。这个《单元测试的艺术》这本书里提供了一个比较有意思的例子。在代码中使用了随机数进行测试,每次产生的随机数都不一样,意味着每次测试的数据也就不一样了,这意味着这个测试代码可信赖程度比较低。
  4. 单元测试之间尽量隔离。尽量做到每个单元测试之间的数据都是自己准备的,尽量不要共用一套东西,因为这样做就意味着一个单元测试的成功与否与另外一个单元测试开始有了关联,不可控的东西就增加了。举个例子,nutsdb的单元测试有几个全局变量,其中大多数单元测试的db实例是共用的,如果上一个单元测试把db关闭了,或者修改了一些配置重启了db,对于下一个单元测试来说他是不知道别人操作了什么,等他执行的时候有可能就会出现意想不到的错误。
  5. 每一个单元测试都尽量独立。每个单元测试尽量可以独立运行。也不要有先后顺序,不要在一个测试里去调用另外一个测试。

在讲完大概比较好的单元测试实践之后,我们可以稍微提升一下。我们不妨假设有这么一个场景,其实是测一段逻辑,但是会有好几个测试用例需要测试,那么我们需要写好几个测试的函数嘛?其实是不用的,这里就涉及到了,参数化测试,什么意思呢?我们直接举例吧。看下面这段代码。

func isLargerThanTen(num int) bool {
	return num > 10
}

func TestIsLargerThanTen_All(t *testing.T) {
	var tests = []struct {
		name     string
		num      int
		expected bool
	}{
		{
			name:     "test_larger_than_ten",
			num:      11,
			expected: true,
		},
		{
			name:     "test_less_than_ten",
			num:      9,
			expected: false,
		},
		{
			name:     "test_equal_than_ten",
			num:      10,
			expected: false,
		},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			res := isLargerThanTen(test.num)
			assert.Equal(t, test.expected, res)
		})
	}
}

​ 这里面测试的是一个判断入参是否大于10的函数,那么我们自然而然的想到三个测试用例,参数大于10的,等于10的,小于10的。但是实际上这三个测试用例都在测试一段逻辑,实际上是不太需要写三个函数的。所以把这三个测试用例和对应的预期结果封装起来,在for循环里面跑这三个测试用例。个人觉得这是一种比较好的测试方法。

3. go测试工具推荐

​ 在讲完上面的一些测试方法之后,在这里推荐一些在go里面的测试工具。其中最著名的testify就是不得不推荐的了。很多开源项目都在用这个库构建测试用例。说到这里突然想到之前有人给goleveldb提交pr代码写自己的单元测试时引入了这个库,我还“批斗”了他,说修改代码和引入新的库是两码事,请你分开做hhhh,现在想想还蛮不好意思的。回归正题,我们来简单介绍一些testify这个库。

3.1 testify

​ testify这个库主要有三个核心内容,assert, mock, suite。assert就是断言,可以封装了一些判断是否相等,是否会有异常之类的。文章篇幅有限,这里就不对assert的api一一介绍了,感兴趣的朋友们可以看衍生阅读的相关文章。这里我主要介绍mock和suite模块。

3.1.1 mock

在我们要准备测试的时候经常需要准备一些数据,mock模块通过实现接口的方式来伪造数据。从而在测试的时候可以用这个mock的对象作为参数进行传递。废话不多说我们看下怎么简单的实践一下。

首先我们定义一个接口:

//go:generate mockery --name=Man
type Man interface {
	GetName() string
	IsHandSomeBoy() bool
}

这个接口定义了一个男孩子,一个方法是获取他的名字,第二个方法是看他是不是帅哥。这里我还推荐使用go:generate的方式执行mockery(执行go get -u -v github.com/vektra/mockery/…/安装)命令去生成对应的mock对象(生成的代码会放在当前目录的mocks目录下,当然你也可以在命令上添加参数指定生成路径),这样就不需要我们去实现mock对象的一些方法了。下面我们看下生成的代码是怎么样的。

// Code generated by mockery v2.10.0. DO NOT EDIT.

package mocks

import mock "github.com/stretchr/testify/mock"

// Man is an autogenerated mock type for the Man type
type Man struct {
	mock.Mock
}

// GetName provides a mock function with given fields:
func (_m *Man) GetName() string {
	ret := _m.Called()

	var r0 string
	if rf, ok := ret.Get(0).(func() string); ok {
		r0 = rf()
	} else {
		r0 = ret.Get(0).(string)
	}

	return r0
}

// IsHandSomeBoy provides a mock function with given fields:
func (_m *Man) IsHandSomeBoy() bool {
	ret := _m.Called()

	var r0 bool
	if rf, ok := ret.Get(0).(func() bool); ok {
		r0 = rf()
	} else {
		r0 = ret.Get(0).(bool)
	}

	return r0
}

那么我们怎么使用呢?看看下面代码:

func TestMan_All(t *testing.T) {
	man := mocks.Man{}
  // 可以通过这段话来添加某个方法对应的返回
	man.On("GetName").Return("Elliot").On("IsHandSomeBoy").Return(true)
	assert.Equal(t, "Elliot", man.GetName())
	assert.Equal(t, true, man.IsHandSomeBoy())
}

3.1.2 suite

​ 有时候我们可能需要测的不是一个单独的函数,是一个对象的很多方法,比如想对leveldb的一些主要方法进行测试,比如简单的读写,范围查询,那么如果每个功能的单元测试都写成一个函数,那么可能这里会重复初始化一些东西,比如db。其实这里是可以做到共享一些状态的,比如数据写入之后就可以测试把这个数据读出来,或者范围查询。在这里的话其实用一种比较紧密的方式把他们串联起来会比较好。那么suite套件就应运而生。这里我就不打算在详细介绍了,感兴趣的读者可以移步衍生阅读中的《go每日一库之testify》。我理解这篇文章讲的比较清晰了。但是这里的话我可以提供nutsdb的一个相关测试用例大家参考:https://github.com/nutsdb/nutsdb/blob/master/bucket_meta_test.go 大家感兴趣的话也可以参考这段代码。

4. 总结

​ 这篇文章主要是总结最近我在单元测试上面的一些思考和沉淀,以及对go的测试工具的粗略讲解。在本文中使用到的一些开源项目的源码,主要是分享一些自己的思考,希望对大家有所帮助。

延伸阅读

  1. Best Practices for Testing in Go:https://fossa.com/blog/golang-best-practices-testing-go/#Catch
  2. 《单元测试的艺术》
  3. go每日一库之testify:https://segmentfault.com/a/1190000040501767
  4. 使用testify和mockery库简化单元测试:https://segmentfault.com/a/1190000016897506
 类似资料: