使用 Go 子测试与子基准测试
引言
Go 1.7 在 testing 包的 T 和 B 类型上引入了 Run 方法,用于创建子测试和子基准测试。借助它们,可以更好地处理失败,从命令行精确选择要运行的测试,控制并行执行,而且通常还能让代码更简洁、更易维护。
表格驱动测试基础
在深入细节之前,先看看 Go 中一种常见的测试写法:遍历一个测试用例切片,执行一系列相关检查。
func TestTime(t *testing.T) {
testCases := []struct {
gmt string
loc string
want string
}{
{"12:31", "Europe/Zuri", "13:31"}, // incorrect location name
{"12:31", "America/New_York", "7:31"}, // should be 07:31
{"08:08", "Australia/Sydney", "18:08"},
}
for _, tc := range testCases {
loc, err := time.LoadLocation(tc.loc)
if err != nil {
t.Fatalf("could not load location %q", tc.loc)
}
gmt, _ := time.Parse("15:04", tc.gmt)
if got := gmt.In(loc).Format("15:04"); got != tc.want {
t.Errorf("In(%s, %s) = %s; want %s", tc.gmt, tc.loc, got, tc.want)
}
}
}
这种方法通常称为表格驱动测试。与为每个测试重复编写相同代码相比,它减少了重复代码,也使添加新用例变得直接。
表格驱动基准测试
在 Go 1.7 之前,基准测试无法使用相同的表格驱动方式。基准测试衡量的是整个函数的性能,因此在函数里遍历多个基准用例,只会把所有用例合在一起测量,得到一个基准结果。
常见的变通方法是分别定义顶层基准测试,让它们以不同参数调用同一个公共函数。例如,Go 1.7 之前,strconv 包针对 AppendFloat 的基准测试大致如下:
func benchmarkAppendFloat(b *testing.B, f float64, fmt byte, prec, bitSize int) {
dst := make([]byte, 30)
b.ResetTimer() // Overkill here, but for illustrative purposes.
for i := 0; i < b.N; i++ {
AppendFloat(dst[:0], f, fmt, prec, bitSize)
}
}
func BenchmarkAppendFloatDecimal(b *testing.B) { benchmarkAppendFloat(b, 33909, 'g', -1, 64) }
func BenchmarkAppendFloat(b *testing.B) { benchmarkAppendFloat(b, 339.7784, 'g', -1, 64) }
func BenchmarkAppendFloatExp(b *testing.B) { benchmarkAppendFloat(b, -5.09e75, 'g', -1, 64) }
func BenchmarkAppendFloatNegExp(b *testing.B) { benchmarkAppendFloat(b, -5.11e-95, 'g', -1, 64) }
func BenchmarkAppendFloatBig(b *testing.B) { benchmarkAppendFloat(b, 123456789123456789123456789, 'g', -1, 64) }
...
使用 Go 1.7 提供的 Run 方法,同一组基准测试可以写成一个顶层基准测试:
func BenchmarkAppendFloat(b *testing.B) {
benchmarks := []struct{
name string
float float64
fmt byte
prec int
bitSize int
}{
{"Decimal", 33909, 'g', -1, 64},
{"Float", 339.7784, 'g', -1, 64},
{"Exp", -5.09e75, 'g', -1, 64},
{"NegExp", -5.11e-95, 'g', -1, 64},
{"Big", 123456789123456789123456789, 'g', -1, 64},
...
}
dst := make([]byte, 30)
for _, bm := range benchmarks {
b.Run(bm.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
AppendFloat(dst[:0], bm.float, bm.fmt, bm.prec, bm.bitSize)
}
})
}
}
每次调用 Run 都会创建一个独立的基准测试。调用 Run 的外层基准测试函数只运行一次,其自身不会被计入测量。
新代码虽然行数更多,却更易维护、更易阅读,也与测试中常用的表格驱动方式一致。此外,各次运行能够共享初始化代码,不再需要重置计时器。
使用子测试编写表格驱动测试
Go 1.7 同样引入了用于创建子测试的 Run 方法。下面使用子测试重写前面的例子:
func TestTime(t *testing.T) {
testCases := []struct {
gmt string
loc string
want string
}{
{"12:31", "Europe/Zuri", "13:31"},
{"12:31", "America/New_York", "7:31"},
{"08:08", "Australia/Sydney", "18:08"},
}
for _, tc := range testCases {
t.Run(fmt.Sprintf("%s in %s", tc.gmt, tc.loc), func(t *testing.T) {
loc, err := time.LoadLocation(tc.loc)
if err != nil {
t.Fatal("could not load location")
}
gmt, _ := time.Parse("15:04", tc.gmt)
if got := gmt.In(loc).Format("15:04"); got != tc.want {
t.Errorf("got %s; want %s", got, tc.want)
}
})
}
}
首先值得关注的是两种实现的输出差异。原来的实现输出:
--- FAIL: TestTime (0.00s)
time_test.go:62: could not load location "Europe/Zuri"
尽管有两处错误,调用 Fatalf 时测试就停止了,第二个用例根本没有运行。
使用 Run 的实现会输出两处错误:
--- FAIL: TestTime (0.00s)
--- FAIL: TestTime/12:31_in_Europe/Zuri (0.00s)
time_test.go:84: could not load location
--- FAIL: TestTime/12:31_in_America/New_York (0.00s)
time_test.go:88: got 07:31; want 7:31
Fatal 及同类方法会终止当前子测试,却不会终止父测试或后续子测试。
另一个变化是新实现的错误消息更简短。子测试名称已经唯一标识了该子测试,因此错误消息不必再次说明是哪一个测试。
使用子测试和子基准测试还有其他好处,后面的章节会继续说明。
运行指定的测试或基准测试
可以使用命令行中的 -run 或 -bench 标志,单独选择子测试或子基准测试。两个标志都接受由斜杠分隔的正则表达式列表,各表达式匹配完整名称中对应的部分。
完整名称由子测试或子基准测试自身的名称,以及从顶层开始的所有父级名称组成,各部分以斜杠分隔。对于顶层测试和基准测试,名称就是对应函数名;对于其他层级,名称是传给 Run 的第一个参数。为避免显示和解析问题,名称中的空格会替换为下划线,不可打印字符会被转义。传给 -run 和 -bench 的正则表达式也会经过相同处理。
下面看几个例子。
运行使用欧洲时区的测试:
$ go test -run=TestTime/"in Europe"
--- FAIL: TestTime (0.00s)
--- FAIL: TestTime/12:31_in_Europe/Zuri (0.00s)
time_test.go:85: could not load location
仅运行中午之后时间的测试:
$ go test -run=Time/12:[0-9] -v
=== RUN TestTime
=== RUN TestTime/12:31_in_Europe/Zuri
=== RUN TestTime/12:31_in_America/New_York
--- FAIL: TestTime (0.00s)
--- FAIL: TestTime/12:31_in_Europe/Zuri (0.00s)
time_test.go:85: could not load location
--- FAIL: TestTime/12:31_in_America/New_York (0.00s)
time_test.go:89: got 07:31; want 7:31
可能有些出乎意料,-run=TestTime/New_York 不会匹配任何测试,因为时区名称中的斜杠同样被当作分隔符。应改用:
$ go test -run=Time//New_York
--- FAIL: TestTime (0.00s)
--- FAIL: TestTime/12:31_in_America/New_York (0.00s)
time_test.go:88: got 07:31; want 7:31
留意传给 -run 的字符串中的 //。时区名称 America/New_York 中的 /,会被视为子测试层级产生的分隔符。模式中的第一个正则表达式(TestTime)匹配顶层测试;第二个表达式为空字符串,可匹配任意内容,这里匹配时间和时区的大洲部分;第三个表达式 New_York 匹配城市部分。
把名称中的斜杠当作分隔符,可以在不改变命名的情况下重构测试层级,也简化了转义规则。如果这种行为带来问题,应自行转义名称中的斜杠,例如将其替换为反斜杠。
对于重复的测试名称,系统会追加唯一的序号。因此,如果没有明显合适的子测试命名方案,而通过序号就能轻松区分它们,可以直接向 Run 传入空字符串。
初始化与清理
子测试和子基准测试可以管理共享的初始化与清理代码:
func TestFoo(t *testing.T) {
// <setup code>
t.Run("A=1", func(t *testing.T) { ... })
t.Run("A=2", func(t *testing.T) { ... })
t.Run("B=1", func(t *testing.T) {
if !test(foo{B:1}) {
t.Fail()
}
})
// <tear-down code>
}
只要其中任意子测试运行,初始化与清理代码就会运行,而且最多运行一次。即使某个子测试调用 Skip、Fail 或 Fatal,这一规则仍然成立。
控制并行执行
子测试支持精细控制并行执行。要掌握这种用法,首先需要理解并行测试的语义。
每个测试都关联一个测试函数。如果测试函数在其 testing.T 实例上调用 Parallel 方法,该测试就是并行测试。并行测试不会与顺序执行的测试同时运行;在调用它的父测试函数返回之前,它会暂停执行。-parallel 标志指定最多允许同时运行多少个并行测试。
一个测试只有在其测试函数返回、并且所有子测试都结束后,才算完成。这意味着,由顺序测试启动的并行测试会全部完成,然后才会运行后续的其他顺序测试。
通过 Run 创建的测试与顶层测试遵循相同规则。实际上,顶层测试在内部也是一个隐藏主测试的子测试。
让一组测试并行执行
利用上述语义,可以使一组测试在组内并行执行,同时不与组外的其他并行测试同时运行:
func TestGroupedParallel(t *testing.T) {
for _, tc := range testCases {
tc := tc // capture range variable
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
if got := foo(tc.in); got != tc.out {
t.Errorf("got %v; want %v", got, tc.out)
}
...
})
}
}
通过 Run 启动的所有并行测试完成之前,外层测试不会完成。因此,其他并行测试不能与这一组测试同时运行。
注意,按照本文 Go 1.7 示例的写法,需要捕获 range 变量,确保 tc 绑定到正确的实例。
在一组并行测试结束后清理
前面的例子利用测试语义,让其他测试等待这一组并行测试结束。同样的方法也可以用于一组共享资源的并行测试,在它们全部结束后执行清理:
func TestTeardownParallel(t *testing.T) {
// <setup code>
// This Run will not return until its parallel subtests complete.
t.Run("group", func(t *testing.T) {
t.Run("Test1", parallelTest1)
t.Run("Test2", parallelTest2)
t.Run("Test3", parallelTest3)
})
// <tear-down code>
}
这里等待一组并行测试结束的行为,与前一个例子相同。
结语
Go 1.7 新增的子测试和子基准测试,让编写结构化测试与基准测试变得自然,并能很好地融入现有工具。一种理解方式是:早期的 testing 包只有一层层级,包级测试由若干独立测试与基准测试组成。现在,这种结构可以递归地延伸到这些独立测试与基准测试内部。
事实上,在实现层面,顶层测试与基准测试本身也作为隐式主测试、主基准测试的子级进行跟踪,各层级的处理方式确实相同。
测试自行定义结构后,就能精确执行特定用例,共享初始化与清理代码,并更好地控制并行执行。我们期待大家发现更多用法。祝使用愉快!











暂无评论内容