Appearance
The Go Programming Language - 测试
1. go test
go test 命令是一个按照一定的约定和组织来测试代码的程序。
在包目录内,所有以 _test.go 为后缀名的源文件在执行 go build 时不会被构建成包的一部分,它们是 go test 测试的一部分。
在 *_test.go 文件中,有三种类型的函数:测试函数、基准测试(benchmark)函数、示例函数。
一个测试函数是以
Test为函数名前缀的函数,用于测试程序的一些逻辑行为是否正确;go test命令会调用这些测试函数并报告测试结果是 PASS 或 FAIL。基准测试函数是以
Benchmark为函数名前缀的函数,它们用于衡量一些函数的性能;go test命令会多次运行基准函数以计算一个平均的执行时间。示例函数是以
Example为函数名前缀的函数,提供一个由编译器保证正确性的示例文档;
go test 命令会遍历所有的 *_test.go 文件中符合上述命名规则的函数,生成一个临时的 main 包用于调用相应的测试函数,接着构建并运行、报告测试结果,最后清理测试中生成的临时文件。
2. 测试函数
每个测试函数必须导入 testing 包。测试函数的名字必须以 Test 开头,可选的后缀名必须以大写字母开头:
Go
func TestSin(t *testing.T) { /* ... */ }
func TestCos(t *testing.T) { /* ... */ }
func TestLog(t *testing.T) { /* ... */ }1
2
3
2
3
其中
t参数用于报告测试失败和附加的日志信息。
以 IsPalindrome 函数为例:
Go
// 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
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
我们编写一个测试函数 TestIsPalindrome:
Go
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)
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
运行测试:
Bash
$ go test gopl.io/ch11/word2
ok gopl.io/ch11/word2 0.015s1
2
2
我们还可以指定 -v 和 -run flag:
- 参数
-v可用于打印每个测试函数的名字和运行时间; - 参数
-run对应一个正则表达式,只有测试函数名被它正确匹配的测试函数才会被go test测试命令运行;
Bash
$ go test -v -run="Palindrome" gopl.io/ch11/word2
=== RUN TestIsPalindrome
--- PASS: TestIsPalindrome (0.00s)
PASS
ok hellogo/chapter7/expr 0.243s1
2
3
4
5
2
3
4
5
这种表格驱动的测试在 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 部分。
2.1. 随机测试
比如 TestRandomPalindromes:
Go
import "math/rand"
// randomPalindrome returns a palindrome whose length and contents
// are derived from the pseudo-random number generator rng.
func randomPalindrome(rng *rand.Rand) string {
n := rng.Intn(25) // random length up to 24
runes := make([]rune, n)
for i := 0; i < (n+1)/2; i++ {
r := rune(rng.Intn(0x1000)) // random rune up to '\u0999'
runes[i] = r
runes[n-1-i] = r
}
return string(runes)
}
func TestRandomPalindromes(t *testing.T) {
// Initialize a pseudo-random number generator.
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 !IsPalindrome(p) {
t.Errorf("IsPalindrome(%q) = false", p)
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
虽然随机测试会有不确定因素,但是它也是至关重要的,我们可以从失败测试的日志获取足够的信息。在我们的例子中,输入 IsPalindrome 的 p 参数将告诉我们真实的数据,另外我们可以不用保存所有的输入,只要日志中简单地记录随机数种子即可(像上面的方式),有了这些随机数初始化种子,我们可以很容易修改测试代码以重现失败的随机测试。通过使用当前时间作为随机种子,在整个过程中的每次运行测试命令时都将探索新的随机数据。如果你使用的是定期运行的自动化测试集成系统,随机测试将特别有价值。
3. 基准测试
在 Go 语言中,基准测试函数和普通测试函数写法类似,但是以 Benchmark 为前缀名,并且带有一个 *testing.B 类型的参数;*testing.B 参数除了提供和 *testing.T 类似的方法,还有额外一些和性能测量相关的方法。例如,它还提供了一个整数 N,用于指定操作执行的循环次数。
下面是 IsPalindrome 函数的基准测试,其中循环将执行 N 次。
Go
import "testing"
func BenchmarkIsPalindrome(b *testing.B) {
for i := 0; i < b.N; i++ {
IsPalindrome("A man, a plan, a canal: Panama")
}
}1
2
3
4
5
6
7
2
3
4
5
6
7
和普通测试不同的是,默认情况下不运行任何基准测试。我们需要通过 -bench 命令行标志参数手工指定要运行的基准测试函数,该参数是一个正则表达式,用于匹配要执行的基准测试函数的名字,默认值是空的。其中 “.” 模式将可以匹配所有基准测试函数。
Bash
$ cd $GOPATH/src/gopl.io/ch11/word2
$ go test -bench=.
PASS
BenchmarkIsPalindrome-8 1000000 1035 ns/op
ok gopl.io/ch11/word2 2.179s1
2
3
4
5
2
3
4
5
结果中基准测试名的数字后缀部分,这里是 8,表示运行时对应的 GOMAXPROCS 的值,这对于一些与并发相关的基准测试是重要的信息。
报告显示每次调用 IsPalindrome 函数花费 1.035 微秒,是执行 1,000,000 次的平均时间。因为基准测试驱动器开始时并不知道每个基准测试函数运行所花的时间,它会尝试在真正运行基准测试前先尝试用较小的 N 运行测试来估算基准测试函数所需要的时间,然后推断一个较大的时间保证稳定的测量结果。
循环在基准测试函数内实现,而不是放在基准测试框架内实现,这样可以让每个基准测试函数有机会在循环启动前执行初始化代码,这样并不会显著影响每次迭代的平均运行时间。如果还是担心初始化代码部分对测量时间带来干扰,那么可以通过 testing.B 参数提供的方法来临时关闭或重置计时器,不过这些一般很少会用到。
现在我们有了一个基准测试和普通测试,我们可以很容易测试改进程序运行速度的想法。也许最明显的优化是在 IsPalindrome 函数中第二个循环的停止检查,这样可以避免每个比较都做两次:
Go
n := len(letters)/2
for i := 0; i < n; i++ {
if letters[i] != letters[len(letters)-1-i] {
return false
}
}
return true1
2
3
4
5
6
7
2
3
4
5
6
7
不过很多情况下,一个显而易见的优化未必能带来预期的效果。这个改进在基准测试中只带来了 4% 的性能提升。
Bash
$ go test -bench=.
PASS
BenchmarkIsPalindrome-8 1000000 992 ns/op
ok gopl.io/ch11/word2 2.093s1
2
3
4
2
3
4
另一个改进想法是在开始为每个字符预先分配一个足够大的数组,这样就可以避免在 append 调用时可能会导致内存的多次重新分配。声明一个 letters 数组变量,并指定合适的大小,像下面这样:
Go
letters := make([]rune, 0, len(s))
for _, r := range s {
if unicode.IsLetter(r) {
letters = append(letters, unicode.ToLower(r))
}
}1
2
3
4
5
6
2
3
4
5
6
这个改进提升性能约 35%,报告结果是基于 2,000,000 次迭代的平均运行时间统计。
Bash
$ go test -bench=.
PASS
BenchmarkIsPalindrome-8 2000000 697 ns/op
ok gopl.io/ch11/word2 1.468s1
2
3
4
2
3
4
如这个例子所示,快的程序往往是伴随着较少的内存分配。-benchmem 命令行标志参数将在报告中包含内存的分配数据统计。我们可以比较优化前后内存的分配情况:
Bash
$ go test -bench=. -benchmem
PASS
BenchmarkIsPalindrome 1000000 1026 ns/op 304 B/op 4 allocs/op1
2
3
2
3
这是优化之后的结果:
Bash
$ go test -bench=. -benchmem
PASS
BenchmarkIsPalindrome 2000000 807 ns/op 128 B/op 1 allocs/op1
2
3
2
3
用一次内存分配代替多次的内存分配节省了 75% 的分配调用次数和减少近一半的内存需求。
这个基准测试告诉了我们某个具体操作所需的绝对时间,但我们往往想知道的是两个不同的操作的时间对比。例如,如果一个函数需要 1ms 处理 1,000 个元素,那么处理 10000 或 1 百万将需要多少时间呢?这样的比较揭示了渐近增长函数的运行时间。另一个例子:I/O 缓存该设置为多大呢?基准测试可以帮助我们选择在性能达标情况下所需的最小内存。第三个例子:对于一个确定的工作哪种算法更好?基准测试可以评估两种不同算法对于相同的输入在不同的场景和负载下的优缺点。
比较型的基准测试就是普通程序代码。它们通常是单参数的函数,由几个不同数量级的基准测试函数调用,就像这样:
Go
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) }1
2
3
4
2
3
4
通过函数参数来指定输入的大小,但是参数变量对于每个具体的基准测试都是固定的。要避免直接修改 b.N 来控制输入的大小。除非你将它作为一个固定大小的迭代计算输入,否则基准测试的结果将毫无意义。
比较型的基准测试反映出的模式在程序设计阶段是很有帮助的,但是即使程序完工了也应当保留基准测试代码。因为随着项目的发展,或者是输入的增加,或者是部署到新的操作系统或不同的处理器,我们可以再次用基准测试来帮助我们改进设计。