您诸位好啊,我是无尘,今天我们进入到Go语言单元测试阶段,讲讲Go如何进行单元测试。
单元测试
顾名思义,单元测试就是对单元进行测试,一个单元可以是一个函数、一个模块等。一般测试的单元应该是一个「完整的最小单元」,比如一个函数。这样当每个最小单元都被验证通过,那么整个模块就都可以被验证通过。
Go 语言有自己的单元测试规范,此处我们以 斐波那契数列
为例。斐波那契数列:它的第 0 项是 0;第 1 项是 1;从第 2 项开始,每一项都等于前两项之和。所以它的数列是:0、1、1、2、3、5、8、13、21……
根据上面规律,函数可以总结为:
F(0)=0
F(1)=1
F(2)=F(2 - 1) F(2 - 2)
F(n)=F(n - 1) F(n - 2)
实现函数:
代码语言:javascript复制func F(n int) int {
if n < 0 {
return 0
}
if n == 0 {
return 0
}
if n == 1 {
return 1
}
return F(n-1) F(n-2)
}
我们通过递归的方式实现了斐波那契数列的计算。 函数编写好后,需要先对它进行单元测试,需要新建一个 go 文件用来存放单元测试代码,比如刚写的函数在 test/main.go 中,测试的代码需要放在 test/main_test.go 中,测试代码如下:
代码语言:javascript复制func TestF(t *testing.T) {
//预先定义的一组斐波那契数列作为测试用例
fsMap := map[int]int{}
fsMap[0] = 0
fsMap[1] = 1
fsMap[2] = 1
fsMap[3] = 2
fsMap[4] = 3
fsMap[5] = 5
fsMap[6] = 8
fsMap[7] = 13
fsMap[8] = 21
fsMap[9] = 34
for k, v := range fsMap {
fib := F(k)
if v == fib {
t.Logf("结果正确:n为%d,值为%d", k, fib)
} else {
t.Errorf("结果错误:期望%d,但是计算的值是%d", v, fib)
}
}
}
测试示例中,通过 map 预定义了一组测试用例,然后通过 F 函数计算结果,通预定义的结果进行比较,如果结果相等,说明 F 函数计算正确,否则说明计算错误。
单元测试命令
go test -v ./test
这里介绍几个常用的参数:
- -bench regexp 执行相应的 benchmarks,例如 -bench=.;
- -cover 开启测试覆盖率;
- -run regexp 只运行 regexp 匹配的函数,例如 -run=Array 那么就执行包含有 Array 开头的函数;
- -v 显示测试的详细命令。
上面命令表示运行 test 目录下的所有单元测试,此处我们这里只有一个单元测试,运行结果为:
代码语言:javascript复制$ go test -v ./test
=== RUN TestF
sum_test.go:23: 结果正确:n为0,值为0
sum_test.go:23: 结果正确:n为2,值为1
sum_test.go:23: 结果正确:n为4,值为3
sum_test.go:23: 结果正确:n为5,值为5
sum_test.go:23: 结果正确:n为6,值为8
sum_test.go:23: 结果正确:n为7,值为13
sum_test.go:23: 结果正确:n为1,值为1
sum_test.go:23: 结果正确:n为3,值为2
sum_test.go:23: 结果正确:n为8,值为21
sum_test.go:23: 结果正确:n为9,值为34
--- PASS: TestF (0.00s)
PASS
ok project/test 0.585s
运行结果中,可以看到 PASS
标记,表明单元测试通过,还可以看到单元测试中写的日志。
单元测试是在 Go 语言提供的测试框架下完成的,需要遵循5点规则:
- 单元测试的 go 文件必须以 _test.go 结尾,Go 语言测试工具只认符合这个规则的文件。
- 单元测试文件名 _test.go 前面的部分最好是被测试的函数所在的 go 文件的文件名。
- 单元测试的函数名必须以 Test 开头,是可导出的、公开的函数。
- 测试函数的签名必须接收一个指向 testing.T 类型的指针,且不能返回任何值。
- 函数名最好是 Test 被测试的函数名。
单元测试日志
一个测试用例可能会并发执行,使用 testing.T 提供的日志输出可以保证日志跟随这个测试上下文一起打印输出。testing.T 提供了几种日志输出方法,如下:
方 法 | 释义 |
---|---|
Log | 打印日志,同时结束测试 |
Logf | 格式化打印日志,同时结束测试 |
Error | 打印错误日志,同时结束测试 |
Errorf | 格式化打印错误日志,同时结束测试 |
Fatal | 打印致命日志,同时结束测试 |
Fatalf | 格式化打印致命日志,同时结束测试 |
单元测试覆盖率
上面示例中的 F 函数是否被全面测试到了呢?我们可以使用命令来查看覆盖率:
go test -v --coverprofile=test.cover ./test
运行结果:
$ go test -v --coverprofile=test.cover ./test
=== RUN TestF
sum_test.go:23: 结果正确:n为9,值为34
sum_test.go:23: 结果正确:n为0,值为0
sum_test.go:23: 结果正确:n为1,值为1
sum_test.go:23: 结果正确:n为6,值为8
sum_test.go:23: 结果正确:n为7,值为13
sum_test.go:23: 结果正确:n为8,值为21
sum_test.go:23: 结果正确:n为2,值为1
sum_test.go:23: 结果正确:n为3,值为2
sum_test.go:23: 结果正确:n为4,值为3
sum_test.go:23: 结果正确:n为5,值为5
--- PASS: TestF (0.00s)
PASS
coverage: 85.7% of statements
ok project/test 0.521s coverage: 85.7% of statements
可以看到,测试覆盖率为 85.7% ,说明 F 函数没有被全面地测试,我们再查看详细的单元测试覆盖率报告来看下:go tool cover -html=test.cover -o=test.html
运行命令后,会在当前目录下生成一个 test.html 文件,我们用浏览器打开它,可以看到:
其中红色标记的部分是没有测试到的,绿色标记的部分是已经测试到的。单位测试覆盖率报告可以很容易地检测单元测试是否完全覆盖。 根据报告,再修改下单元测试代码,把没有覆盖的代码逻辑覆盖到:
代码语言:javascript复制fsMap[-1] = 0
再运行这个单元测试,查看它的单元测试覆盖率,就会发现已经是 100% 了。
基准测试
基准测试可以测试一段程序的运行性能及耗费 CPU 的程度,基准测试和单元测试的规则基本一样,只是测试函数的命名规则不一样。以上面的函数 F() 为例,基准测试代码:
代码语言:javascript复制func BenchmarkF(b *testing.B) {
for i:=0;i<b.N;i {
F(10)
}
}
基准测试和单元测试的区别:
- 基准测试函数必须以 Benchmark 开头,必须是可导出的;
- 函数的签名必须接收一个指向 testing.B 类型的指针,并且不能返回任何值;
- 被测试的代码要放到 for 循环里;
- b.N 是基准测试框架提供的,表示循环的次数,因为需要反复调用测试的代码,才可以评估性能。
运行基准测试同样是使用 go test 命令,并且需要加上 -bench 这个 Flag ,它接收一个表达式作为参数,"." 表示运行所以的基准测试。命令为:go test -bench=. ./test
运行结果:
goos: windows
goarch: amd64
pkg: project/test
cpu: Intel(R) Core(TM) i7-10750H CPU @ 2.60GHz
BenchmarkF-12 4042413 280.1 ns/op
PASS
ok project/test 2.208s
结果解析:
- 函数后的 "-12" 表示运行基准测试时对应的 GOMAXPROCS 的值。
- 4042413 表示运行 for 循环的次数,也就是调用被测试代码的次数。
- 280.1 ns/op 表示每次需要花费 280.1 纳秒。
基准测试的时间默认是 1 秒,也就是 1 秒调用 4042413 次、每次调用花费 280.1 纳秒。如果想让测试运行的时间更长,可以通过 -benchtime 指定,比如 5 秒:go test -bench=. -benchtime=5s ./test
计时方法
- ResetTimer 方法用来重置计时器
- StartTimer 方法控制开始计时
- StopTimer 方法控制停止计时
进行基准测试之前可能会做一些准备,比如构建测试数据等,这些准备也需要消耗时间,所以需要把这部分时间排除在外。这时候我们可以使用 ResetTimer 方法来重置计时器,避免准备数据的耗时对测试数据造成干扰:
代码语言:javascript复制func BenchmarkF(b *testing.B) {
n := 20
b.ResetTimer() //重置计时器
for i := 0; i < b.N; i {
F(n)
}
}
内存统计
在基准测试时,还可以统计每次操作分配内存的次数与字节数,这两个指标可以作为优化代码的参考。要开启内存统计需要通过 ReportAllocs() 方法:
代码语言:javascript复制func BenchmarkF(b *testing.B) {
n := 20
b.ReportAllocs() //开启内存统计
b.ResetTimer() //重置计时器
for i := 0; i < b.N; i {
F(n)
}
}
运行结果:
代码语言:javascript复制goos: windows
goarch: amd64
pkg: project/test
cpu: Intel(R) Core(TM) i7-10750H CPU @ 2.60GHz
BenchmarkF-12 166467 35043 ns/op 0 B/op 0 allocs/op
PASS
ok project/test 6.724s
结果中比之前的基准测试多了两项指标:
- 0 B/op ,表示每次操作分配了多少字节的内存。
- 0 allocs/op ,表示每次操作分配内存的次数。
并发基准测试
除了上面介绍的基准测试,Go 语言还支持并发基准测试,可以测试在多个 gorouting 并发下代码的性能。并发基准测试需要通过 RunParallel 方法,RunParallel 方法会创建多个 goroutine,并将 b.N 分配给这些 goroutine 执行:
代码语言:javascript复制func BenchmarkFibonacciRunParallel(b *testing.B) {
n := 10
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
F(n)
}
})
}
F 函数优化
上面我们写的斐波那契数列函数 F ,因为使用了递归,一定会有重复计算,这是影响递归的主要因素。解决重复计算我们可以使用缓存,把已经计算好的结果先保存起来,以便后续重复使用。 优化后的函数:
代码语言:javascript复制func F(n int) int {
if v, ok := cache[n]; ok {
return v
}
result := 0
switch {
case n < 0:
result = 0
case n == 0:
result = 0
case n == 1:
result = 1
default:
result = F(n-1) F(n-2)
}
cache[n] = result
return result
}
运行结果:
代码语言:javascript复制goos: windows
goarch: amd64
pkg: project/test
cpu: Intel(R) Core(TM) i7-10750H CPU @ 2.60GHz
BenchmarkFibonacciRunParallel-12 563681175 2.123 ns/op
PASS
ok project/test 2.006s
可以看到,结果为 2.123 纳秒,相比优化前的 280.1 纳秒,性能上有很大的提升。