单元测试
Go语言中的测试依赖go test命令,编写测试代码和编写普通的Go代码过程是类似的,并不需要学习新的语法,规则或工具。
go test命令是一个按照一定约定和组织的测试代码的驱动程序,在包目录内,所有以_test.go为后缀名的源代码文件都是go test测试的一部分,不会被go build编译到最终的可执行文件。
在*_test.go文件中有三种类型的函数,单元测试函数,基准测试函数和示例函数。
类型 | 格式 | 作用 |
---|---|---|
测试函数 | 函数名前缀为Test | 测试程序的一些逻辑行为是否正确 |
基准函数 | 函数前缀名为Benchmark | 测试函数的性能 |
示例函数 | 函数名为Example | 为文档提供示例文档 |
go test命令会遍历所有的*_test.go文件中符合上述命令的规则函数,然后生成一个临时的main包用于调用相应的测试函数,然后构建并运行,报告测试结果,最后清理测试中生成的临时文件。
测试函数
测试函数的格式
每个测试函数必须导入testing包,测试函数的基本格式(签名)如下:
func TestName(t *testing.T){
// ...
}
测试函数的名字必须以Test开头,可选的后缀名必须以大写字母开头,举几个例子:
func TestAdd(t *testing.T){...}
func TestSum(t *testing.T){...}
func TestLog(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) 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 (c *T) Parallel()
func (c *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 ...interfaceP{})
func (c *T) Skipped() bool
测试函数示例
就像细胞是构成我们身体的基本单位,一个软件程序也是由很多单元组件构成的,单元组件可以是函数,结构体,方法和最终用户可能依赖的任意东西。总之我们需要确保这些最贱是能够正常运行的,单元测试是一些利用各种方法测试单元组件的程序,它会将结果与预期输出进行比较。
接下来我们定义一个split的包,包里定义一个Split函数,具体实现如下:
package spilt
import (
"strings"
)
func Split(s, sep string)(result []string){
i := strings.Index(s, sep) // 获取到第一次的索引值
for i > -1{ // 循环判断
result = append(result, s[:i])
s = s[i+1:]
i = strings.Index(s, sep)
}
result = append(result, s)
return
}
在当前目录下,我们创建一个split_test.go的测试文件,并定义一个测试函数如下:
package spilt
import (
"reflect"
"testing"
)
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命令,可以看到输出的结果如下:
D:\gocode\src\Negan\split>go test
PASS
ok _/D_/gocode/src/Negan/split 0.600s
一个测试用例有点单薄,我们在编写一个测试使用多个字符切割字符串的例子,在split_test.go中添加如下测试函数:
func TestMoreSplit(t *testing.T){
got := Split("abcd","bc")
want := []string{"a", "d"}
if !reflect.DeepEqual(want, got){
t.Errorf("excepted:%v, got:%v",want, got)
}
}
再次运行go test命令,输出结果如下:
D:\gocode\src\Negan\split>go test
--- FAIL: TestMoreSplit (0.00s)
split_test.go:23: excepted:[a d], got:[a cd]
FAIL
exit status 1
FAIL _/D_/gocode/src/Negan/split 0.708s
这一次我们的测试失败了,我们可以在go test命令后面添加-v参数,查看测试函数名称和运行时间。
D:\gocode\src\Negan\split>go test -v
=== RUN TestSplit
--- PASS: TestSplit (0.00s)
=== RUN TestMoreSplit
TestMoreSplit: split_test.go:23: excepted:[a d], got:[a cd]
--- FAIL: TestMoreSplit (0.00s)
FAIL
exit status 1
FAIL _/D_/gocode/src/Negan/split 0.703s
这次我们能清除的看到TestMoreSplit这个测试没有成功,还可以在go test命令后添加-run参数,它对应一个正则表达式,只有函数名匹配上的测试函数才会被go test命令执行。
go test -run="More"
--- FAIL: TestMoreSplit (0.00s)
split_test.go:23: excepted:[a d], got:[a cd]
FAIL
exit status 1
FAIL _/D_/gocode/src/Negan/split 0.735s
现在我们回过头来解决我们程序中的问题,很显然我们最初的split函数并没有考虑到sep为多个字符的情况,我们来修复这个Bug:
package spilt
import (
"strings"
)
func Split(s, sep string)(result []string){
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
}
这一次我们再来测试一下,注意当我们修改了我们的代码之后不要金金之星那些失败的测试函数,我们应该完整的运行所有的测试,保证不会因为修改代码而引入新的问题。
go test -v
=== RUN TestSplit
--- PASS: TestSplit (0.00s)
=== RUN TestMoreSplit
--- PASS: TestMoreSplit (0.00s)
PASS
ok _/D_/gocode/src/Negan/split 0.703s
测试组
我们现在还想要测试一下split函数对中文字符串的支持,这个时候我们可以再编写一个TestChineseSplit测试函数,但是我们也可以使用如下更友好的一种方式来添加更多的测试用例。
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 -v命令
go test -v
=== RUN TestSplit
--- PASS: TestSplit (0.00s)
PASS
ok _/D_/gocode/src/Negan/split 0.742s
子测试
如果测试用例比较多的时候,我们是没有办法一眼看出具体是哪个测试用例失败了,我们可能会想到下面的解决方法:
func TestSplit(t *testing.T) {
type test struct{
input string
sep string
want []string
}
tests := map[string]test{
"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)
}
}
}
上面的方法是能够解决问题的,同时Go1.7+中新增了子测试,我们可以按照如下方式使用t.Run执行子测试:
func TestSplit(t *testing.T) {
type test struct{
input string
sep string
want []string
}
tests := map[string]test{
"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{
t.Run(name, func(t *testing.T) { // 使用t.Run()执行子测试
got := Split(tc.input, tc.sep)
if !reflect.DeepEqual(got, tc.want){
t.Errorf("name:%s excepted:%#v, got:%#v", name, tc.want, got)
}
})
}
}
此时我们再执行go test -v命令就能够看到更清晰的输出内容了
go test -v
=== RUN TestSplit
=== RUN TestSplit/more_sep
=== RUN TestSplit/leading_sep
TestSplit/leading_sep: split_test.go:70: name:leading sep excepted:[]string{"", "上海", "来水来"
, "海上"}, got:[]string{"上海", "来水来", "海上"}
=== RUN TestSplit/simple
=== RUN TestSplit/wrong_sep
--- FAIL: TestSplit (0.00s)
--- PASS: TestSplit/more_sep (0.00s)
--- FAIL: TestSplit/leading_sep (0.00s)
--- PASS: TestSplit/simple (0.00s)
--- PASS: TestSplit/wrong_sep (0.00s)
FAIL
exit status 1
FAIL _/D_/gocode/src/Negan/split 0.773s
我们知道可以通过-run=RegExp来指定运行的测试用例,还可以通过/指定要运行的子测试用例,例如:go test -v -run=Split/simple只会运行simple对应的子测试用例。
go test -v -run=Split/simple
=== RUN TestSplit
=== RUN TestSplit/simple
--- PASS: TestSplit (0.00s)
--- PASS: TestSplit/simple (0.00s)
PASS
ok _/D_/gocode/src/Negan/split 0.713s
测试覆盖率
测试覆盖率是袋中被测试套件覆盖的百分比,通常我们使用的都是语句的覆盖率,也就是在测试中至少被运行一次的代码占总代码的比例。
Go提供了内置功能来检查代码覆盖率,使用go test -cover来查看测试覆盖率。
>go test -cover
PASS
coverage: 100.0% of statements
ok _/D_/gocode/src/Negan/split 0.685s
从上面的结果可以看到我们的测试用例覆盖了100%的代码。
Go还提供了一个额外的-coverprofile参数,用来将覆盖率相关的记录信息输出到一个文件,例如:
>go test -cover -coverprofile=c.out
PASS
coverage: 100.0% of statements
ok _/D_/gocode/src/Negan/split 0.812s
上面的命令会将覆盖率相关的信息输出到当前文件下面的c.out文件中,然后我们执行go tool cover -html=c.out,使用cover工具来处理生成的记录信息,该命令会打开本地的浏览器窗口生成一个html报告。
基准测试
基准测试函数格式
基准测试就是在一定的工作负载之下检测程序性能的一种方法。基准测试的基本格式如下:
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()
func (b *B) StopTimer()
基准测试示例
我们为split包中的Split函数编写基准测试如下:
func BenchmarkSplit(b *testing.B) {
for i := 0; i < b.N; i++{
Split("上海自来水来自海上","水")
}
}
基准测试并不会默认执行,需要增加-bench参数,所以我们通过执行go test-bench=Split命令执行基准测试,输出结果如下:
go test -bench=Split
goos: windows
goarch: amd64
BenchmarkSplit-4 4818822 246 ns/op
PASS
ok _/D_/gocode/src/Negan/split 2.041s
其种BenchmarkSplit-4表示对Split函数进行基准测试,数字4表示GOMAXPROCS的值,这个对于并发基准测试很重要。4818822和246 ns/op表示每次代用Split函数耗时246ns,这个结果是4848822次调用的平均值。
我们还可以为基准测试添加-benchmem参数来获得内存分配的统计数据。
>go test -bench=Split -benchmem
goos: windows
goarch: amd64
BenchmarkSplit-4 4708045 256 ns/op 48 B/op 2 allocs/op
PASS
ok _/D_/gocode/src/Negan/split 2.220s
其中48B/op表示每次操作内存分配了48字节,2 allocs/op则表示每次操作进行了2次内存分配,我们将我们的Split函数优化如下:
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):]
i = strings.Index(s, sep)
}
result = append(result, s)
return
}
这一次我们提前使用make函数将result初始化一个容量足够大的切片,而不像之前一样通过调用append函数来追加,我们来看一下这个改进会带来多大的性能提升:
go test -bench=Split -benchmem
goos: windows
goarch: amd64
BenchmarkSplit-4 6301537 195 ns/op 32 B/op 1 allocs/op
PASS
ok _/D_/gocode/src/Negan/split 2.072s
性能比较函数
上面的基准测试只能得到给定操作的绝对耗时,但是在很多性能问题是发生在两个不同操作之间的相对耗时,比如同一个函数处理1000个元素的耗时与处理一万个甚至100万个元素的耗时差别是多少呢?
或者对于同一个任务酒精使用哪种算法性能更佳?我们通常需要对两个不同算法的实现使用相同的输入来进行基准比较测试。
性能比较函数通常是一个带有参数的函数,被多个不同的Benchmark函数传入不同的值来调用。
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)}
我们编写一个计算斐波那契数列函数如下:
// fib.go
func Fib(n int) int{
if n < 2{
return n
}
return Fib(n-1) + Fib(n-2)
}
我们编写的性能比较函数如下:
// fib-test.go
import "testing"
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)
}
运行基准测试:
>go test -bench=Fib
goos: windows
goarch: amd64
BenchmarkFib1-4 380433421 3.06 ns/op
BenchmarkFib2-4 121904761 9.22 ns/op
BenchmarkFib3-4 87771267 14.1 ns/op
BenchmarkFib10-4 2137042 539 ns/op
BenchmarkFib20-4 19473 55064 ns/op
BenchmarkFib40-4 2 997070300 ns/op
PASS
ok _/D_/gocode/src/Negan/split 11.877s
这里需要注意的是,默认情况下,每个基准测试至少运行1秒,如果在Benchmark函数返回时没有1秒,则b.N的值会按照1,2,5,10,20,50,…增加,并且函数再次运行。
最终的BenchmarkFib40只运行了两次,每次运行的平均值只有不到一秒,像这种情况下我们使用-benchtime标志增加最小基准时间,以产生更准确的结果,例如:
>go test -bench=Fib40 -benchtime=20s
goos: windows
goarch: amd64
BenchmarkFib40-4 24 1020060225 ns/op
PASS
ok _/D_/gocode/src/Negan/split 26.215s
这一次BenchmarkFib40函数运行了24次,结果会更佳准确一些。
使用性能比较函数做测试的时候容易犯的错误就是把b.N作为输入的大小,例如以下两个例子都是错误示例:
// 错误示范1
func BenchmarkFibWrong(b *testing.B){
for n:=0;n<b.N;n++{
Fib(n)
}
}
// 错误示例2
func BenchmarkFibWrong2(b *testing.B){
Fib(b.N)
}
重置时间
b.ResetTimer之前的处理不会放到执行时间里,也不会输出到报告中,所以可以在之前做一些不计划作为测试报告的操作。
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之前调用SetParallelism。RunParallel通常会与-cpu标志一同使用。
func BenchmarkSplitParallel(b *testing.B) {
// b.SetParallelism(1) // 设置使用cpu数
b.RunParallel(func(pb *testing.PB) {
for pb.Next(){
Split("上海自来水来自海上","水")
}
})
}
执行以下基准测试:
go test -bench=Split
goos: windows
goarch: amd64
BenchmarkSplit-4 6023530 207 ns/op
BenchmarkSplitParallel-4 16168431 74.0 ns/op
PASS
ok _/D_/gocode/src/Negan/split 28.532s
还可以通过测试命令后添加-cpu参数,如go test -bench=Split -cpu 1来指定使用cpu的数量。
Setup与TearDown
测试程序有时需要在测试之前进行额外的设置(setup)或在测试之后进行拆卸(teardown)。
TestMain
通过在_test.go文件中定义TestMain函数,可以在测试之前进行额外的设置(setup)或在测试之后进行拆卸(teardown)操作。
如果测试文件包含函数func TestMain(m testing.M)那么生成的测试会先调用TestMain(n),然后再运行具体测试。TestMain运行在主goroutine中,可以在调用m.Run前后做任何设置(setup)和拆卸(teardown)。退出设施的时候应该使用m.Run的返回值作为参数调用os.Exit。
一个使用TestMain来设置Setup和TearDown的示例如下:
func TestMain(m *testing.M){
fmt.Println("write setup code here ...") // 测试之前做一些设置
// 如果TestMain使用了flags,这里应该加上flag.Parse()'
retCode := m.Run()
fmt.Println("write teardown code here...") // 测试之后做一些拆卸工作
os.Exit(retCode)
}
需要注意的是:在调用TestMain时,flag.Parse并没有被调用。如果TestMain依赖于command-line标志(包括testing包的标记),则应该显示的调用flag.Parse。
子测试的Setup与TearDown
有时候我们可能需要为每个测试集设置Setup与Trardown,也有可能需要为每个子测试设置Setup与Trardown。下面我们定义两个工具函数如下:
// 测试机的Setup与Teardown
func setupTestCase(t *testing.T) func(t *testing.T){
t.Log("如果需要再次执行:测试之前的setup")
return func(t *testing.T){
t.Log("如有需要再次执行:测试之后的teardown")
}
}
// 子测试的Setup与Teardown
func setupSubTest(t *testing.T) func(t *testing.T){
t.Log("如有需要再次执行:子测试之前的setup")
return func(t *testing.T){
t.Log("如有需要在此执行:子测试之后的teardown")
}
}
使用方式如下:
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{"上海自来","来自海上"}},
}
teardownTestCase := setupTestCase(t) // 测试之前执行setup操作
defer teardownTestCase(t) // 测试之后执行testdown
for name,tc:=range tests{
t.Run(name,func(t *testing.T){ // 使用t.Run()执行子测试
teardownSubTest := setupSubTest(t) // 子测试执行之前执行setup操作
defer teardownSubTest(t) // 测试之后执行testdown操作
got := Split(tc.input,tc.sep)
if !reflect.DeepEqual(got,tc.want){
t.Errorf("excepted:%#v,got:%#v", tc.want,got)
}
})
}
}
测试结果如下:
go test -v
=== RUN TestSplit
TestSplit: split_test.go:104: 如果需要再次执行:测试之前的setup
=== RUN TestSplit/simple
TestSplit/simple: split_test.go:112: 如有需要再次执行:子测试之前的setup
TestSplit/simple: split_test.go:114: 如有需要在此执行:子测试之后的teardown
=== RUN TestSplit/wrong_sep
TestSplit/wrong_sep: split_test.go:112: 如有需要再次执行:子测试之前的setup
TestSplit/wrong_sep: split_test.go:114: 如有需要在此执行:子测试之后的teardown
=== RUN TestSplit/more_sep
TestSplit/more_sep: split_test.go:112: 如有需要再次执行:子测试之前的setup
TestSplit/more_sep: split_test.go:114: 如有需要在此执行:子测试之后的teardown
=== RUN TestSplit/leading_sep
TestSplit/leading_sep: split_test.go:112: 如有需要再次执行:子测试之前的setup
TestSplit/leading_sep: split_test.go:114: 如有需要在此执行:子测试之后的teardown
TestSplit: split_test.go:106: 如有需要再次执行:测试之后的teardown
--- PASS: TestSplit (0.01s)
--- PASS: TestSplit/simple (0.00s)
--- PASS: TestSplit/wrong_sep (0.00s)
--- PASS: TestSplit/more_sep (0.00s)
--- PASS: TestSplit/leading_sep (0.00s)
PASS
ok _/D_/gocode/src/Negan/split 0.716s
示例函数
示例函数的格式
被go test特殊对待的第三种函数就是示例函数,它们的函数名以Example为前缀,它们既没有参数也没有返回值。标准格式如下:
func ExampleName(){
//...
}
示例函数示例
下面的代码使我们为Split函数编写一个示例函数:
func ExampleSplit() {
fmt.Println(Split("a:b:c",":"))
fmt.Println(Split("上海自来水来自海上","水"))
// Output:
// [a b c]
// [上海自来 来自海上]
}
为代码编写示例代码有如下三个好处:
- 示例函数能够作为文档直接使用,例如基于web的godoc中能把示例函数与对应的函数或包相关联;
- 示例函数只要包含了//Ouput:也是可以通过go test运行的可执行测试 ```java go test -run Example PASS ok /D/gocode/src/Negan/split 0.691s
```
- 示例函数提供了可以直接运行的示例代码,可以直接在golang.org的godoc文档服务器上使用Go Playgoround运行示例代码。