34.单元测试了解
34.单元测试了解
不写测试的开发不是好的程序猿。Go语言中的测试依赖go test
命令。编写测试代码和编写普通的Go代码过程是类似的,并不需要学习新的语法,规则或者工具。
1,go test
go test
命令是一个按照一定约定和组织的测试代码的驱动程序。在包目录内,所有以_test.go
为后缀的源代码文件都是测试的一部分,不会被go build
编译到最终的可执行文件中。
在*_test.go
文件中有三种类型的函数,单元测试函数
,基准测试函数
和示例函数
。
类型 | 格式 | 作用 |
---|---|---|
测试函数 | 函数名前缀为Test | 测试程序的一些逻辑行为是否正确 |
基准函数 | 函数名前缀为Benchmark | 测试函数的性能 |
示例函数 | 函数名前缀为Example | 为文档提供示例文档 |
go test
命令会遍历所有的*_test.go
文件中符合上述命名规则的函数,然后生成一个临时的main包用于调用相应的测试函数,然后构建并运行,报告测试结果,最后清理测试中生成的临时文件。
2,测试函数格式
每个测试函数必须导入testing包,测试函数基本格式如下:
func TestName(t *testing.T){
//...
}
测试函数的名字必须以Test开头,可选的后缀名必须以大写字母开头:
func TestAdd(t *testing.T){...}
func TestSum(t *testing.T){...}
其中参数t
用于报告测试失败和附加的日志信息。 testing.T
的拥有的方法如下:
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
3,测试函数示例
模拟日常开发过程, 我们先写一个功能单一的程序,这个程序可以分割字符串,代码如下:
package split
import "strings"
// Split 切割一个字符串
func Split(str string, sep string) []string {
var tmp []string
index := strings.Index(str, sep)
for index >= 0 {
tmp = append(tmp, str[:index])
str = str[index+1:]
index = strings.Index(str, sep)
}
tmp = append(tmp, str)
return tmp
}
在同级目录下,可以创建一个 split_test.go
的测试文件,并定义一个测试函数如下:
package split
import (
"reflect"
"testing"
)
// 测试函数名必须以Test开头,必须接收一个*testing.T类型参数
func TestSplit(t *testing.T) {
// 程序输出的结果
got := Split("a:b:c", ":")
// 期望的结果
want := []string{"a", "b", "c"}
// 因为slice不能比较直接,借助反射包中的方法比较
if !reflect.DeepEqual(want, got) {
// 测试失败输出错误提示
t.Errorf("test failed, want:%v but got:%v\n", want, got)
}
}
func Test2Split(t *testing.T) {
got := Split("abcdef", "c")
want := []string{"ab", "def"}
if !reflect.DeepEqual(want, got) {
t.Errorf("test failed, want:%v but got:%v\n", want, got)
}
}
然后在这个目录中,直接运行 go test
可以看到输出结果:
$ go test
PASS
ok oldboy/day09/split 0.005s
使用 go test -v
可以看到详细内容:
$ go test -v
=== RUN TestSplit
--- PASS: TestSplit (0.00s)
=== RUN Test2Split
--- PASS: Test2Split (0.00s)
PASS
ok oldboy/day09/split 0.005s
如上测试用例当中写了两个测试用例,都验证通过了,现在再写一个如下内容的用例:
func Test3Split(t *testing.T) {
got := Split("abcdef", "cd")
want := []string{"ab", "ef"}
if !reflect.DeepEqual(want, got) {
t.Errorf("test failed, want:%v but got:%v\n", want, got)
}
}
跑一下,发现报错了:
$ go test -v
=== RUN TestSplit
--- PASS: TestSplit (0.00s)
=== RUN Test2Split
--- PASS: Test2Split (0.00s)
=== RUN Test3Split
--- FAIL: Test3Split (0.00s)
split_test.go:33: test failed, want:[ab ef] but got:[ab def]
FAIL
exit status 1
FAIL oldboy/day09/split 0.004s
看起来程序采用这个用例就跑不过去了,那么就需要调整程序:
package split
import (
"fmt"
"strings"
)
// Split 切割一个字符串
func Split(str string, sep string) []string {
var tmp []string
index := strings.Index(str, sep)
fmt.Println(index)
for index >= 0 {
tmp = append(tmp, str[:index])
str = str[index+len(sep):] // 这里使用len(sep)获取sep的长度
index = strings.Index(str, sep)
fmt.Println(index)
}
tmp = append(tmp, str)
return tmp
}
再跑一下,就通过。
4,测试组
我们现在还想要测试一下split
函数对中文字符串的支持,这个时候我们可以再编写一个TestChineseSplit
测试函数,但是我们也可以使用如下更友好的一种方式来添加更多的测试用例。
package split
import (
"reflect"
"testing"
)
func TestSplit(t *testing.T) {
// 定义一个测试用例类型
type testCase struct {
str string
sep string
want []string
}
// 定义一个存储测试用例的切片
testGroup := []testCase{
testCase{"a:b:c", ":", []string{"a", "b", "c"}},
testCase{"abcdef", "c", []string{"ab", "def"}},
testCase{"你好吗", "好", []string{"你", "吗"}},
}
// 遍历切片,逐一执行测试用例
for _, tc := range testGroup {
got := Split(tc.str, tc.sep)
if !reflect.DeepEqual(tc.want, got) {
t.Fatalf("test failed,want:%v,got:%v\n", tc.want, got)
}
}
}
执行结果如下:
$ go test -v
=== RUN TestSplit
--- PASS: TestSplit (0.00s)
PASS
ok oldboy/day09/split 0.004s
5,子测试
上边的定义方式大大简化了针对同一功能代码用例的编写,只需要在切片中新增一个testCase即可,但是有一个不足就是所有用例执行结果不能够清晰逐个看到。在Go1.7+中新增了子测试,可以通过定义map的方式,将每个用例名字打印出来:
package split
import (
"reflect"
"testing"
)
func TestSplit(t *testing.T) {
// 定义一个测试用例类型
type testCase struct {
str string
sep string
want []string
}
// 定义一个存储测试用例的map
testGroup := map[string]testCase{
"case-1": testCase{"a:b:c", ":", []string{"a", "b", "c"}},
"case-2": testCase{"abcdef", "c", []string{"ab", "def"}},
"case-3": testCase{"你好吗", "好", []string{"你", "吗"}},
}
// 遍历map,逐一执行测试用例
for name, tc := range testGroup {
t.Run(name, func(t *testing.T) {
got := Split(tc.str, tc.sep)
if !reflect.DeepEqual(tc.want, got) {
t.Fatalf("test failed,want:%#v,got:%#v\n", tc.want, got)
}
})
}
}
执行效果如下:
$ go test -v
=== RUN TestSplit
=== RUN TestSplit/case-3
=== RUN TestSplit/case-1
=== RUN TestSplit/case-2
--- PASS: TestSplit (0.00s)
--- PASS: TestSplit/case-3 (0.00s)
--- PASS: TestSplit/case-1 (0.00s)
--- PASS: TestSplit/case-2 (0.00s)
PASS
ok oldboy/day09/split 0.005s
如果想要指定执行其中某个用例,可用如下方式进行:
$ go test -v -run=TestSplit/case-2
=== RUN TestSplit
=== RUN TestSplit/case-2
--- PASS: TestSplit (0.00s)
--- PASS: TestSplit/case-2 (0.00s)
PASS
ok oldboy/day09/split 0.004s
6,测试覆盖率
测试覆盖率是你的代码被测试套件覆盖的百分比。通常我们使用的都是语句的覆盖率,也就是在测试中至少被运行一次的代码占总代码的比例。
Go提供内置功能来检查你的代码覆盖率。我们可以使用go test -cover
来查看测试覆盖率。例如:
$ go test -cover
PASS
coverage: 100.0% of statements
ok oldboy/day09/split 0.004s
从上面的结果可以看到我们的测试用例覆盖了100%的代码。
Go还提供了一个额外的-coverprofile
参数,用来将覆盖率相关的记录信息输出到一个文件。例如:
$ go test -cover -coverprofile=c.out
PASS
coverage: 100.0% of statements
ok oldboy/day09/split 0.004s
上面的命令会将覆盖率相关的信息输出到当前文件夹下面的c.out
文件中,然后我们执行go tool cover -html=c.out
,使用cover
工具来处理生成的记录信息,该命令会打开本地的浏览器窗口生成一个HTML报告。
图中每个用绿色标记的语句块表示被覆盖了,而红色的表示没有被覆盖。
日常开发当中:
函数覆盖率:应该达到100%
代码覆盖率:应该超过60%
7,基准测试函数
基准测试就是在一定的工作负载之下检测程序性能的一种方法。基准测试的基本格式如下:
func BenchmarkName(b *testing.B){
// ...
}
基准测试以Benchmark为前缀,需要一个*testing.B类型的参数b,基准测试必须要执行b.N次,这样的测试才有对照性,b.N的值是系统根据实际情况去调整的,从而保证测试的稳定性。 testing.B拥有的方法如下:
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()
1,基准测试示例
我们为split包中的Split
函数编写基准测试如下:
$ cat split_test.go
package split
import (
"testing"
)
func BenchmarkSplit(b *testing.B) {
for i := 0; i < b.N; i++ {
Split("a:b:c:d:e", ":")
}
}
基准测试并不会默认执行,需要增加-bench
参数,所以我们通过执行go test -bench=Split
命令执行基准测试,输出结果如下:
$ go test -bench=Split
goos: darwin
goarch: amd64
pkg: oldboy/day09/split
BenchmarkSplit-4 5000000 242 ns/op
PASS
ok oldboy/day09/split 1.473s
其中BenchmarkSplit-4
表示对Split函数进行基准测试,数字4
表示GOMAXPROCS
的值,这个对于并发基准测试很重要。5000000
和242ns/op
表示每次调用Split
函数耗时242ns
,这个结果是5000000
次调用的平均值。
我们还可以为基准测试添加-benchmem
参数,来获得内存分配的统计数据。
$ go test -bench=Split -benchmem
goos: darwin
goarch: amd64
pkg: oldboy/day09/split
BenchmarkSplit-4 5000000 250 ns/op 240 B/op 4 allocs/op
PASS
ok oldboy/day09/split 1.515s
其中,240 B/op
表示每次操作内存分配了240字节,4 allocs/op
则表示每次操作进行了3次内存分配。 我们将我们的Split
函数优化如下:
// Split 切割一个字符串
func Split(str string, sep string) []string {
var tmp = make([]string, 0, strings.Count(str, sep)+1) // 在声明变量的时候直接make分配内存空间
index := strings.Index(str, sep)
for index >= 0 {
tmp = append(tmp, str[:index])
str = str[index+len(sep):]
index = strings.Index(str, sep)
}
tmp = append(tmp, str)
return tmp
}
改进之后再次进行性能测试看看效果:
$ go test -bench=Split -benchmem
goos: darwin
goarch: amd64
pkg: oldboy/day09/split
BenchmarkSplit-4 20000000 101 ns/op 80 B/op 1 allocs/op
PASS
ok oldboy/day09/split 2.146s
这个使用make函数提前分配内存的改动,减少了2/3的内存分配次数,并且减少了一半的内存分配。