从现在开始,我将用两讲的篇幅带大家学会如何对 Go 源码开展测试和性能优化工作。本讲的内容是测试部分,具体包含单元测试与基准测试。
我们都知道,测试的最基本目的在于发现错误,其次是程序运行的性能和效率是否满足需要。一些优秀的测试用例往往能发现从未发生过的错误。单元测试是保证代码运行正确性的常用做法,基准测试是测量运行时,空间和时间资源消耗的常用做法。
# 单元测试
正如前文所述,单元测试可以保证代码运行的正确性,这个“正确性”不仅指测试时的结果,还有代码修复或优化后的执行结果。但它无法保证程序的功能是正确的,换句话说,就是无法确保程序在业务逻辑上的正确性。举例来说,一个文件重命名的函数即使通过了单元测试,但无法保证程序在正确的时机调用它。如果错误地执行,就很可能与业务需求相悖,单元测试无法察觉这类错误。
我们先一起来看这样的源码文件(calc.go):
go
复制代码package calc
func AddCalc(num1 int, num2 int) int {
return num1 + num2
}
func SubtractCalc(num1 int, num2 int) int {
return num1 - num2
}
💡 提示:本例中的 calc.go 位于单独的 calc 包中。
可以看到,这个源码文件中总共包含了两个函数:AddCalc() 和 SubstractCalc()。从命名和各自的函数体代码中很容易看出,它们的作用就是将传入的 int 型参数相加和相减,并将计算结果返回给它们的调用者。
现在,我们要对这两个函数进行单元测试,看其是否正常运行。
在 Go 语言中,官方推荐的做法是单元测试的代码与被测的代码文件放在一起,并添加“_test”作为文件名的结尾。对于本例而言,将创建名为 calc_test.go 文件,内容是单元测试的代码。
紧接着,如果我们要对 calc.go 源码中的 AddCalc() 函数进行测试,就要创建相应的测试函数。测试函数名通常是在被测函数名前加“Test”前缀,且要求测试函数有且只有一个参数,即 *testing.T(用于函数测试)、*testing.B(用于基准测试)、*testing.M(用于测试 main) 三种类型之一。
对于本例中的 AddCalc() 函数,其测试代码可写为:
go
复制代码func TestAddCalc(t *testing.T) {
if result := AddCalc(1, 2); result != 3 {
t.Errorf("传入的参数是1、2,相加应该是3。实际运行结果却是%d", result)
}
}
我们都知道,1 和 2 相加,结果为 3。若 AddCalc() 函数执行正确,其结果应该就是 3。否则,代码将通过 t(*testing.T类型) 变量调用 Errorf()(或 Error()) 方法输出相关信息,但不会打断后续的测试。变量 t 的另一组方法是 Fatalf() 和 Fatal(),这两个方法会打断后续的测试。
最后便是运行单元测试了,启动终端。由于 calc.go 源码属于 calc 包,在源码根目录的 calc 子目录中,因此先定位到 calc 子目录下,然后执行:
shell
复制代码go test
稍等片刻,终端将输出测试结果:
PASS
ok main/calc 0.216s
测试通过。
接下来编写一个名为 TestSubtractCalc() 的测试函数,内容是测试 SubtractCalc() 函数。具体如下:
go
复制代码func TestSubtractCalc(t *testing.T) {
if result := SubtractCalc(1, 2); result != -1 {
t.Errorf("传入的参数是1、2,相减应该是-1。实际运行结果却是%d", result)
}
}
别急着运行,go test 除了可以直接使用外,还可以添加 -v 参数输出每个用例的结果,-cover 参数输出测试覆盖率, -run 参数指定要执行的测试函数。现在,尝试只运行 TestSubtractCalc() 函数,并输出代码覆盖率,执行语句是:
shell
复制代码go test -run TestSubtractCalc -cover
稍等片刻,终端输出如下结果:
PASS
coverage: 50.0% of statements
ok main/calc 0.217s
测试通过,代码覆盖率是 50%。这是因为 calc.go 中仅包含两个函数,测试了其中一个,覆盖率当然是 50%。
接下来,再尝试运行所有测试,并输出每个测试的结果和整体覆盖率,执行的语句是:
shell
复制代码go test -v -cover
稍等片刻,终端如下输出:
=== RUN TestAddCalc
--- PASS: TestAddCalc (0.00s)
=== RUN TestSubtractCalc
--- PASS: TestSubtractCalc (0.00s)
PASS
coverage: 100.0% of statements
ok main/calc 0.213s
可以看到,每条用例的测试结果都被输出出来了。
对于需要分组测试的情况,Go 语言允许将其作为子测试进行归类。假如对于本例中的 AddCalc() 函数,希望进行正整数、负整数和零值的运算测试,就可以将其作为 3 条子测试进行。具体请参考下面的代码:
go
复制代码func TestAddCalc(t *testing.T) {
testCases := []struct {
Name string
Num1, Num2, ExpectedResult int
}{
{"positive", 5, 6, 11},
{"negative", -2, -3, -5},
{"zero", 0, 0, 5},
}
for _, singleCase := range testCases {
t.Run(singleCase.Name, func(t *testing.T) {
if result := AddCalc(singleCase.Num1, singleCase.Num2); result != singleCase.ExpectedResult {
t.Errorf("%s:传入的参数是%d、%d,相加应该是%d。实际运行结果却是%d",
singleCase.Name, singleCase.Num1, singleCase.Num2, singleCase.ExpectedResult, result)
}
})
}
}
上述代码中,所有的测试用例数据被存放在 testCases 的切片中,随后遍历该切片执行每个子测试。这种测试方式又被称为:表格驱动测试。这里对于零值的计算,我故意写错了期望结果,添加 -v 参数执行测试后,可以看到终端的输出结果如下:
=== RUN TestAddCalc
=== RUN TestAddCalc/positive
=== RUN TestAddCalc/negative
=== RUN TestAddCalc/zero
calc_test.go:17: zero:传入的参数是0、0,相加应该是5。实际运行结果却是0
--- FAIL: TestAddCalc (0.00s)
--- PASS: TestAddCalc/positive (0.00s)
--- PASS: TestAddCalc/negative (0.00s)
--- FAIL: TestAddCalc/zero (0.00s)
FAIL
exit status 1
FAIL main/calc 0.208s
💡 提示:编写测试用例时,比较好的做法是输出足够的信息,帮助测试人员和开发者更高效地纠正错误。
对于某些需要初始化一些公共数据或资源用于测试的情况,可以增加 setup() 、 teardown() 及 TestMain() 函数组织它们。setup() 函数用于初始化数据或申请资源,teardown() 函数用于测试收尾时的释放资源,TestMain() 函数用于指挥它们及启动执行测试。下面的代码演示了它们是如何在一起配合执行的:
go
复制代码func setup() {
fmt.Println("预处理数据")
}
func teardown() {
fmt.Println("释放资源")
}
func TestMain(m *testing.M) {
setup()
resultCode := m.Run()
teardown()
os.Exit(resultCode)
}
func TestAddCalc(t *testing.T) {
testCases := []struct {
Name string
Num1, Num2, ExpectedResult int
}{
{"positive", 5, 6, 11},
{"negative", -2, -3, -5},
{"zero", 0, 0, 0},
}
for _, singleCase := range testCases {
t.Run(singleCase.Name, func(t *testing.T) {
if result := AddCalc(singleCase.Num1, singleCase.Num2); result != singleCase.ExpectedResult {
t.Errorf("%s:传入的参数是%d、%d,相加应该是%d。实际运行结果却是%d",
singleCase.Name, singleCase.Num1, singleCase.Num2, singleCase.ExpectedResult, result)
}
})
}
}
执行测试,终端输出如下:
预处理数据
PASS
释放资源
ok main/calc 0.210s
💡 提示:setup() 和 teardown() 函数的名称是固定的吗?当然不是!根据个人喜好不同,可以将其命名为自己喜欢的名称,比如 initData() 和 release()。甚至在单个测试用例中调用某个自定义函数都是允许的。
# 基准测试
当我们需要衡量代码的运行效率(CPU 时间消耗、内存空间占用)时,就需要用到基准测试了。
编写 Go 代码的基准测试函数,要求用例函数名必须以 Benchmark 开头,习惯上后面接待测函数名。用例函数的参数,必须为 b *testing.B。
现在,我们要衡量 AddCalc() 函数的运行效率,基准测试用例函数如下:
go
复制代码func BenchmarkAddCalc(b *testing.B) {
for i := 0; i < b.N; i++ {
fmt.Println(AddCalc(50, 50))
}
}
接着,来到终端,定位到 calc 包目录,执行:
shell
复制代码go test -benchmem -bench .
这条语句中,-benchmem 用于输出内存消耗量,-bench . 表示测试文件中所有的基准测试。
等待测试完成,控制台输出示例如下:
36684 33150 ns/op 8 B/op 1 allocs/op
PASS
ok main/calc 1.778s
上述输出内容可解读为:总共执行了 36765 次,每次消耗 35371 纳秒,每次调用分配了 8 个字节,每次调用有 1 次分配操作。
此外,还可以自定义测试时间,通过 -benchtime 定义。下面的命令执行了 5 秒测试:
shell
复制代码go test -benchmem -benchtime 5s -bench .
运行这条命令,终端输出变为:
157554 32214 ns/op 8 B/op 1 allocs/op
PASS
ok main/calc 5.686s
由于测试时间发生了变化,所以测试的总次数也会随之变化。
实际上,默认的 benchtime 值是 1 秒。但无论是否指定 benchtime 时长,其真实的测试时长往往并非恰好 1 秒。甚至当给定时长后,测试时长也并非恰好是给定的时长,这是为何呢?
当 Benchmark 基准测试开始后,若测试用例返回时还不到 1 秒,则 b 参数的 N 值(即本例中的循环结束条件)会以 1、2、5、10、20、50、……递增,并立即应用递增后的结果。所以我们看到的最终真实测试时长并非一开始给定的时长。
# 小结
🎉 恭喜,您完成了本次课程的学习!
📌 以下是本次课程的重点内容总结:
- Go 代码测试
- 单元测试
- 基准测试
本讲开始介绍 Go 程序的测试和性能优化。
首先简单介绍了软件测试的作用,然后阐述了 Go 程序的两大测试种类,即单元测试和基准测试,一个验证正确性,另一个衡量性能。
在单元测试模块,从最简单的单次函数测试开始,再到覆盖率分析、子用例测试以及输出每个用例的详细结果。
在基准测试模块,介绍了基本方法、报告数值解读以及修改测试时长的技巧。
➡️ 在下次课程中,我们会介绍 Go 程序的性能分析工具。具体内容如下:
- Go 程序性能分析工具的使用