单元测试
不测试的开发不是好程序员。
1. go test 工具
Go语言的测试依赖 go test
命令。在包目录中,所有以 _test.go 为后缀名的源代码文件都是go test
测试的一部分,不会被 go build
编译。
文件名必须以 _test.go 结尾(例如:xx_tes.go)。
在 _test.go 文件中有三种类型的函数:单元测试函数、基准测试函数、示例函数。
类型 | 格式 | 作用 | 示例 |
---|---|---|---|
测试函数 | 函数名前缀为Test | 测试程序的逻辑是否正确 | TestFunc() |
基准函数 | 函数名前缀为Benchmark | 测试程序的性能是否达到预期 | BenchmarkFunc() |
示例函数 | 函数名前缀为Example | 文档示例 | ExampleFunc() |
go test 参数:
- 通过
go help test
查看go test
使用说明。 - 命令格式:go test [-c] [-i] [build flags] [packages] [flags for test binary]
常用参数说明:
-c
:编译go test
成可执行的二进制文件,但不运行测试。
-i
:安装测试包依赖的 package,但不运行测试。
测试函数
go test -v
:输出全部单元测试用例,默认(不加)只输出失败的测试用例。
go test -run="regexp"
:只运行指定的单元测试用例,regexp 为正则表达式。
go test -cover
:测试覆盖率。显示包函数被单元测试用例覆盖的百分比。
go test -cover -coverprofile="xxx.out"
:编写一个基准测试覆盖率概要到当前文件夹下的xxx.out文件中。
go tool cover -html="xxx.out"
:使用 cover 工具处理生成的覆盖率记录文件,该命令会打开本地的浏览器窗口生成一个HTML报告。
基准测试
go test -bench="regexp"
:运行指定的基准测试用例,regexp 为正则表达式。基准测试默认不执行,需要加上-bench
选项。
go test -bench="regexp" -benchmem
:运行基准测试用例时输出内存情况。
go test -bench="regexp" -benchtime=10s
:设置运行每个基准测试持续的时间(默认1s)
官方文档:
-test.bench regexp
run only benchmarks matching regexp
-test.benchmem
print memory allocations for benchmarks
-test.benchtime d
run each benchmark for duration d (default 1s)
-test.blockprofile file
write a goroutine blocking profile to file
-test.blockprofilerate rate
set blocking profile rate (see runtime.SetBlockProfileRate) (default 1)
-test.count n
run tests and benchmarks n times (default 1)
-test.coverprofile file
write a coverage profile to file
-test.cpu list
comma-separated list of cpu counts to run each test with
-test.cpuprofile file
write a cpu profile to file
-test.failfast
do not start new tests after the first test failure
-test.fuzz regexp
run the fuzz test matching regexp
-test.fuzzcachedir string
directory where interesting fuzzing inputs are stored (for use only by cmd/go)
-test.fuzzminimizetime value
time to spend minimizing a value after finding a failing input (default 1m0s)
-test.fuzztime value
time to spend fuzzing; default is to run indefinitely
-test.fuzzworker
coordinate with the parent process to fuzz random values (for use only by cmd/go)
-test.list regexp
list tests, examples, and benchmarks matching regexp then exit
-test.memprofile file
write an allocation profile to file
-test.memprofilerate rate
set memory allocation profiling rate (see runtime.MemProfileRate)
-test.mutexprofile string
write a mutex contention profile to the named file after execution
-test.mutexprofilefraction int
if >= 0, calls runtime.SetMutexProfileFraction() (default 1)
-test.outputdir dir
write profiles to dir
-test.paniconexit0
panic on call to os.Exit(0)
-test.parallel n
run at most n tests in parallel (default 4)
-test.run regexp
run only tests and examples matching regexp
-test.short
run smaller test suite to save time
-test.shuffle string
randomize the execution order of tests and benchmarks (default "off")
-test.testlogfile file
write test action log to file (for use only by cmd/go)
-test.timeout d
panic test binary after duration d (default 0, timeout disabled)
-test.trace file
write an execution trace to file
-test.v
verbose: print additional output
2. 测试函数
单元测试是一些利用各种方法测试单元组件的程序,它将结果与预期输出进行比较,以验证程序的正确性。
2.1. 格式及参数
// 测试函数:方法名必须以 Test 开头,Test后首字母必须大写(如:Name),参数必须为 t *testing.T
func TestName(t *testing.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) Helper()
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
2.2. 测试函数示例
结下来我们定义一个split包,包中定义了一个Split函数,如下:
// split/split.go
package split
import "strings"
func Split(s, sep string) []string {
var res []string
i := strings.Index(s, sep)
for i > -1 {
res = append(res, s[:i])
s = s[i+1:]
i = strings.Index(s, sep)
}
res = append(res, s)
return res
}
然后我们在split包中编写一个单元测试,如下:
// split/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"} // 期望结果
if !reflect.DeepEqual(got, want) { // 借助reflect.DeepEqual方法比较结果
t.Errorf("fail: want:%v, got:%v", want, got) // 失败,输出提示
}
}
在 split 包路径下,执行 go test
命令,如下:
PS F:/Learing/test/split> go test
PASS
ok Learing/test/split 0.305s
若在 split_test.go 新增测试方法 TestSplit2,如下
func TestSplit2(t *testing.T) {
got := Split("a||b||c", "||")
want := []string{"a", "b", "c"}
if !reflect.DeepEqual(got, want) {
t.Errorf("fail: want:%v, got:%v", want, got)
}
}
执行 go test
显示如下错误(因split不支持sep多个字符):
PS F:/Learing/test/split> go test
--- FAIL: TestSplit2 (0.00s)
split_test.go:20: fail: want:[a b c], got:[a |b |c]
FAIL
exit status 1
FAIL Learing/test/split 0.468s
go test
只展示失败的测试用例,若查看所有测试函数的详细信息,执行 go test -v
命令,如下:
PS F:\Learning\test\split> go test -v
=== RUN TestSplit
--- PASS: TestSplit (0.00s)
=== RUN TestSplit2
split_test.go:20: fail: want:[a b c], got:[a |b |c]
--- FAIL: TestSplit2 (0.00s)
FAIL
exit status 1
FAIL Learing/test/split 0.241s
go test -run="regexp"
仅执行匹配到的测试用例,如下仅执行了 TestSplit2
的测试用例:
PS F:\Learning\test\split> go test -v -run="2"
=== RUN TestSplit2
split_test.go:12: fail: want:[a b c], got:[a |b |c]
--- FAIL: TestSplit2 (0.00s)
FAIL
exit status 1
FAIL Learing/test/split 0.227s
2.3. 子测试
上面 split_test.go
中写了两个测试函数,一个是 TestSplit
,一个是 TestSplit2
,分别测试两个用例。我们使用更友好的方式,将多个测试用例放在一个测试组中;且在Go1.7+中新增了子测试,我们可以按照如下方式使用t.Run执行子测试。如下:
// split/split.go
func Split(s, sep string) []string {
var res []string
i := strings.Index(s, sep)
for i > -1 {
res = append(res, s[:i])
s = s[i+len(sep):] // 优化,使用len(sep)获取sep的长度,支持sep字符串
i = strings.Index(s, sep)
}
res = append(res, s)
return res
}
func TestSplit(t *testing.T) {
// 定义一个测试用例类型
type test struct {
s string
sep string
want []string
}
// 定义一组测试用例(测试组)
tests := map[string]test{
"simple": {s: "a:b:c", sep: ":", want: []string{"a", "b", "c"}},
"multichar sep": {s: "a||b||c", sep: "||", want: []string{"a", "b", "c"}},
"中文sep": {s: "小明:18:男", sep: ":", want: []string{"小明", "18", "男"}},
}
// 遍历用例进行测试
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
if got := Split(tc.s, tc.sep); !reflect.DeepEqual(got, tc.want) {
t.Errorf("fail: want:%#v, got:%#v", tc.want, got) //推荐使用%#v的格式化
}
})
}
}
执行 go test -v
输出如下:
PS F:\Learning\test\split> go test -v
=== RUN TestSplit
=== RUN TestSplit/simple
=== RUN TestSplit/multichar_sep
=== RUN TestSplit/中文sep
--- PASS: TestSplit (0.00s)
--- PASS: TestSplit/simple (0.00s)
--- PASS: TestSplit/multichar_sep (0.00s)
--- PASS: TestSplit/中文sep (0.00s)
PASS
ok Learing/test/split 0.194s
子测试同样可以使用-run="regexp"
进行筛选,但前面须加上函数名,如下:
PS F:\Learning\test\split> go test -v -run="Split/sep"
=== RUN TestSplit
=== RUN TestSplit/multichar_sep
=== RUN TestSplit/中文sep
--- PASS: TestSplit (0.00s)
--- PASS: TestSplit/multichar_sep (0.00s)
--- PASS: TestSplit/中文sep (0.00s)
PASS
ok Learing/test/split 0.227s
注意:子测试筛选必须使用函数名作为路径,上面示例中使用 Split/
不可使用 TestSplit/
。
2.4. 测试覆盖率
测试覆盖率,是你的代码被测试套件覆盖的百分比。通常我们使用的都是语句覆盖率,也就是在测试中至少被运行一次的代码占总代吗的比例。
Go语言使用go test -cover
来查看测试覆盖率。
例如,我们在 split/split.go 文件中在增加两个方法:
// split/split.go
package split
import (
"strings"
)
func Split(s, sep string) []string {
var res []string
i := strings.Index(s, sep)
for i > -1 {
res = append(res, s[:i])
s = s[i+len(sep):]
i = strings.Index(s, sep)
}
res = append(res, s)
return res
}
// 优化Split函数,事先开辟好内存空间,减少append操作
func SplitHigh(s, sep string) (res []string) {
res = make([]string, 0, strings.Count(s, sep)+1)
i := strings.Index(s, sep)
for i > -1 {
res = append(res, s[:i])
s = s[i+len(sep):]
i = strings.Index(s, sep)
}
res = append(res, s)
return
}
// Fib 计算第n个斐波那契数的函数
func Fib(n int) int {
if n <= 0 {
return 0
} else if n <= 2 {
return 1
}
return Fib(n-1) + Fib(n-2)
}
执行 go test -cover
:
PS F:\Learning\test\split> go test -cover
PASS
coverage: 38.1% of statements
ok Learing/test/split 0.233s
Go还提供了一个额外的-coverprofile参数,用来将覆盖率相关的记录信息输出到一个文件。如下命令,在当前目录生成了一个split.out文件,其中包含测试覆盖率信息。
PS F:\Learning\test\split> go test -cover -coverprofile="split.out"
PASS
coverage: 38.1% of statements
ok Learing/test/split 0.278s
go tool cover -html=split.out
使用 cover 工具处理生成的记录,打开本地浏览器窗口生成一个HTML报告。使用 go tool cover -help
查看命令详情。
3. 基准函数
基准测试是在一定的工作负载之下检测程序性能的一种方法。
3.1. 基准测试函数格式
// 必须以 Benchmark 为前缀,之后首字母必须大写(如:Name),参数必须为 *testing.B
func BenchmarkName(b *testing.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()
3.2. 基准测试示例
在 split_test.go 中为Split函数及SplitHigh函数编写基准测试:
func BenchmarkSplit(b *testing.B) {
for i := 0; i < b.N; i++ {
Split("a:b:c", ":")
}
}
func BenchmarkSplitHigh(b *testing.B) {
for i := 0; i < b.N; i++ {
SplitHigh("a:b:c", ":")
}
}
基准函数不会默认执行,需增加 -bench
参数,执行 go test -bench=Split
(-bench="."
执行当前包下所有基准函数),输出如下:
PS F:\Learning\test\split> go test -bench=Split
goos: windows
goarch: amd64
pkg: Learing/test/split
cpu: Intel(R) Core(TM) i5-4460 CPU @ 3.20GHz
BenchmarkSplit-4 4828096 248.6 ns/op
BenchmarkSplitHigh-4 10184638 112.9 ns/op
PASS
ok Learing/test/split 2.997s
其中 BenchmarkSplit-4 中 4 表示 GOMAXPROCS 的值,对于并发基准测试很重要;4828096、248.6 ns/op 表示调用Split函数4828096次平均每次耗时为248.6ns。
还可以为基准测试添加 -benchmem
参数,来获得内存分配的统计数据。
PS F:\Learning\test\split> go test -bench=Split -benchmem
goos: windows
goarch: amd64
pkg: Learing/test/split
cpu: Intel(R) Core(TM) i5-4460 CPU @ 3.20GHz
BenchmarkSplit-4 4712768 247.2 ns/op 112 B/op 3 allocs/op
BenchmarkSplitHigh-4 10606215 114.6 ns/op 48 B/op 1 allocs/op
PASS
ok Learing/test/split 2.958s
其中,112 B/op表示每次操作内存分配了112字节,3 allocs/op则表示每次操作进行了3次内存分配。
对比 Split
和 SplitHigh
函数,使用make
函数提前分配内存的改动,减少了2/3的内存分配次数,并且减少了一半的内存分配,执行时间减少了一半。
3.3. 性能比较函数
上面的基准测试只能得到给定操作的绝对耗时,但在很多性能问题是发生在两个不同操作之间的性对耗时,比如同一个函数处理1000个元素与处理10000个甚至更多元素的耗时的差别是多少,或者对于同一个任务究竟使用哪种算法性能最佳?我们通常需要对两个不同算法的实现使用相同的输入来进行基准比较测试。
性能比较函数通常是一个带有参数的函数,被多个不同的Benchmark函数传入不同的值来调用。举例子如下:
// split/fib.go
package split
// Fib 是一个计算第n个斐波那契数的函数
func Fib(n int) int {
if n <= 0 {
return 0
} else if n <= 2 {
return 1
}
return Fib(n-1) + Fib(n-2) // 使用递归
}
// split/fib_test.go
package split
import "testing"
// 开头小写
func benchmarkFib(b *testing.B, n int) {
for i := 0; i < b.N; i++ {
Fib(n)
}
}
// 性能比较
func BenchmarkFib5(b *testing.B) { benchmarkFib(b, 5) }
func BenchmarkFib50(b *testing.B) { benchmarkFib(b, 50) }
func BenchmarkFib500(b *testing.B) { benchmarkFib(b, 500) }
func BenchmarkFib500000000(b *testing.B) { benchmarkFib(b, 500000000) }
执行 go test -bench=Fib
PS F:\Learning\test\split> go test -bench=Fib
goos: windows
goarch: amd64
pkg: Learing/test/split
cpu: Intel(R) Core(TM) i5-4460 CPU @ 3.20GHz
BenchmarkFib5-4 385520368 3.169 ns/op
BenchmarkFib50-4 35705040 31.33 ns/op
BenchmarkFib500-4 6423651 183.3 ns/op
BenchmarkFib500000000-4 7 167491157 ns/op
PASS
ok Learing/test/split 5.692s
注意:默认情况下,每个基准测试至少运行1秒。如果在Benchmark函数返回时没有到1秒,则b.N的值会按1,2,5,10,20,50,…增加,并且函数再次运行。
如最终 BenchmarkFib500000000
只运行了 7 次,像这种情况下我们应该使用 -benchtime
增加最小基准时间,以产生更准确的结果。如下:
PS F:\Learning\test\split> go test -bench=Fib50000000 -benchtime=10s
goos: windows
goarch: amd64
pkg: Learing/test/split
cpu: Intel(R) Core(TM) i5-4460 CPU @ 3.20GHz
BenchmarkFib500000000-4 72 166507756 ns/op
PASS
ok Learing/test/split 12.372s
3.4. 重置时间
b.ResetTimer
之前的处理不会放到执行时间里,也不会输出到报告中,所以可以在之前做一些不计划作为测试报告的操作。例如:
func benchmarkFib(b *testing.B, n int) {
time.Sleep(5 * time.Second) // 假设需要做一些耗时的无关操作
b.ResetTimer() // 重置计时器
for i := 0; i < b.N; i++ {
Fib2(n)
}
}
3.5. 并行测试
RunParallel 会创建多个 goroutine,并将 b.N 分配给这些 goroutine 执行,其中 goroutine 数量的默认值为 GOMAXPROCS。
若要增加非CPU受限基准测试的并行性,可在 RunParallel 之前调用 SetParallelism 设置并行数。还可以通过在测试命令后添加 -cpu 参数如 go test -bench=. -cpu 1
来指定使用的CPU数量。
func BenchmarkFibParallel(b *testing.B) {
//b.SetParallelism(2) // 设置CPU使用数
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Fib(100)
}
})
}
PS F:\Learning\test\split> go test -bench=Fib -cpu 2
goos: windows
goarch: amd64
pkg: Learing/test/split
cpu: Intel(R) Core(TM) i5-4460 CPU @ 3.20GHz
BenchmarkFib-2 187356174 6.312 ns/op
BenchmarkFibParallel-2 11944492 99.56 ns/op
PASS
ok Learing/test/split 3.381s
4. Setup 与 Teardown
测试程序有时需要在测试之前进行额外的设置(setup)或在测试之后进行拆卸(teardown)。
SS
若测试文件包含 func TestMain(m testing.M)
,那么生成的测试用例会先调用 TestMain(m)
,然后运行具体测试。退出测试时应该使用m.Run
的返回值作为参数调用os.Exit。
func TestMain(m *testing.M) {
fmt.Println("测试-TestMain Setup") // 测试之前的做一些设置
// 如果 TestMain 使用了 flags,这里应该加上 flag.Parse()
retCode := m.Run() // 执行测试
fmt.Println("测试-TestMain Teardown") // 测试之后做一些拆卸工作
os.Exit(retCode) // 退出测试
}
PS F:\Learning\test\split> go test -bench=Split
测试-TestMain Setup
goos: windows
goarch: amd64
pkg: Learing/test/split
cpu: Intel(R) Core(TM) i5-4460 CPU @ 3.20GHz
BenchmarkSplit-4 4676877 254.5 ns/op
BenchmarkSplitHigh-4 10206516 113.2 ns/op
PASS
测试-TestMain Teardown
ok Learing/test/split 3.754s
5. 示例函数
示例函数能作为文档直接使用
格式如下:
func ExampleName() {
// ...
}
示例:
func ExampleSplit() {
fmt.Println(split.Split("a:b:c", ":"))
fmt.Println(split.Split("x||y||z", "||"))
// Output:
// [a b c]
// [x y z]
}