移动测试开发 盘点 Go 代码质量提升的那些绝妙的测试方法

opentest-oper@360.cn · 2023年02月09日 · 3786 次阅读

大家对于 Go 语言可能不陌生,但在日常工作当中,对 Go 语言本身提供的单元测试、覆盖率等工具可能并不熟悉。本文将简单介绍一下 Go 语言提供的各种方便提升代码质量的工具,供大家参考,并在工作中灵活使用,以提升代码的质量。

主要介绍内容包括,Go 语言及其周边工具提供的单元测试能力、Benchmark 功能、代码覆盖率、Fuzz 测试能力以及数据竞争检查。

案例编写

正式介绍之前,我们需要编写一个案例,是一个本地记账软件,会将收支记录下来,并提供平均值等简单统计指标的计算功能。

首先,我们定义好账户中每条记录的类型枚举:

然后,我们定义账户中每条记录的结构以及账户的结构:

对于账户,我们首先要支持的就是记账,我们的记账类型分为收入和支出,因此我们也定义两个方法,分别用来完成收入和支出操作。

接下来,是账户支持的平均数统计功能,其可以返回账户内简单的统计结果。

最后,除了记账外,我们还支持对记录的随机访问和删除。可以看到,账户中记录的删除操作支持批量操作,为了性能考虑,我们使用了 Go 语言提供的 goroutine 来并发完成,充分利用了 Go 语言的并发能力。

到此,我们初步完成了我们程序的功能。
但是,它是否存在问题呢?我们接下来通过 Go 语言提供的各种测试能力,来发现潜在的问题。

单元测试

单元测试是指对代码中最小可测试单元进行检查的测试和验证方法。我们庞大的代码仓库也是由一个一个小的逻辑和单元组成起来的,就像摩天大楼是由一块一块砖堆砌起来的一样。而单元测试就是对这些 “砖” 进行测试,只要这些小的单元正常执行,我们的 “大楼” 才有可能拔地而起。

Go 语言本身的工具链支持完备的单元测试能力,同时提供了 testing 包来控制测试的流程,与此同时,也可以使用各种开源库来书写更加紧凑的断言等,如本文使用的 github.com/go-playground/assert/v2。

在 Go 中,所有以_test.go 结尾的文件,均会被认为是单元测试文件,会被 go 命令识别,并可通过 go test 来执行,并输出报告。文件中 Test 开头的函数为我们的测试函数。

为了用来检查我们上文中实现的功能是否存在问题,我们接下来为上文中的代码实现单元测试,首先创建 main_test.go,并添加如下代码:

在测试函数中,我们首先创建一个账户,然后增加收入记录 1 条,增加支出记录 1 条。并通过账户提供的 OperationCount 函数计算出每种操作类型的数量。然后通过 assert 包完成断言。

在我们执行 go test -v(-v 表示输出更多信息)后,我们得到了如下输出:

可以看到,我们的测试并未通过,其中 15 行 income 是 2,而不是我们期待的 1。通过代码 Review 我们发现,在 TakeIn 函数和 PayOut 函数中,我们忘记给 Type 进行赋值,从而导致记录的类型出错,因此断言才没有通过。

在知道原因后,我们将代码更改为如下样子:

此时再执行单元测试,我们发现代码可以成功的通过测试。

Benchmark

性能通常来说至关重要,在完成功能和保证正确性的同时,性能当然是越快越好。有时候由于算法、编码方式等不同,性能也有很大的差别。为了能够量化这些差异,为我们选择更快的算法提供理论依据,Go 语言也提供了 Benchmark 功能。

和单元测试一样,Benchmark 也存在于以_test.go 结尾的文件中。但和单元测试不同的是,Benchmark 函数均以 Benchmark 开头,并通过 go test -bench=.来执行。

在本文的示例中我们发现 OperationCount 执行较慢,因此提供了一个优化的版本 OperationCountFast 函数。

为了能够更准确的知道优化后版本究竟快了多少,我们提供了对应的 Benchmark 函数:

可以看到,两个 Benchmark 函数几乎一样,只是调用的方法不同。在执行 go test -v -bench=. -run=#(# 表示跳过单元测试,仅执行 Benchmark)后,我们得到了以下输出:

可以看到,新的 AccountOperationFast 执行速度是 AccountOperation 的三倍,通过量化后的数据,我们可以放心的选择 AccountOperationFast 来获得更好的性能。

代码覆盖率

代码覆盖率可以配合单元测试,来查看我们单元测试的覆盖度,覆盖度越高,代表更多的代码被测试过,质量相应的也就越高。在 Go 语言中,可以方便的通过 go 命令来执行单元测试,生成覆盖率文件。

我们通过 go test --coverprofile=coverage.out 命令执行单元测试,并生成覆盖率文件 coverage.out。在执行后,我们得到了如下输出:

可以看到,我们代码中有 26.2% 被测试文件覆盖到,而生成的 coverage.out 文件中包含了详细的覆盖信息:

但是由于文本文件看起来并不方便,Go 语言也提供了工具可以方便的将覆盖率信息转化为 HTML 文件方便观察,只需要执行 go tool cover -html=coverage.out。

Fuzz 测试

Fuzz 测试也叫 “模糊测试”,用来通过大量随机输入来挖掘软件安全漏洞、检测软件健壮性的黑盒测试。它通过向软件输入大量随机的字段,观测被测试软件是否完备地处理了各种输入情况。

Fuzz 测试面世以来,发现了大量的安全漏洞,其通过各种类型的输入,不间断的对程序进行冲撞,极大的拓展了测试的边界。

Go 语言从 1.18 版本开始,也支持了 Fuzz 测试。Go 语言的 Fuzz 测试同 C++ 的一样,系统输入大量随机的字节序列,并通过随机的字节序列组成入参,然后进行测试,直到发现空指针等难以发现的问题。

目前 Go 语言的 Fuzz 测试入参支持的类型有:布尔型、整型、浮点型、字符串以及字节序列。用户可以根据需要调整入参类型,方便使用。

于是,我们对上文中 GetRecord 方法进行了 Fuzz 测试。

通过执行 go test -fuzz=Fuzz 命令来进行我们的 Fuzz 测试。在经过短时间的运行后,获得如下输出:

通过错误我们可以清楚地看到,在 Fuzz 测试期间,爆出了下标越界的错误,同时导致越界的输入值为-79,通过检查 GetRecord 方法不难发现,接口虽然进行了入参最大范围的校验,但是却忽略了入参为负数的情况,从而使得程序并不健壮,当用户输入错误的入参的时候,会触发越界,导致 panic 这样严重的错误,给了黑客可乘之机。

由此,我们很方便的完善了 GetRecord 方法,也使得这个方法变得健壮,可以防止各种非法入参攻击,大幅度提高程序的质量。

数据竞争检测

Go 语言的一大特色是方便强大的并发能力,通过 go 关键字,轻松的启动 goroutine。由于 Go 语言的并发模型充分地利用了多核能力,同时采用了抢占式的调度方式,因此带来方便和高效的同时,也引入了数据竞争的风险。

数据竞争问题一直是多线程编程中难以避免却又十分重要的一个问题,其难以发现,随机触发,通常在测试环节比较难发现,甚至线上运行很久问题也始终没有浮现。但是一旦发生,往往造成的破坏又十分巨大。

在其他语言当中,大家主要通过三种方法来规避数据竞争:

①.通过各种设计模式和严格遵守的设计规范来规避数据竞争。但是效果往往差强人意,各种测试代码也难以准确的测试出数据竞争的发生。如 Java、C++ 等

②.不支持并发,通过一个全局锁来保证指令的顺序执行。但是性能的损失又是一个难以接受的理由。如 Python 等。

③.十分严格的静态检查来避免数据竞争。效果斐然,但是也极大的失去了程序的灵活性。如 Rust。

以上解决思路均有一定效果,但是又存在许多问题。有没有既能发现问题,又不用损失灵活性的方案呢?答案是肯定的。

谷歌公司通过努力,针对 C++ 语言推出了一套 Sanitizer 工具,包括 Address Sanitizer、Leak Sanitizer、Undefined Sanitizer 以及 Thread Sanitizer 等,其中 Thread Sanitizer 通过代码插桩和内存改造,在保证灵活性的同时,方便快速地检查出数据竞争的问题。

而 Go 语言中的数据竞争检测,也是利用这套 Thread Sanitizer 工具。Go 语言在编译过程中,如果加入了-race 参数,就会使用 Thread Sanitizer 来进行代码插桩并链接相应的运行时,从而可以在代码测试过程中方便的检测出数据竞争所在。

在此,我们利用 Go 语言的数据竞争检测功能,来完成对 Remove 方法数据竞争问题的检测。

我们添加如下测试,创建一个账户,进行三次操作后,移除第一次和第二次的操作记录。由于 Remove 方法使用了并发来完成移除操作,因此可能会出现数据竞争的情况。

正如上文中说到的那样,数据竞争问题难以发现。如果读者直接运行测试代码 TestRemove100 次,极大概率会发现 100 次运行的结果均正确。但是,Remove 方法依然存在发生问题的可能性,a.Records 的长度为 3,在通过 Remove 方法删去 2 个记录后,正常情况下,剩余的记录数量为 1,当数据竞争发生时,剩余的记录数量就会为 2。通过代码 Review,可以发现当 Remove 中两个 goroutine 发生数据竞争时,可能会导致某个删除操作被覆盖掉,从而产出错误的结果。

通过 go test -race -run=TestRemove,我们可以清楚地看到可能发生的数据竞争的各种情况。

通过上述输出,我们可以看到 Remove 的当前实现,既有并发写 Records 的潜在风险,也有并发读写 Records 的潜在风险,这两种情况均会导致读取到的结果存在问题,从而导致隐秘又危险的问题发生。

我们可以通过对 Remove 内部代码进行加锁改造,使得对 Records 的读和写都有锁保护,即可顺利通过检测,代码再无数据竞争的风险。

总结

Go 语言吸取了大量的经验,为我们提供了方便的测试工具和测试能力。在以后的工作中,大家可以根据代码情况将这些工具利用起来,从而轻松完成稳定运行且没有漏洞的代码。

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册