Go 博客
使用 Subtests 和 Sub-benchmarks
引言
在 Go 1.7 中,testing
包在 T
和 B
类型上引入了 Run 方法,允许创建子测试 (subtests) 和子基准测试 (sub-benchmarks)。引入子测试和子基准测试使得更好地处理失败、从命令行对运行哪些测试进行细粒度控制、控制并行性成为可能,并且通常会带来更简单、更易维护的代码。
表驱动测试基础知识
在深入细节之前,我们先讨论一下 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 之前,不可能对基准测试使用相同的表驱动方法。基准测试衡量的是整个函数的性能,因此对基准测试进行迭代只会将它们作为一个单独的基准测试进行衡量。
一种常见的权宜之计是定义单独的顶级基准测试,每个基准测试都使用不同的参数调用一个通用函数。例如,在 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
方法的 enclosing 基准测试函数只运行一次,并且不被衡量。
新代码的行数更多,但更易维护、更具可读性,并与测试中常用的表驱动方法一致。此外,现在可以在运行之间共享通用的 setup 代码,同时无需重置计时器。
使用子测试进行表驱动测试
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
) 匹配顶层测试。第二个正则表达式(空字符串)匹配任何内容,在本例中是时间和位置的 continent 部分。第三个正则表达式 (New_York
) 匹配位置的 city 部分。
将名称中的斜杠视为分隔符允许用户在无需更改命名的情况下重构测试的层次结构。它还简化了转义规则。如果构成问题,用户应转义名称中的斜杠,例如将它们替换为反斜杠。
对于不唯一的测试名称,会附加一个唯一的序列号。因此,如果子测试没有明显的命名方案,并且可以通过其序列号轻松识别,则可以直接向 Run
传递一个空字符串。
设置和拆卸 (Setup and Tear-down)
子测试和子基准测试可用于管理通用的设置和拆卸代码
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>
}
如果运行任何 enclosed 的子测试,设置和拆卸代码将运行,并且最多运行一次。即使任何子测试调用了 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
启动的并行测试完成。因此,这些并行测试不能与其他并行测试并行运行。
请注意,我们需要捕获范围变量以确保 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 添加的子测试和子基准测试允许您以一种自然的方式编写结构化测试和基准测试,与现有工具 nicely 融合。可以这样想,早期版本的 testing 包只有 1 级层次结构:包级测试被构造为一组独立的测试和基准测试。现在,该结构已递归地扩展到这些独立的测试和基准测试。实际上,在实现中,顶层测试和基准测试被视为隐式主测试和基准测试的子测试和子基准测试来跟踪:所有级别的处理方式确实相同。
测试定义这种结构的能力使得可以对特定测试用例进行细粒度执行,共享设置和拆卸,以及更好地控制测试并行性。我们很高兴看到人们会发现其他哪些用途。祝您使用愉快。
下一篇文章:引入 HTTP 追踪
上一篇文章:更小的 Go 1.7 二进制文件
博客索引