GO语言基础篇(三十)- 一文搞定go测试

797 阅读6分钟

这是我参与8月更文挑战的第 30 天,活动详情查看: 8月更文挑战

传统测试 vs 表格驱动测试

传统测试

传统测试的缺点

  • 测试数据和测试逻辑混在一起
  • 出错信息不明确
  • 一旦一个数据出错,测试全部结束

表格驱动测试

go使用的就是表格驱动测试。在本文的【功能测试】部分,有展示表格驱动测试,可以结合着看

表格驱动测试优点

  • 分离测试数据和测试逻辑
  • 明确的出错信息(我们是可以自己自定义输出的)
  • 可以部分失败(不会因为中间某一个没通过,而终止了整个测试)
  • go语言的语法使得我们更容易实践表格驱动测试

go test工具

go test子命令是Go语言包的测试驱动程序,这些包根据某些约定组织在一起。在一个包目录中,以_test.go 结尾的文件不是go build命令编译的目标,而是go test编译的目标

在*_test.go文件中,三种函数需要特殊对待,即功能测试函数基准测试函数示例函数

  1. 功能测试函数是以Test前缀命名的函数,用来检测一些程序逻辑的正确性,go test 运行测试函数,并且报告结果是PASS还是FAIL
  2. 基准测试函数的名称以Benchmark开头,用来测试某些操作的性能,go test汇报操作的平均执行时间
  3. 示例函数的名称,以Example 开头,用来提供机器检查过的文档

go test工具扫描*_test.go文件来寻找特殊函数,并生成一个临时的main包来调用它们,然后编译和运行,并汇报结果,最后清空临时文件

功能测试

每一个测试文件,必须导入testing包。这些函数的函数签名如下:

func TestName(t *testing.T) {
    //...
}

功能测试函数,必须以Test开头,可选的后缀名称必须以大写字母开头

func TestSin(t *testing.T) {/*...*/}
func TestCos(t *testing.T) {/*...*/}
func TestLog(t *testing.T) {/*...*/}

参数t提供了汇报测试失败和日志记录的功能。下边实现了一个用来判断一个字符串是否是回文字符串的函数(/src/go.language/ch11/word/word.go)

package word

func IsPalindromeNew(str string) bool {
	for i := range str {
		if str[i] != str[len(str)-1-i] {
			return false
		}
	}

	return true
}

在同一目录下,文件word_test.go包含了两个功能测试函数TestIsPalindromeNew和TestNonPalindromeNew。两个函数都检查IsPalindromeNew是否针对单个输入参数给出了正确的结果,并且用t.Error来报错

func TestIsPalindromeNew(t *testing.T) {
	if !IsPalindromeNew("detartrated") {
		t.Error(`IsPalindromeNew("detartrated") == false`)
	}

	if !IsPalindromeNew("kayak") {
		t.Error(`IsPalindromeNew("kayak") == false`)
	}
}

func TestNonPalindromeNew(t *testing.T)  {
	if IsPalindromeNew("palindrome") {
		t.Error(`IsPalindromeNew("palindrome") == true`)
	}
}

go test(或者go build)命令在不指定包参数的情况下,以当前目录所在的包为参数。可以用下面的命令来编译和运行测试

$ cd $GOPATH/src/go.language/ch11/word
$ go test

PASS
ok      go.language/ch11/word   0.007s

可以看到程序测试通过

上边实现的判断一个字符串是否是回文字符串的函数,有个很明显的bug就是,如果该字符串中的字符都是ASCII值,那没问题。如果有中文这类字符,它会出问题了

func TestChinesePalindrome(t *testing.T)  {
	str := "一二三二一"
	if !IsPalindromeNew(str) {
		t.Errorf(`IsPalindromeNew(%q) = false`, str)
	}
}

因为str比较长,为了避免写两次,这里使用了Errorf函数,这个函数和Printf一样提供了格式化功能

此时再执行go test命令就会发现测试没通过

$ go test
--- FAIL: TestChinesePalindrome (0.00s)
    word_test.go:26: IsPalindromeNew("一二三二一") = false
FAIL
exit status 1
FAIL    go.language/ch11/word   0.005s

如果一个测试套件里边有很多测试用例,我们可以选择性的执行测试用例。-v可以输出包中每个测试用例的名称和执行的时间

$ go test -v
=== RUN   TestIsPalindromeNew
--- PASS: TestIsPalindromeNew (0.00s)
=== RUN   TestNonPalindromeNew
--- PASS: TestNonPalindromeNew (0.00s)
=== RUN   TestChinesePalindrome
    word_test.go:26: IsPalindromeNew("一二三二一") = false
--- FAIL: TestChinesePalindrome (0.00s)
FAIL
exit status 1
FAIL    go.language/ch11/word   0.004s

参数-run是一个正则表达式,它可以使得go test只运行哪些测试函数名匹配给定模式的函数

$ go test -v -run="Chinese|English"
=== RUN   TestChinesePalindrome
    word_test.go:26: IsPalindromeNew("一二三二一") = false
--- FAIL: TestChinesePalindrome (0.00s)
FAIL
exit status 1
FAIL    go.language/ch11/word   0.005s

下边是完善判断回文字符串的函数,使其可以支持更多类型的字符

func IsPalindromeRune(str string) bool {
	strRune := []rune(str)
	for i, _ := range strRune {
		if strRune[i] != strRune[len(strRune) - i - 1] {
			return false
		}
	}
	return true
}

然后再写一个测试用例,用来测试这个新视线的判断回文字符串的方法

func TestIsPalindromeRune(t *testing.T) {
	var tests = []struct {
		input string
		want bool
	}{
		{"", true},
		{"a", true},
		{"aa", true},
		{"ab", false},
		{"kayak", true},
		{"detartrated", true},
		{"就是个测试", false},
		{"就是一个测试测个一是就", false},
	}

	for _, test := range tests {
		if got := IsPalindromeRune(test.input); got != test.want {
			t.Errorf("IsPalindromeRune(%q) = %v", test.input, got)
		}
	}
}

//执行以下测试用例
$ go test -v -run="TestIsPalindromeRune"
=== RUN   TestIsPalindromeRune
--- PASS: TestIsPalindromeRune (0.00s)
PASS
ok      go.language/ch11/word   0.004s

这种基于表的测试方式在Go里面很常见。根据需要添加新的表项目很直观,并且由于断言逻辑没有重复,所以我们可以花点精力让输出的错误消息更好看一点

当前调用t.Errorf输出的失败的测试用例信息没有包含整个跟踪栈信息,也不会导致程序宕机或者终止执行,这和很多其他语言的测试框架中的断言不同。测试用例彼此是独立的。如果测试表中的一个条目造成测试失败,那么其他的条目仍然会继续测试,这样我们就可以在一次测试过程中发现多个失败的情况

如果我们真的需要终止一个测试函数,比如由于初始化代码失败或者避免已有的错误产生令人困惑的输出,我们可以使用t.Fatal或者t.Fatalf函数来终止测试。这些函数的调用必须和Test函数在同一个goroutine中,而不是在测试创建的其他goroutine中

测试错误消息一般格式是"f(x)=y,wantz",这里f(x)表示需要执行的操作和它的输 人,y是实际的输出结果,z是期望得到的结果。出于方便,对于f(x)我们会使用Go的语法,比如在上面回文的例子中,我们使用Go的格式化来显示较长的输入,避免重复输 人。在基于表的测试中,输出x是很重要的,因为一条断言语句会在不同的输入情况下执行多次。错误消息要避免样板文字和冗余信息

随机测试

基于表的测试方便针对精心选择的输入检测函数是否工作正常,以测试逻辑上引人关注的用例。另外一种方式是随机测试通过构建随机输入来扩展测试的覆盖范围

如果给出的输入是随机的,我们怎么知道函数输出什么内容呢?这里有两种策略。一种方式就是额外写一个函数,这个函数使用低效但是清晰的算法,然后检查这两种实现的输出 是否一致。另外一种方式是构建符合某种模式的输入,这样我们可以知道它们对应的输出是什么

下边这个例子使用了第二种方式,randomPalindrome函数产生一系列的回文字符串,这些输出在构建的时候就确定是回文字符串了

func randomPalindrome(rng *rand.Rand) string {
	n := rng.Intn(25) // 随机字符串最大长度是24
	runes := make([]rune, n)
	for i := 0; i < (n+1)/2; i++ {
		r := rune(rng.Intn(0x1000))//随机最大字符是'\u0999'
		runes[i] = r
		runes[n-i-1] = r
	}

	return string(runes)
}

func TestRandomPalindrome(t *testing.T) {
	// 初始化一个伪随机数生成器
	seed := time.Now().UTC().UnixNano()
	t.Logf("Random seed: %d", seed)
	rng := rand.New(rand.NewSource(seed))
	for i:=0;i<1000;i++ {
		p := randomPalindrome(rng)
		if !IsPalindromeRune(p) {
			t.Errorf("IsPalindromeRune(%q) = false", p)
		}
	}
}

由于随机测试的不确定性,在遇到测试用例失败的情况下,一定要记录足够的信息以便于重现这个问题。在该例子中,函数IsPalindromeRune的输入p告诉我们所需要知道的所有信息,但是对于那些拥有更复杂输入的函数来说,记录伪随机数生成器的种子会比转储整个输入数据结构要简单得多。有了随机数的种子,我们可以简单地修改测试代码来准确地重现错误

通过使用当前时间作为伪随机数的种子源,在测试的整个生命周期中,每次运行的时候 都会得到新的输入。如果你的项目使用自动化系统来间周期地运行测试,这一点很重要

白盒测试

测试的分类方式之一是基于对所要进行测试的包的内部了解程度。黑盒测试假设测试者对包的了解仅通过公开的API和文档,而包的内部逻辑则是不透明的。相反,白盒测试可以访问包的内部函数和数据结构,并且可以做一些常规用户无法做到的观察和改动。例如,白盒测试可以检查包的数据类型不可变性在每次操作后都是经过维护的

这两种方法是互补的。黑盒测试通常更加健壮,每次程序更新后基本不需要修改。它们也会帮助测试的作者关注包的用户并且能够发现API设计的缺陷。反之,白盒测试可以对实现的特定之处提供更详细的覆盖测试

上边的TestIsPalindromeNew函数仅调用导出的函数IsPalindromeNew,所以它是一个黑盒测试

覆盖率

从本质上看,测试从来不会结束。著名计算机科学家Edsger Dijkstra说:“测试旨在发现bug,而不是证明其不存在”无论有多少测试都无法证明一个包是没有bug的。在最好的情况下,它们增强了我们的信心,这些包是可以在很多重要的场景下使用的

一个测试套件覆盖待测试包的比例称为测试的覆盖率。覆盖率无法直接通过数量来衡量,任何事情都是动态的,即使最微小的程序都无法精确地测量。但还是有办法帮助我们将测试精力放到最有潜力的地方

语句覆盖率是一种最简单的且广泛使用的方法之一一个测试套件的语句覆盖率是指部分语句在一次执行中至少执行一次。这部分将使用Go的cover工具,这个工具被集成到了go test中,用来衡量语句覆盖率并帮助识别测试之间的明显差别

以上边的判断一个字符串是否是回文字符串为例,进入测试文件所在的目录,执行如下命令来生成覆盖率的数据

$ go test -coverprofile=c.out

执行上边命令,会在当前文件下生成一个c.out文件(这个文件名字可以任意),通过go tool cover命令来在网页中看文件内容

$ go tool cover -html=c.out

执行该命令之后会打开一个网页,内容如下

image.png

蓝色代表覆盖到了,红色代表没覆盖到

基准测试

Benchmark函数

基准测试就是在一定的工作负载之下检测程序性能的一种方法。在Go里面,基准测试函数看上去像一个测试函数,但是前缀是Benchmark并且拥有一个* testing.B 参数用来提供大多数和testing.T相同的方法,额外增加了一些与性能检测相关方法。它还提供了一个整型成员N,用来指定被检测操作的执行次数

这里是IsPalindromeNew函数的基准测试,它在一个循环中调用了 IsPalindromeNew共N次

package word

import "testing"

func BenchmarkIsPalindromeNew(b *testing.B) {
	for i:=0; i<b.N;i++ {
		IsPalindromeNew("abcdedcba")
	}
}

使用下面的命令执行它。和测试不同,默认情况下不会运行任何基准测试。标记-bench的参数指定了要运行的基准测试。它是一个匹配Benchmark函数名称的正则表达式,它的默认值不匹配任何函数。模式" . "使它匹配包word中所有的基准测试函数,因为这里只有一个基准测试函数,所以和指定-bench=IsPalindromeNew效果一样

$ go test -bench=.                   
goos: darwin
goarch: amd64
pkg: go.language/ch11/word
cpu: Intel(R) Core(TM) i7-1068NG7 CPU @ 2.30GHz
BenchmarkIsPalindromeNew-8      100000000               11.52 ns/op
PASS
ok      go.language/ch11/word   1.170s

基准测试名称的数字后缀8表示GOMAXPROCS的值(关于GOMAXPROCS,在前边的文章中已经分享,可以点这里了解),这个对并发基准测试很重要。输出的结果告诉我们,每次调用IsPalindromeNew耗费0.01152ms,它是调用100000000次的平均值。因为基准测试运行器开始的时候并不清楚这个操作的耗时长短,所以开始的时候它使用了比较小的N值来做检测,然后为了检测稳定的运行时间,推断出足够大的N值

使用基准测试函数来实现循环而不是在测试驱动程序中调用代码的原因是,在基准测试函数中,在循环外面可以执行一些必要的初始化代码,并且这段时间不加到每次迭代的时间中。 如果初始化代码干扰了结果,参数testing.B提供了方法用来停止、恢复和重置计时器,但是这些方法很少用到(比如重置计时器,可以使用b.ResetTimer())

既然有了基准测试和功能测试,这时就很容易想到让程序更快一点。或许最明显的优化是使得IsPalindromeNew函数的第二次循环在中间停止检测,以避免比较两次:

n := len(str)/2
	for i := 0 ;i < n; i++ {
		if str[i] != str[len(str) - 1 - i] {
			return false
		}
	}

$ go test -bench=.
goos: darwin
goarch: amd64
pkg: go.language/ch11/word
cpu: Intel(R) Core(TM) i7-1068NG7 CPU @ 2.30GHz
BenchmarkIsPalindromeNew-8      384384532                3.136 ns/op
PASS
ok      go.language/ch11/word   1.528s

可以看到,程序的性能得到了比较大的提升。最快的程序,往往是那些进行内存分配次数最少的程序。命令行标记-benchmem在报告中包含了内存分配统计数据

这种基准测试告诉我们给定操作的绝对耗时,但是在很多情况下,引起关注的性能问题是两个不同操作之间的相对耗时。例如,如果一个函数需要1ms来处理1000个元素,那么它处理10 000个或者100万个元素需要多久呢?

另外一个例子:I/O缓冲区的最佳大小是多少。对一个应用使用一系列的大小进行基准测试可以帮助我们选择最小的缓冲区并带来最佳的性能表现

第三个例子:对于一个任务来讲,哪种算法表现最佳?对两个不同的算法使用相同的输入,在重要的或者具有代表性的工作负载下,进行基准测试通常可以显示出每个算法的优点和缺点

性能比较函数只是普通的代码。它们的表现形式通常是带有一个参数的函数,被多个不同的Benchmark函数传入不同的值来调用,如下所示:

func benchmark(b *testing.B, size int*) {/* ..*. */}
func Benchmark10(b *testing.B) { benchmark(b, 10) }
func Benchmark100(b *testing.B) { benchmark(b, 100) }
func Benchmark1000(b *testing.B) { benchmark(b, 1000) }

参数size指定了输入的大小,每个Benchmark函数传入的值都不同,但是在每个函数内部是一个常量。不要使用b.N作为输入的大小。除非把它当作固定大小输入的循环次数,否则该基准测试的结果毫无意义

性能剖析

性能剖析是通过自动化手段,在程序执行过程中,基于一些性能事件的采样来进行性能评测,然后再从这些采样中推断分析,得到的统计报告就称为性能剖析(profile)

Go支持很多种性能剖析方式,每一个都和一个不同方面的性能指标相关,但是它们都需要记录一些相关的事件,每一个都有一个相关的栈信息——在事件发生时活跃的函数调用栈。工具go test内置支持一些类别的性能剖析

CPU性能剖析:识别出执行过程中需要CPU最多的函数。在每个CPU上面执行的线程都每隔几毫秒会定期地被操作系统中断,在每次中断过程中记录一个性能剖析事件,然后恢复正常执行

堆性能剖析:识别出负责分配最多内存的语句。性能剖析库对协程内部内存分配调用进行采样,因此每个性能剖析事件平均记录了分配的512KB内存

阻塞性能剖析:**识别出那些阻塞协程最久的操作。**例如系统调用,通道发送和接收数据,以及获取锁等。性能分析库在一个goroutine每次被上述操作之一阻塞的时候记录一个事件

获取待测试代码的性能剖析报告很容易,只需要像下面一样指定一个标记即可。当一次使用多个标记的时候需要注意,获取性能分析报告的机制是,当获取其中一个类别的报告时会覆盖掉其他类别的报告

$ go test -cpuprofile=cpu.out
$ go test -blockprofile=block.out
$ go test -memprofile=mem.out

尽管具体的做法对于短暂的命令行工具和长时间运行的服务器程序有所不同,但是为非测试程序添加性能剖析支持也很容易。性能剖析对于长时间运行的程序尤其有用,所以Go运行时的性能剖析特性可以让程序员通过runtime API来启用

在我们获取性能剖析结果后,我们需要使用pprof开工具来分析它。这是Go发布包的标准部分,但是因为不经常使用,所以通过go tool pprof间接来使用它。它有很多特性和选项,但是基本的用法只有两个参数,产生性能剖析结果的可执行文件和性能剖析日志

为了使得性能剖析过程高效并且节约空间,性能剖析日志里面没有包含函数名称而是使用它们的地址。这就意味着pprof工具需要可执行文件才能理解数据内容。虽然通常情况下 go test工具在测试完成之后就丢弃了用于测试而临时产生的可执行文件,在性能剖析启用的时候,它保存并把可执行文件命名为foo.test,其中foo是被测试包的名字

使用pprof

下边通过之前文章中用到的求最长不重复子串的代码为例来展示一下性能剖析。最长不重复子串的代码内容如下:

package nonrepeating

func lengthOfNonRepeatingSubStr(s string) int {
	lastOccured := make(map[rune]int)
	start := 0
	maxLength := 0
	for i, ch := range []rune(s) {
		lastId, ok := lastOccured[ch]
		if ok && lastId >= start {
			start = lastId + 1
		}
		if i - start +1 > maxLength {
			maxLength = i - start + 1
		}
		lastOccured[ch] = i
	}

	return maxLength
}

然后编写一个Benchmark的测试函数,内容如下:

package nonrepeating

import "testing"

func BenchmarkLengthOfNonRepeatingSubStr(b *testing.B)  {
	s := "一二三二一"
	ans := 3
	for i :=0; i < b.N; i++ {
		actual := LengthOfNonRepeatingSubStr(s)
		if actual != ans {
			b.Errorf("got %d for input %s; " + "expected %d", actual, s, ans)
		}
	}
}

在没有优化之前,可以先看一下他的性能测试结果

$ go test -bench .
goos: darwin
goarch: amd64
pkg: go.language/ch11/nonrepeating
cpu: Intel(R) Core(TM) i7-1068NG7 CPU @ 2.30GHz
BenchmarkLengthOfNonRepeatingSubStr-8            9461041               127.4 ns/op
PASS
ok      go.language/ch11/nonrepeating   1.340s

现在我们想看看上边的函数慢在哪里?它花的时间到底在哪个部分。执行如下命令:

$ go test -bench . -cpuprofile=cpu.out

执行完毕后,会发现在当前目录下生成了一个cpu.out的二进制文件,因为是二进制文件,就别再通过命令打开来看里边的内容了,因为也不好看懂。有别的方法看

$ go tool pprof cpu.out
Type: cpu
Time: Aug 30, 2021 at 2:48pm (CST)
Duration: 1.45s, Total samples = 1.17s (80.66%)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)

会看到输出如下内容,并且最后一行是一个交互命令行,还可以输入其它的指令,比如输入help,可以看到能执行哪些命令。这里演示一个最简单的命令,输入web。就会在网页中打开一个svg的文件,如果你输入web之后提示如下内容:

failed to execute dot. Is Graphviz installed? Error: exec: "dot": executable file not found in $PATH

是你电脑没有安装gvedit导致的,去gvedit官网下载一个稳定版本安装即可(官网中有各种操作系统的安装方法,也可以源码安装)。安装完成后,设置环境变量path后面加上gvedit安装路径的bin文件夹

image.png

图中方框越大的部分,说明是耗时越多的地方,我们就可以根据这些信息来对程序进行优化

也可以通过表格的方式展示(下边就等效于在上边的交互命令行中输入text)

$ go test -run=NONE -bench=. -cpuprofile=cpu.log
goos: darwin
goarch: amd64
pkg: go.language/ch11/word
cpu: Intel(R) Core(TM) i7-1068NG7 CPU @ 2.30GHz
BenchmarkIsPalindromeNew-8      386407360                3.096 ns/op
PASS
ok      go.language/ch11/word   1.660s

$ go tool pprof -text -nodecount=10 cpu.log 
Type: cpu
Time: Aug 30, 2021 at 3:54pm (CST)
Duration: 1.43s, Total samples = 1.12s (78.19%)
Showing nodes accounting for 1.08s, 96.43% of 1.12s total
Showing top 10 nodes out of 30
      flat  flat%   sum%        cum   cum%
     0.30s 26.79% 26.79%      0.48s 42.86%  runtime.mapassign_fast32
     0.24s 21.43% 48.21%      0.24s 21.43%  runtime.decoderune
     0.12s 10.71% 58.93%      0.13s 11.61%  runtime.mapaccess2_fast32
     0.11s  9.82% 68.75%      1.09s 97.32%  go.language/ch11/nonrepeating.LengthOfNonRepeatingSubStr
     0.11s  9.82% 78.57%      0.35s 31.25%  runtime.stringtoslicerune
     0.10s  8.93% 87.50%      0.10s  8.93%  runtime.memhash32
     0.06s  5.36% 92.86%      0.06s  5.36%  runtime.add (inline)
     0.02s  1.79% 94.64%      0.02s  1.79%  runtime.(*hmap).growing (inline)
     0.01s  0.89% 95.54%      1.10s 98.21%  go.language/ch11/nonrepeating.BenchmarkLengthOfNonRepeatingSubStr
     0.01s  0.89% 96.43%      0.01s  0.89%  runtime.(*bmap).overflow

标记-text指定输出的格式,在上边的例子中,展示的是一个文本表格,表格中每行一个函数,这些函数是根据消耗CPU最多的规则排序的”热函数“。标记-nodecount=10限制输出的结果共10行。对于比较明显的性能问题,这个文本格式的输出足够暴露问题了

参考

关于golang性能调试及pprof可视化

《Go程序设计语言》—-艾伦 A. A. 多诺万

《Go语言学习笔记》—-雨痕