golang[66]-test测试

go test

1
2
3
go test是一个按照一定的约定和组织的测试代码的驱动程序.在包目录内,以_test.go为后缀名的源文件并不是go build构建包的以部分,它们是go test测试的一部分.
早*_test.go文件中,有三种类型的函数:测试函数,基准测试函数,例子函数.一个测试函数是以Test为函数名前缀的函数,用于测试程序的一些逻辑行为是否正确; go test会调用这些测试函数并报告测试结果是PASS或FAIL.基准测试函数是以Benchmark为函数名前缀的函数,用于衡量一些函数的性能; go test会多次运行基准函数以计算一个平均的执行时间.例子函数是以Example为函数名前缀的函数,提供一个由机器检测正确性的例子文档
go test命令会遍历所有的*_test.go文件中上述函数,然后生成一个临时的main包调用相应的测试函数,然后构建并运行,报告测试结果,最后清理临时文件.

测试函数

每个测试函数必须导入testing 包. 测试函数有如下的签名:

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

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

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

其中t 参数用于报告测试失败和附件的日志信息. 让我们顶一个一个实例包gopl.io/ch11/word1, 只有一个函数IsPalindrome 用于检查一个字符串是否从前向后和从后向前读都一样. (这个实现对于一个字符串是否是回文字符串前后重复测试了两次; 我们稍后会再讨论这个问题.)

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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 报告失败:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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/word1GOPATH/src/gopl.io/ch11/word1 go test
ok gopl.io/ch11/word1 0.008s
还比较满意, 我们运行了这个程序, 不过没有提前退出是因为还没有遇到BUG报告. 一个法国名为Noelle Eve Elleon 的用户抱怨IsPalindrome 函数不能识别’‘été.’’. 另外一个来自美国中部用户的抱怨是不能识别’‘A man, a plan, a canal: Panama.’’. 执行特殊和小的BUG报告为我们提供了新的更自然的测试用例.

1
2
3
4
5
6
7
8
9
10
11
12
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返回了测试失败的信息.

1
2
3
4
5
6
7
$ 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用于打印每个测试函数的名字和运行时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ 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运行:

1
2
3
4
5
6
7
8
9
10
$ 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, 我们仔细重写了函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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
}

同时我们也将之前的所有测试数据合并到了一个测试中的表格中.

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
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 的长度, 输出一个相关部分的简明总结卽可. 测试的作者应该要努力帮助程序员诊断失败的测试.

basic.go

1
2
3
4
5
func calcTriangle(a, b int) int {
var c int
c = int(math.Sqrt(float64(a*a + b*b)))
return c
}

triangle_test.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main

import "testing"

func TestTriangle(t *testing.T) {
tests := []struct{ a, b, c int }{
{3, 4, 5},
{5, 12, 13},
{8, 15, 17},
{12, 35, 37},
{30000, 40000, 50000},
}

for _, tt := range tests {
if actual := calcTriangle(tt.a, tt.b); actual != tt.c {
t.Errorf("calcTriangle(%d, %d); "+
"got %d; expected %d",
tt.a, tt.b, actual, tt.c)
}
}
}

noreactping_test.go

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
29
30
31
32
33
34
package main

import "testing"

func TestSubstr(t *testing.T) {
tests := []struct {
s string
ans int
}{
// Normal cases
{"abcabcbb", 3},
{"pwwkew", 3},

// Edge cases
{"", 0},
{"b", 1},
{"bbbbbbbbb", 1},
{"abcabcabcd", 4},

// Chinese support
{"这里是慕课网", 6},
{"一二三二一", 3},
{"黑化肥挥发发灰会花飞灰化肥挥发发黑会飞花", 8},
}

for _, tt := range tests {
actual := lengthOfNonRepeatingSubStr(tt.s)
if actual != tt.ans {
t.Errorf("got %d for input %s; "+
"expected %d",
actual, tt.s, tt.ans)
}
}
}

随机测试

表格驱动的测试便于构造基于精心挑选的测试数据的测试用例. 另一种测试思路是随机测试, 也就是通过构造更广泛的随机输入来测试探索函数的行为.
那么对于一个随机的输入, 我们如何能知道希望的输出结果呢? 这里有两种策略. 第一个是编写另一个函数, 使用简单和清晰的算法, 虽然效率较低但是行为和要测试的函数一致, 然后针对相同的随机输入检查两者的输出结果. 第二种是生成的随机输入的数据遵循特定的模式, 这样我们就可以知道期望的输出的模式.
下面的例子使用的是第二种方法: randomPalindrome 函数用于随机生成回文字符串.

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
29
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)
}
}
}

虽然随机测试有不确定因素, 但是它也是至关重要的, 我们可以从失败测试的日志获取足够的信息. 在我们的例子中, 输入IsPalindrome 的p 参数将告诉我们眞实的数据, 但是对于函数将接受更复杂的输入, 不需要保存所有的输入, 只要日志中简单地记录随机数种子卽可(像上面的方式). 有了这些随机数初始化种子, 我们可以很容易修改测试代码以重现失败的随机测试.
通过使用当前时间作为随机种子, 在整个过程中的每次运行测试命令时都将探索新的随机数据. 如果你使用的是定期运行的自动化测试集成系统, 随机测试将特别有价值.

测试代码覆盖率

1
2
go test  -coverprofile=c.out
go tool cover -html=c.out

性能测试

同一个函数执行N多次。

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
29
30
31
32
33
34
35
36
func BenchmarkSubstr(b *testing.B) {
s := "黑化肥挥发发灰会花飞灰化肥挥发发黑会飞花"
//for i := 0; i < 13; i++ {
// s = s + s
//}
//b.Logf("len(s) = %d", len(s))
ans := 8
//b.ResetTimer()

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)
}
}
}

func lengthOfNonRepeatingSubStr(s string) int {
lastOccurred := make(map[rune]int)
start := 0
maxLength := 0

for i, ch := range []rune(s) {
if lastI, ok := lastOccurred[ch]; ok && lastI >= start {
start = lastI + 1
}
if i-start+1 > maxLength {
maxLength = i - start + 1
}
lastOccurred[ch] = i
}

return maxLength
}