使用 Go 子测试与子基准测试

使用 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 包只有一层层级,包级测试由若干独立测试与基准测试组成。现在,这种结构可以递归地延伸到这些独立测试与基准测试内部。

事实上,在实现层面,顶层测试与基准测试本身也作为隐式主测试、主基准测试的子级进行跟踪,各层级的处理方式确实相同。

测试自行定义结构后,就能精确执行特定用例,共享初始化与清理代码,并更好地控制并行执行。我们期待大家发现更多用法。祝使用愉快!

来源:Using Subtests and Sub-benchmarks,Marcel van Lohuizen,2016年10月3日。本文完整汉化,保留原文 Go 1.7 的版本语境及示例;代码与示例输出均来自原文。

许可证原文
Copyright 2009 The Go Authors.

Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:

   * Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
   * Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimer
in the documentation and/or other materials provided with the
distribution.
   * Neither the name of Google LLC nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容