参考:
书籍:The Go Programming Language (中文:Go语言圣经)
书籍地址:https://www.gopl.io/
代码:https://github.com/adonovan/gopl.io
go test命令是一个按照一定的约定和组织来测试代码的程序。在包目录内,所有以_test.go为后缀名的源文件在执行go build时不会被构建成包的一部分,它们是go test测试的一部分。
在*_test.go
文件中,有三种类型的函数:
- 测试函数
一个测试函数是以Test为函数名前缀的函数,用于测试程序的一些逻辑行为是否正确;go test命令会调用这些测试函数并报告测试结果是PASS或FAIL。 - 基准测试(benchmark)函数
基准测试函数是以Benchmark为函数名前缀的函数,它们用于衡量一些函数的性能;go test命令会多次运行基准测试函数以计算一个平均的执行时间。 - 示例函数
示例函数是以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参数用于报告测试失败和附加的日志信息。让我们定义一个实例包gopl.io/ch11/word1,其中只有一个函数IsPalindrome用于检查一个字符串是否从前向后和从后向前读都是一样的。(下面这个实现对于一个字符串是否是回文字符串前后重复测试了两次;我们稍后会再讨论这个问题。)
gopl.io/ch11/word1
// Package word provides utilities for word games.
package word
// IsPalindrome reports whether s reads the same forward and backward.
// (Our first attempt.)
func IsPalindrome(s string) bool {
for i := range s {
if s[i] != s[len(s)-1-i] {
return false
}
}
return true
}
在相同的目录下,word_test.go测试文件中包含了TestPalindrome和TestNonPalindrome两个测试函数。每一个都是测试IsPalindrome是否给出正确的结果,并使用t.Error报告失败信息:
package word
import "testing"
func TestPalindrome(t *testing.T) {
if !IsPalindrome("detartrated") {
t.Error(`IsPalindrome("detartrated") = false`)
}
if !IsPalindrome("kayak") {
t.Error(`IsPalindrome("kayak") = false`)
}
}
func TestNonPalindrome(t *testing.T) {
if IsPalindrome("palindrome") {
t.Error(`IsPalindrome("palindrome") = true`)
}
}
go test命令如果没有参数指定包那么将默认采用当前目录对应的包(和go build命令一样)。我们可以用下面的命令构建和运行测试。
$ cd $GOPATH/src/gopl.io/ch11/word1
$ go test
ok gopl.io/ch11/word1 0.008s
不过IsPalindrome函数不能识别“été”。也不能识别“A man, a plan, a canal: Panama.”。执行特殊和小的BUG报告为我们提供了新的更自然的测试用例。
func TestFrenchPalindrome(t *testing.T) {
if !IsPalindrome("été") {
t.Error(`IsPalindrome("été") = false`)
}
}
func TestCanalPalindrome(t *testing.T) {
input := "A man, a plan, a canal: Panama"
if !IsPalindrome(input) {
t.Errorf(`IsPalindrome(%q) = false`, input)
}
}
为了避免两次输入较长的字符串,我们使用了提供了有类似Printf格式化功能的 Errorf函数来汇报错误结果。
当添加了这两个测试用例之后,go test返回了测试失败的信息。
$ go test
--- FAIL: TestFrenchPalindrome (0.00s)
word_test.go:28: IsPalindrome("été") = false
--- FAIL: TestCanalPalindrome (0.00s)
word_test.go:35: IsPalindrome("A man, a plan, a canal: Panama") = false
FAIL
FAIL gopl.io/ch11/word1 0.014s
先编写测试用例并观察到测试用例触发了和用户报告的错误相同的描述是一个好的测试习惯。只有这样,我们才能定位我们要真正解决的问题。
如果测试集有很多运行缓慢的测试,我们可以通过只选择运行某些特定的测试来加快测试速度。
参数-v
可用于打印每个测试函数的名字和运行时间:
$ go test -v
=== RUN TestPalindrome
--- PASS: TestPalindrome (0.00s)
=== RUN TestNonPalindrome
--- PASS: TestNonPalindrome (0.00s)
=== RUN TestFrenchPalindrome
--- FAIL: TestFrenchPalindrome (0.00s)
word_test.go:28: IsPalindrome("été") = false
=== RUN TestCanalPalindrome
--- FAIL: TestCanalPalindrome (0.00s)
word_test.go:35: IsPalindrome("A man, a plan, a canal: Panama") = false
FAIL
exit status 1
FAIL gopl.io/ch11/word1 0.017s
参数-run
对应一个正则表达式,只有测试函数名被它正确匹配的测试函数才会被go test测试命令运行:
$ go test -v -run="French|Canal"
=== RUN TestFrenchPalindrome
--- FAIL: TestFrenchPalindrome (0.00s)
word_test.go:28: IsPalindrome("été") = false
=== RUN TestCanalPalindrome
--- FAIL: TestCanalPalindrome (0.00s)
word_test.go:35: IsPalindrome("A man, a plan, a canal: Panama") = false
FAIL
exit status 1
FAIL gopl.io/ch11/word1 0.014s
当然,一旦我们已经修复了失败的测试用例,在我们提交代码更新之前,我们应该以不带参数的go test命令运行全部的测试用例,以确保修复失败测试的同时没有引入新的问题。
我们现在的任务就是修复这些错误。简要分析后发现第一个BUG的原因是我们采用了 byte而不是rune序列,所以像“été”中的é等非ASCII字符不能正确处理。第二个BUG是因为没有忽略空格和字母的大小写导致的。
针对上述两个BUG,我们仔细重写了函数:
gopl.io/ch11/word2
// Package word provides utilities for word games.
package word
import "unicode"
// IsPalindrome reports whether s reads the same forward and backward.
// Letter case is ignored, as are non-letters.
func IsPalindrome(s string) bool {
var letters []rune
for _, r := range s {
if unicode.IsLetter(r) {
letters = append(letters, unicode.ToLower(r))
}
}
for i := range letters {
if letters[i] != letters[len(letters)-1-i] {
return false
}
}
return true
}
同时我们也将之前的所有测试数据合并到了一个测试中的表格中。
func TestIsPalindrome(t *testing.T) {
var tests = []struct {
input string
want bool
}{
{"", true},
{"a", true},
{"aa", true},
{"ab", false},
{"kayak", true},
{"detartrated", true},
{"A man, a plan, a canal: Panama", true},
{"Evil I did dwell; lewd did I live.", true},
{"Able was I ere I saw Elba", true},
{"été", true},
{"Et se resservir, ivresse reste.", true},
{"palindrome", false}, // non-palindrome
{"desserts", false}, // semi-palindrome
}
for _, test := range tests {
if got := IsPalindrome(test.input); got != test.want {
t.Errorf("IsPalindrome(%q) = %v", test.input, got)
}
}
}
现在我们的新测试都通过了:
$ go test gopl.io/ch11/word2
ok gopl.io/ch11/word2 0.015s
这种表格驱动的测试在Go语言中很常见。我们可以很容易地向表格添加新的测试数据,并且后面的测试逻辑也没有冗余,这样我们可以有更多的精力去完善错误信息。
失败测试的输出并不包括调用t.Errorf时刻的堆栈调用信息。和其他编程语言或测试框架的assert断言不同,t.Errorf调用也没有引起panic异常或停止测试的执行。即使表格中前面的数据导致了测试的失败,表格后面的测试数据依然会运行测试,因此在一个测试中我们可能了解多个失败的信息。
如果我们真的需要停止测试,或许是因为初始化失败或可能是早先的错误导致了后续错误等原因,我们可以使用t.Fatal或t.Fatalf停止当前测试函数。它们必须在和测试函数同一个goroutine内调用。
测试失败的信息一般的形式是“f(x) = y, want z”,其中f(x)解释了失败的操作和对应的输入,y是实际的运行结果,z是期望的正确的结果。就像前面检查回文字符串的例子,实际的函数用于f(x)部分。显示x是表格驱动型测试中比较重要的部分,因为同一个断言可能对应不同的表格项执行多次。要避免无用和冗余的信息。在测试类似IsPalindrome返回布尔类型的函数时,可以忽略并没有额外信息的z部分。如果x、y或z是y的长度,输出一个相关部分的简明总结即可。测试的作者应该要努力帮助程序员诊断测试失败的原因。
外部测试包
考虑下这两个包:net/url包,提供了URL解析的功能;net/http包,提供了web服务和HTTP客户端的功能。如我们所料,上层的net/http包依赖下层的net/url包。然后,net/url包中的一个测试是演示不同URL和HTTP客户端的交互行为。也就是说,一个下层包的测试代码导入了上层的包。
这样的行为在net/url包的测试代码中会导致包的循环依赖,正如图11.1中向上箭头所示,同时正如我们在10.1节所讲的,Go语言规范是禁止包的循环依赖的。
不过我们可以通过外部测试包的方式解决循环依赖的问题,也就是在net/url包所在的目录声明一个独立的url_test测试包。其中包名的_test后缀告诉go test工具它应该建立一个额外的包来运行测试。我们将这个外部测试包的导入路径视作是net/url_test会更容易理解,但实际上它并不能被其他任何包导入。
因为外部测试包是一个独立的包,所以能够导入那些依赖待测代码本身的其他辅助包;包内的测试代码就无法做到这点。在设计层面,外部测试包是在所有它依赖的包的上层。
包内测试:测试代码放在与被测包同名的包中
包外测试:测试代码放在名为被测包包名+_test的包中
go list命令查看包对应目录中哪些Go源文件是产品代码,哪些是包内测试,还有哪些是外部测试包。
我们以fmt包作为一个例子:
- GoFiles表示产品代码对应的Go源文件列表;也就是go build命令要编译的部分。
$ go list -f={{.GoFiles}} fmt
[doc.go format.go print.go scan.go]
- TestGoFiles表示的是fmt包内部测试代码,以_test.go为后缀文件名,不过只在测试时被构建:
$ go list -f={{.TestGoFiles}} fmt
[export_test.go]
- XTestGoFiles表示的是属于外部测试包的测试代码,也就是fmt_test包,因此它们必须先导入fmt包。同样,这些文件也只是在测试时被构建运行:
$ go list -f={{.XTestGoFiles}} fmt
[fmt_test.go scan_test.go stringer_test.go]
测试覆盖率
现在我们可以用-coverprofile
标志参数重新运行测试:
$ go test -run=Coverage -coverprofile=c.out gopl.io/ch7/eval
ok gopl.io/ch7/eval 0.032s coverage: 68.5% of statements
这个标志参数通过在测试代码中插入生成钩子来统计覆盖率数据。也就是说,在运行每个测试前,它将待测代码拷贝一份并做修改,在每个词法块都会设置一个布尔标志变量。当被修改后的被测试代码运行退出时,将统计日志数据写入c.out文件,并打印一部分执行的语句的一个总结。(如果你需要的是摘要,使用go test -cover。)
如果使用了-covermode=count标志参数,那么将在每个代码块插入一个计数器而不是布尔标志量。在统计结果中记录了每个块的执行次数,这可以用于衡量哪些是被频繁执行的热点代码。
为了收集数据,我们运行了测试覆盖率工具,打印了测试日志,生成一个HTML报告,然后在浏览器中打开
绿色的代码块被测试覆盖到了,红色的则表示没有被覆盖到。
基准测试
基准测试是测量一个程序在固定工作负载下的性能。
格式:Benchmark为前缀名,并且带有一个*testing.B
类型的参数
*testing.B
参数除了提供和*testing.T
类似的方法,还有额外一些和性能测量相关的方法。它还提供了一个整数N,用于指定操作执行的循环次数。
下面是IsPalindrome函数的基准测试,其中循环将执行N次。
import "testing"
func BenchmarkIsPalindrome(b *testing.B) {
for i := 0; i < b.N; i++ {
IsPalindrome("A man, a plan, a canal: Panama")
}
}
我们需要通过-bench命令行标志参数手工指定要运行的基准测试函数。该参数是一个正则表达式,用于匹配要执行的基准测试函数的名字,默认值是空的。其中“.”模式将可以匹配所有基准测试函数,但因为这里只有一个基准测试函数,因此和-bench=IsPalindrome参数是等价的效果。
$ cd $GOPATH/src/gopl.io/ch11/word2
$ go test -bench=.
PASS
BenchmarkIsPalindrome-8 1000000 1035 ns/op
ok gopl.io/ch11/word2 2.179s
结果中基准测试名的数字后缀部分,这里是8,表示运行时对应的GOMAXPROCS的值,这对于一些与并发相关的基准测试是重要的信息。
报告显示每次调用IsPalindrome函数花费1.035微秒,是执行1,000,000次的平均时间。因为基准测试驱动器开始时并不知道每个基准测试函数运行所花的时间,它会尝试在真正运行基准测试前先尝试用较小的N运行测试来估算基准测试函数所需要的时间,然后推断一个较大的时间保证稳定的测量结果。
-benchmem
命令行标志参数将在报告中包含内存的分配数据统计。
$ go test -bench=. -benchmem
PASS
BenchmarkIsPalindrome 1000000 1026 ns/op 304 B/op 4 allocs/op
比较型的基准测试就是普通程序代码。它们通常是单参数的函数,由几个不同数量级的基准测试函数调用,就像这样:
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) }
实例函数
示例函数以Example为函数名开头。示例函数没有函数参数和返回值。下面是IsPalindrome函数对应的示例函数:
func ExampleIsPalindrome() {
fmt.Println(IsPalindrome("A man, a plan, a canal: Panama"))
fmt.Println(IsPalindrome("palindrome"))
// Output:
// true
// false
}
示例函数有三个用处。
- 最主要的一个是作为文档:一个包的例子可以更简洁直观的方式来演示函数的用法,比文字描述更直接易懂,特别是作为一个提醒或快速参考时。
- 在go test执行测试的时候也会运行示例函数测试。如果示例函数内含有类似上面例子中的// Output:格式的注释,那么测试工具会执行这个示例函数,然后检查示例函数的标准输出与注释是否匹配。
- 示例函数的第三个目的提供一个真实的演练场。 http://golang.org 就是由godoc提供的文档服务,它使用了Go Playground让用户可以在浏览器中在线编辑和运行每个示例函数,就像图11.4所示的那样。这通常是学习函数使用或Go语言特性最快捷的方式。