Go单元测试

go test工具

Go语言中的测试依赖 go test命令。编写测试代码和编写普通的Go代码过程是类似的,并不需要学习新的语法、规则或工具。

go test命令是一个按照一定约定和组织的测试代码的驱动程序。在包目录内,所有以 _test.go为后缀名的源代码文件都是 go test测试的一部分(测试函数的要求:文件名以_test.go结尾,函数名以Test等特殊单词开头,参数为*testing.T等),不会被 go build编译到最终的可执行文件中。

*_test.go文件中有三种类型的函数,单元测试函数、基准测试函数和示例函数。

image.png

go test命令会遍历所有的 *_test.go文件中符合上述命名规则的函数,然后生成一个临时的main包用于调用相应的测试函数,然后构建并运行、报告测试结果,最后清理测试中生成的临时文件。

测试函数

测试函数的格式

每个测试函数必须导入 testing包,测试函数的基本格式(签名)如下:

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

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

1
2
3
func TestAdd(t *testing.T){ ... }
func TestSum(t *testing.T){ ... }
func TestLog(t *testing.T){ ... }

其中参数 t用于报告测试失败和附加的日志信息。 testing.T的拥有的方法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
func (c *T) Error(args ...interface{})
func (c *T) Errorf(format string, args ...interface{})
func (c *T) Fail()
func (c *T) FailNow()
func (c *T) Failed() bool
func (c *T) Fatal(args ...interface{})
func (c *T) Fatalf(format string, args ...interface{})
func (c *T) Log(args ...interface{})
func (c *T) Logf(format string, args ...interface{})
func (c *T) Name() string
func (t *T) Parallel()
func (t *T) Run(name string, f func(t *T)) bool
func (c *T) Skip(args ...interface{})
func (c *T) SkipNow()
func (c *T) Skipf(format string, args ...interface{})
func (c *T) Skipped() bool

就像细胞是构成我们身体的基本单位,**一个软件程序也是由很多单元组件构成的。**单元组件可以是函数、结构体、方法和最终用户可能依赖的任意东西。总之我们需要确保这些组件是能够正常运行的。单元测试是一些利用各种方法测试单元组件的程序,它会将结果与预期输出进行比较。

接下来,我们定义一个 split的包,包中定义了一个 Split函数,具体实现如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
//以关键字分割字符串,如abc分割后就是[a c]
func Split(str string,sep string) []string{
	var ret []string
	index:=strings.Index(str,sep)
	for index>=0{
		ret=append(ret,str[:index])
		str=str[index+len(sep):]
		index=strings.Index(str,sep)
	}
	ret=append(ret,str)
	return ret
}

在当前目录下,创建一个 split_test.go的测试文件,并定义一个测试函数如下:

1
2
3
4
5
6
7
func TestSplit(t *testing.T) { // 测试函数名必须以Test开头,必须接收一个*testing.T类型参数
	got := Split("a:b:c", ":")         // 程序输出的结果
	want := []string{"a", "b", "c"}    // 期望的结果
	if !reflect.DeepEqual(want, got) { // 因为slice不能比较直接,借助反射包中的方法比较
		t.Errorf("excepted:%v, got:%v", want, got) // 测试失败输出错误提示
	}
}

split包路径下,执行 go test命令,可以看到输出结果如下:

1
2
3
go test
PASS
ok      github.com/Q1mi/studygo/code_demo/test_demo/split       0.005s

也可以用 go test -run TestAdd来执行单个测试函数,可以为 go test命令添加 -v参数,查看测试函数名称和运行时间:

1
2
3
4
5
6
7
go test -v
=== RUN   TestAdd
=== RUN   TestAdd/case1
--- PASS: TestAdd (0.00s)
    --- PASS: TestAdd/case1 (0.00s)
PASS
ok      test    0.029s

注意:当我们修改了我们的代码之后不仅仅要执行那些失败的测试函数,我们应该完整的运行所有的测试,保证不会因为修改代码而引入了新的问题。

测试组

我们现在还想要测试一下 split函数对中文字符串的支持,这个时候我们可以再编写一个 TestChineseSplit测试函数,但是我们也可以使用如下更友好的一种方式来添加更多的测试用例。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func TestSplit(t *testing.T) {
   // 定义一个测试用例类型
	type test struct {
		input string
		sep   string
		want  []string
	}
	// 定义一个存储测试用例的切片
	tests := []test{
		{input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
		{input: "a:b:c", sep: ",", want: []string{"a:b:c"}},
		{input: "abcd", sep: "bc", want: []string{"a", "d"}},
		{input: "沙河有沙又有河", sep: "沙", want: []string{"河有", "又有河"}},
	}
	// 遍历切片,逐一执行测试用例
	for _, tc := range tests {
		got := Split(tc.input, tc.sep)
		if !reflect.DeepEqual(got, tc.want) {
			t.Errorf("excepted:%v, got:%v", tc.want, got)
		}
	}
}

我们通过上面的代码把多个测试用例合到一起,再次执行 go test命令。

1
2
3
4
5
6
7
split $ go test -v
=== RUN   TestSplit
--- FAIL: TestSplit (0.00s)
    split_test.go:42: excepted:[河有 又有河], got:[ 河有 又有河]
FAIL
exit status 1
FAIL    github.com/Q1mi/studygo/code_demo/test_demo/split       0.006s

我们的测试出现了问题,仔细看打印的测试失败提示信息:excepted:[河有 又有河], got:[ 河有 又有河],你会发现 [ 河有 又有河]有个不明显的空串,这种情况下十分推荐使用 %#v的格式化方式。

子测试

如果测试用例比较多的时候,我们是没办法一眼看出来具体是哪个测试用例失败了(只会报出错误信息,是什么函数的问题还得自己找)。所以可以使用map函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
func TestSplit(t *testing.T) {
	type test struct { // 定义test结构体
		input string
		sep   string
		want  []string
	}
	tests := map[string]test{ // 测试用例使用map存储
		"simple":      {input: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
		"wrong sep":   {input: "a:b:c", sep: ",", want: []string{"a:b:c"}},
		"more sep":    {input: "abcd", sep: "bc", want: []string{"a", "d"}},
		"leading sep": {input: "沙河有沙又有河", sep: "沙", want: []string{"河有", "又有河"}},
	}
	for name, tc := range tests {
		got := Split(tc.input, tc.sep)
		if !reflect.DeepEqual(got, tc.want) {
			t.Errorf("name:%s excepted:%#v, got:%#v", name, tc.want, got) // 将测试用例的name格式化输出
		}
	}
}

上面的做法是能够解决问题的。同时Go1.7+中新增了子测试,我们可以按照如下方式使用 t.Run执行子测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
func TestAdd(t *testing.T){	// 测试函数名必须以Test开头,必须接收一个*testing.T类型参数
	type testCase struct {	// 定义test结构体
		str string
		ret string
		want []string
	}
	testGroup:=map[string]testCase{	// 测试用例使用map存储
		"case1":{"abce","b",[]string{"a","ce"}},
		"case2":{"a:b:c",":",[]string{"a","b","c"}},
		"case3":{"abce","bc",[]string{"a","e"}},
		"case4":{"沙河有沙又有河","沙",[]string{"","河有","又有河"}},
	}
	for key,testCa :=range testGroup{
		t.Run(key,func(t *testing.T){	// 使用t.Run()执行子测试
			got:=Split(testCa.str,testCa.ret)
			if !reflect.DeepEqual(got,testCa.want){
				t.Errorf("error,you want %#v,but it is %#v\n",testCa.want,got)
			}
		})
	}
}

此时我们再执行 go test命令就能够看到更清晰的输出内容了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
go test -v
=== RUN   TestAdd
=== RUN   TestAdd/case1
=== RUN   TestAdd/case2
=== RUN   TestAdd/case3
=== RUN   TestAdd/case4
--- PASS: TestAdd (0.00s)
    --- PASS: TestAdd/case1 (0.00s)
    --- PASS: TestAdd/case2 (0.00s)
    --- PASS: TestAdd/case3 (0.00s)
    --- PASS: TestAdd/case4 (0.00s)
PASS
ok      test    0.156s

我们都知道可以通过 -run=RegExp来指定运行的测试用例,还可以通过 /来指定要运行的子测试用例,例如:go test -v -run=Split/case1只会运行 case1对应的子测试用例。

测试覆盖率

测试覆盖率是你的代码被测试套件覆盖的百分比。通常我们使用的都是语句的覆盖率,也就是在测试中至少被运行一次的代码占总代码的比例。

Go提供内置功能来检查你的代码覆盖率。我们可以使用 go test -cover来查看测试覆盖率。

1
2
3
4
go test -cover
PASS
coverage: 100.0% of statements
ok      test    0.184s

从上面的结果可以看到我们的测试用例覆盖了100%的代码。

Go还提供了一个额外的 -coverprofile参数,用来将覆盖率相关的记录信息输出到一个文件。

1
2
3
4
go test -cover -coverprofile=c.out
PASS
coverage: 100.0% of statements
ok      github.com/Q1mi/studygo/code_demo/test_demo/split       0.005s

上面的命令会将覆盖率相关的信息输出到当前文件夹下面的 c.out文件中,然后我们执行 go tool cover -html=c.out,使用 cover工具来处理生成的记录信息,该命令会打开本地的浏览器窗口生成一个HTML报告。Go test cover上图中每个用绿色标记的语句块表示被覆盖了,而红色的表示没有被覆盖。

通常测试函数覆盖率要求达到100%,测试覆盖率要达到60%,如果没到60%要么就是代码考虑不周全,要么就是永远不可能执行到的语句有很多

基准测试

基准测试函数格式

基准测试就是在一定的工作负载之下检测程序性能的一种方法。基准测试的基本格式如下:

1
2
3
func BenchmarkName(b *testing.B){
    // ...
}

基准测试以 Benchmark为前缀,需要一个 *testing.B类型的参数b,基准测试必须要执行 b.N次,这样的测试才有对照性,b.N的值是系统根据实际情况去调整的,从而保证测试的稳定性(至少运行一秒钟)。 b.N不是固定的数,它会尽量去跑,能跑多少次就是多少次**,** testing.B拥有的方法如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
func (c *B) Error(args ...interface{})
func (c *B) Errorf(format string, args ...interface{})
func (c *B) Fail()
func (c *B) FailNow()
func (c *B) Failed() bool
func (c *B) Fatal(args ...interface{})
func (c *B) Fatalf(format string, args ...interface{})
func (c *B) Log(args ...interface{})
func (c *B) Logf(format string, args ...interface{})
func (c *B) Name() string
func (b *B) ReportAllocs()
func (b *B) ResetTimer()
func (b *B) Run(name string, f func(b *B)) bool
func (b *B) RunParallel(body func(*PB))
func (b *B) SetBytes(n int64)
func (b *B) SetParallelism(p int)
func (c *B) Skip(args ...interface{})
func (c *B) SkipNow()
func (c *B) Skipf(format string, args ...interface{})
func (c *B) Skipped() bool
func (b *B) StartTimer()
func (b *B) StopTimer()

为split包中的 Split函数编写基准测试如下:

1
2
3
4
5
func BenchmarkSplit(b *testing.B) {
	for i := 0; i < b.N; i++ {
		Split("沙河有沙又有河", "沙")
	}
}

**基准测试并不会默认执行,需要增加 -bench参数,**所以我们通过执行 go test -bench=Split命令执行基准测试,输出结果如下:

1
2
3
4
5
6
7
go test -bench=Split
goos: darwin
goarch: amd64
pkg: github.com/Q1mi/studygo/code_demo/test_demo/split
BenchmarkSplit-8        10000000               203 ns/op
PASS
ok      github.com/Q1mi/studygo/code_demo/test_demo/split       2.255s

其中 BenchmarkSplit-8表示对Split函数进行基准测试,数字 8表示 GOMAXPROCS的值,这个对于并发基准测试很重要。10000000203ns/op表示每次调用 Split函数耗时 203ns,这个结果是 10000000次调用的平均值。

我们还可以为基准测试添加 -benchmem参数,来获得内存分配的统计数据。

1
2
3
4
5
6
7
split $ go test -bench=Split -benchmem
goos: darwin
goarch: amd64
pkg: github.com/Q1mi/studygo/code_demo/test_demo/split
BenchmarkSplit-8        10000000               215 ns/op             112 B/op          3 allocs/op
PASS
ok      github.com/Q1mi/studygo/code_demo/test_demo/split       2.394s

其中,112 B/op表示每次操作内存分配了112字节,3 allocs/op则表示每次操作进行了3次内存分配。然后提前使用make函数将result初始化为一个容量足够大的切片,而不再像之前一样通过调用append函数来追加。(Count用于统计一个字符在字符串中出现的总次数)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func Split(s, sep string) (result []string) {
	result = make([]string, 0, strings.Count(s, sep)+1)
	i := strings.Index(s, sep)
	for i > -1 {
		result = append(result, s[:i])
		s = s[i+len(sep):] // 这里使用len(sep)获取sep的长度
		i = strings.Index(s, sep)
	}
	result = append(result, s)
	return
}
1
2
3
4
5
6
7
split $ go test -bench=Split -benchmem
goos: darwin
goarch: amd64
pkg: github.com/Q1mi/studygo/code_demo/test_demo/split
BenchmarkSplit-8        10000000               127 ns/op              48 B/op          1 allocs/op
PASS
ok      github.com/Q1mi/studygo/code_demo/test_demo/split       1.423s

这个使用make函数提前分配内存的改动,减少了2/3的内存分配次数,并且减少了一半的内存分配。因此尽量少让系统动态分配切片或者map的大小,它在map或切片不足时进行扩容就会再申请一次内存,会使程序运行变慢

性能比较函数

上面的基准测试只能得到给定操作的绝对耗时,但是在很多性能问题是发生在两个不同操作之间的相对耗时,比如同一个函数处理1000个元素的耗时与处理1万甚至100万个元素的耗时的差别是多少?再或者对于同一个任务究竟使用哪种算法性能最佳?我们通常需要对两个不同算法的实现使用相同的输入来进行基准比较测试。

性能比较函数通常是一个带有参数的函数,被多个不同的Benchmark函数传入不同的值来调用。举个例子如下:

1
2
3
4
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
 5
 6
 7
 8
 9
10
11
func benchmarkFib(b *testing.B, n int) {
	for i := 0; i < b.N; i++ {
		Fib(n)
	}
}
func BenchmarkFib1(b *testing.B)  { benchmarkFib(b, 1) }
func BenchmarkFib2(b *testing.B)  { benchmarkFib(b, 2) }
func BenchmarkFib3(b *testing.B)  { benchmarkFib(b, 3) }
func BenchmarkFib10(b *testing.B) { benchmarkFib(b, 10) }
func BenchmarkFib20(b *testing.B) { benchmarkFib(b, 20) }
func BenchmarkFib40(b *testing.B) { benchmarkFib(b, 40) }

运行基准测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
split $ go test -bench=.
goos: darwin
goarch: amd64
pkg: github.com/Q1mi/studygo/code_demo/test_demo/fib
BenchmarkFib1-8         1000000000               2.03 ns/op
BenchmarkFib2-8         300000000                5.39 ns/op
BenchmarkFib3-8         200000000                9.71 ns/op
BenchmarkFib10-8         5000000               325 ns/op
BenchmarkFib20-8           30000             42460 ns/op
BenchmarkFib40-8               2         638524980 ns/op
PASS
ok      github.com/Q1mi/studygo/code_demo/test_demo/fib 12.944s

**这里需要注意的是,默认情况下,每个基准测试至少运行1秒。**如果在Benchmark函数返回时没有到1秒,则b.N的值会按1,2,5,10,20,50,…增加,并且函数再次运行。

最终的BenchmarkFib40只运行了两次,每次运行的平均值只有不到一秒。像这种情况下我们应该可以使用 -benchtime标志增加最小基准时间,以产生更准确的结果。例如:

1
2
3
4
5
6
7
split $ go test -bench=Fib40 -benchtime=20s
goos: darwin
goarch: amd64
pkg: github.com/Q1mi/studygo/code_demo/test_demo/fib
BenchmarkFib40-8              50         663205114 ns/op
PASS
ok      github.com/Q1mi/studygo/code_demo/test_demo/fib 33.849s

这一次 BenchmarkFib40函数运行了50次,结果就会更准确一些了。

**使用性能比较函数做测试的时候一个容易犯的错误就是把 b.N作为输入的大小,**b.N是系统跑满一秒的次数,它是不确定的,不能这样使用

重置时间

b.ResetTimer之前的处理不会放到执行时间里,也不会输出到报告中,所以可以在之前做一些不计划作为测试报告的操作。例如:

1
2
3
4
5
6
7
func BenchmarkSplit(b *testing.B) {
	time.Sleep(5 * time.Second) // 假设需要做一些耗时的无关操作
	b.ResetTimer()              // 重置计时器
	for i := 0; i < b.N; i++ {
		Split("沙河有沙又有河", "沙")
	}
}

并行测试

func (b *B) RunParallel(body func(*PB))会以并行的方式执行给定的基准测试。

RunParallel会创建出多个 goroutine,并将 b.N分配给这些 goroutine执行, 其中 goroutine数量的默认值为 GOMAXPROCS。用户如果想要增加非CPU受限(non-CPU-bound)基准测试的并行性, 那么可以在 RunParallel之前调用 SetParallelismRunParallel通常会与 -cpu标志一同使用。

1
2
3
4
5
6
7
8
func BenchmarkSplitParallel(b *testing.B) {
	// b.SetParallelism(1) // 设置使用的CPU数
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			Split("沙河有沙又有河", "沙")
		}
	})
}

执行一下基准测试:

1
2
3
4
5
6
7
8
split $ go test -bench=.
goos: darwin
goarch: amd64
pkg: github.com/Q1mi/studygo/code_demo/test_demo/split
BenchmarkSplit-8                10000000               131 ns/op
BenchmarkSplitParallel-8        50000000                36.1 ns/op
PASS
ok      github.com/Q1mi/studygo/code_demo/test_demo/split       3.308s

还可以通过在测试命令后添加 -cpu参数如 go test -bench=. -cpu 1来指定使用的CPU数量。

Licensed under CC BY-NC-SA 4.0