围棋子测试和子基准的使用
介绍
Go 1.7,testing 包在 T 和 B 类型上引入了一个 Run 方法,允许创建子测试和子基准测试。子测试和子基准测试的引入可以更好地处理故障(failures),细化控制从命令行运行的测试,并行控制,并且经常会使代码更简单、更易于维护。
Table-driven 测试
在详细介绍之前,首先讨论在 Go 中编写测试的常用方法。一系列相关验证可以通过循环遍历一系列测试用例来实现:
1func TestTime(t *testing.T) { 2 testCases := []struct { 3 gmt string 4 loc string 5 want string 6 }{ 7 {"12:31", "Europe/Zuri", "13:31"}, // incorrect location name 8 {"12:31", "America/New_York", "7:31"}, // should be 07:31 9 {"08:08", "Australia/Sydney", "18:08"},10 }11 for _, tc := range testCases {12 loc, err := time.LoadLocation(tc.loc)13 if err != nil {14 t.Fatalf("could not load location %q", tc.loc)15 }16 gmt, _ := time.Parse("15:04", tc.gmt)17 if got := gmt.In(loc).Format("15:04"); got != tc.want {18 t.Errorf("In(%s, %s) = %s; want %s", tc.gmt, tc.loc, got, tc.want)19 }20 }21}
通常称为 table-driven(表格驱动) 测试,相比每次测试重复相同代码,减少了重复代码的数量,并且可以直接添加更多的测试用例。
Table-driven 基准测试
在 Go 1.7 之前,不可能使用相同的 table-driven 方法进行基准测试。基准测试对整个函数的性能进行测试,因此迭代基准测试只是将它们整体作为一个基准测试。
常见的解决方法是定义单独的*基准,每个基准用不同的参数调用共同的函数。例如,在 1.7 之前,strconv 包的 AppendFloat 的基准测试看起来像这样:
1func benchmarkAppendFloat(b *testing.B, f float64, fmt byte, prec, bitSize int) { 2 dst := make([]byte, 30) 3 b.ResetTimer() // Overkill here, but for illustrative purposes. 4 for i := 0; i < b.N; i++ { 5 AppendFloat(dst[:0], f, fmt, prec, bitSize) 6 } 7} 8func BenchmarkAppendFloatDecimal(b *testing.B) { benchmarkAppendFloat(b, 33909, 'g', -1, 64) } 9func BenchmarkAppendFloat(b *testing.B) { benchmarkAppendFloat(b, 339.7784, 'g', -1, 64) }10func BenchmarkAppendFloatExp(b *testing.B) { benchmarkAppendFloat(b, -5.09e75, 'g', -1, 64) }11func BenchmarkAppendFloatNegExp(b *testing.B) { benchmarkAppendFloat(b, -5.11e-95, 'g', -1, 64) }12func BenchmarkAppendFloatBig(b *testing.B) { benchmarkAppendFloat(b, 123456789123456789123456789, 'g', -1, 64) }13...
使用 Go 1.7 中提供的 Run 方法,现在将同一组基准表示为单个*基准:
1func BenchmarkAppendFloat(b *testing.B) { 2 benchmarks := []struct{ 3 name string 4 float float64 5 fmt byte 6 prec int 7 bitSize int 8 }{ 9 {"Decimal", 33909, 'g', -1, 64},10 {"Float", 339.7784, 'g', -1, 64},11 {"Exp", -5.09e75, 'g', -1, 64},12 {"NegExp", -5.11e-95, 'g', -1, 64},13 {"Big", 123456789123456789123456789, 'g', -1, 64},14 ...15 }16 dst := make([]byte, 30)17 for _, bm := range benchmarks {18 b.Run(bm.name, func(b *testing.B) {19 for i := 0; i < b.N; i++ {20 AppendFloat(dst[:0], bm.float, bm.fmt, bm.prec, bm.bitSize)21 }22 })23 }24}
每次调用 Run 方法创建一个单独的基准测试。调用 Run 方法的基准函数只运行一次,不进行性能度量。
新代码行数更多,但是更可维护,更易读,并且与通常用于测试的 table-driven 方法一致。此外,共同的 setup 代码现在在 Run 之间共享,而不需要重置定时器。
Table-driven 用于子测试
Go 1.7 还引入了一种用于创建子测试的 Run 方法。这个测试是我们上面使用子测试的例子的重写版本:
1func TestTime(t *testing.T) { 2 testCases := []struct { 3 gmt string 4 loc string 5 want string 6 }{ 7 {"12:31", "Europe/Zuri", "13:31"}, 8 {"12:31", "America/New_York", "7:31"}, 9 {"08:08", "Australia/Sydney", "18:08"},10 }11 for _, tc := range testCases {12 t.Run(fmt.Sprintf("%s in %s", tc.gmt, tc.loc), func(t *testing.T) {13 loc, err := time.LoadLocation(tc.loc)14 if err != nil {15 t.Fatal("could not load location")16 }17 gmt, _ := time.Parse("15:04", tc.gmt)18 if got := gmt.In(loc).Format("15:04"); got != tc.want {19 t.Errorf("got %s; want %s", got, tc.want)20 }21 })22 }23}
首先要注意的是两个实现的输出差异。原始实现打印:
1--- FAIL: TestTime (0.00s)2 time_test.go:62: could not load location "Europe/Zuri”3
即使有两个错误,测试停止在对 Fatal 的调用上,而第二个测试不会运行。
而使用 Run 的版本两个都执行了:
1--- FAIL: TestTime (0.00s)2 --- FAIL: TestTime/12:31_in_Europe/Zuri (0.00s)3 time_test.go:84: could not load location4 --- FAIL: TestTime/12:31_in_America/New_York (0.00s)5 time_test.go:88: got 07:31; want 7:31
Fatal 及其相关方法导致子测试被跳过,但不会跳过其父测试其他的子测试。
另一个需要注意的点是新实现版本中的错误消息较短。由于子测试名称可以唯一标识,因此无需在错误消息中再次进行标识。
使用子测试或子基准还有其他好处,如以下部分所述。
运行特定的测试或基准测试
可以使用 -run 或 -bench 标志在命令行中标识子测试或子基准测试。两个标志都采用一个斜杠分隔的正则表达式列表,它们与子测试或子基准测试的全名的相应部分相匹配。
子测试或子基准测试的全名是一个斜杠分隔的名称列表,以*名称开始。该名称开始是*测试或基准测试的相应函数名称,其他部分是 Run 的第一个参数。为了避免显示和解析问题,名称会通过下划线替换空格并转义不可打印的字符来清理。对传递给 -run 或 -bench 标志的正则表达式应用相同的清理规则。
看一些例子:
使用欧洲时区运行测试:
1$ go test -run=TestTime/"in Europe"2--- FAIL: TestTime (0.00s)3 --- FAIL: TestTime/12:31_in_Europe/Zuri (0.00s)4 time_test.go:85: could not load location
仅仅运行时间在午后的测试:(Go 语言中文网注:我本地测试,必须转义 go test -run=Time/12:\[0-9\] -v)
1$ go test -run=Time/12:[0-9] -v2=== RUN TestTime3=== RUN TestTime/12:31_in_Europe/Zuri4=== RUN TestTime/12:31_in_America/New_York5--- FAIL: TestTime (0.00s)6 --- FAIL: TestTime/12:31_in_Europe/Zuri (0.00s)7 time_test.go:85: could not load location8 --- FAIL: TestTime/12:31_in_America/New_York (0.00s)9 time_test.go:89: got 07:31; want 7:31
也许有点令人惊讶,使用 -run = TestTime/New_York 将不会匹配任何测试。这是因为位置名称中存在的斜线也被视为分隔符。需要这么使用:
1$ go test -run=Time//New_York2--- FAIL: TestTime (0.00s)3 --- FAIL: TestTime/12:31_in_America/New_York (0.00s)4 time_test.go:88: got 07:31; want 7:31
注意 // 在传递给 -run 的字符串中,在时区名称 America/New_York 中的 / 被处理了,就好像它是一个子测试分隔符。模式(TestTime)的第一个正则表达式匹配*测试。第二个正则表达式(空字符串)匹配任何内容,在这 case 中,是时间和位置的洲部分。第三个正则表达式(New_York)匹配位置的城市部分。
将名称中的斜杠视为分隔符可以让用户重构测试的层次结构,而无需更改命名。它也简化了转义规则。用户应避免在名称中使用斜扛,如果出现问题,请使用反斜杠替换它们。
唯一的序列号附加到不唯一的测试名称。因此,如果没有更好的子测试命名方案,则可以将空字符串传递给 Run,并且可以通过序列号轻松识别子测试。
Setup 和 Tear-down
子测试和子基准测试可用于管理常见的 setup 和 tear-down 代码:
1func TestFoo(t *testing.T) { 2 // <setup code> 3 t.Run("A=1", func(t *testing.T) { ... }) 4 t.Run("A=2", func(t *testing.T) { ... }) 5 t.Run("B=1", func(t *testing.T) { 6 if !test(foo{B:1}) { 7 t.Fail() 8 } 9 })10 // <tear-down code>11}
当运行测试时,Setup 和 Tear-down 代码运行且最多运行一次。即使任何一个子测试调用了 Skip,Fail 或 Fatal,也适用。
并行度控制
子测试允许对并行性进行细粒度控制。为了理解如何使用子测试进行并行控制,得先理解并行测试的语义。
每个测试都与一个测试函数相关联。如果测试函数调用了其 testing.T 实例上的 Parallel 方法,则测试称为并行测试。并行测试从不与顺序测试同时运行,直到顺序测试返回,并行测试才继续运行。-parallel 标志定义可并行运行的最大并行测试数。
一个测试被堵塞,直到其所有的子测试都已完成。这意味着在一个测试中(TestXXX 函数中),在并行测试完成后,顺序测试才会执行。
对于由 Run 和 *测试 创建的测试,此行为是相同的。实际上,*测试是隐式的主测试 (master test) 的子测试。
并行运行一组测试
上述语义允许并行地运行一组测试,但不允许其他并行测试:
1func TestGroupedParallel(t *testing.T) { 2 for _, tc := range testCases { 3 tc := tc // capture range variable 4 t.Run(tc.Name, func(t *testing.T) { 5 t.Parallel() 6 if got := foo(tc.in); got != tc.out { 7 t.Errorf("got %v; want %v", got, tc.out) 8 } 9 ...10 })11 }12}
在由 Run 启动的所有并行测试完成之前,外部测试将不会完成。因此,没有其他并行测试可以并行地运行这些并行测试。
请注意,我们需要复制 range 变量以确保 tc 绑定到正确的实例。(因为 range 会重用 tc)
并行测试后的清理
在前面的例子中,根据语义,等待一组并行测试完成之后,其他测试才会开始。在一组共享共同资源的并行测试之后,可以使用相同的技术进行清理:
1func TestTeardownParallel(t *testing.T) { 2 // <setup code> 3 // This Run will not return until its parallel subtests complete. 4 t.Run("group", func(t *testing.T) { 5 t.Run("Test1", parallelTest1) 6 t.Run("Test2", parallelTest2) 7 t.Run("Test3", parallelTest3) 8 }) 9 // <tear-down code>10}
等待一组并行测试的行为与上一个示例的行为相同。
结论
Go 1.7 加入子测试和子基准测试可以让您以自然的方式编写结构化测试和基准测试,将其很好地融入到现有的工具中。早期版本的 testing 包具有1级层次结构:包级测试由一组单独的测试和基准测试组成。现在,这种结构已经被递归扩展到这些单独的测试和基准测试中。实际上,在实施过程中,*测试和基准测试被追踪,就像它们是隐式的主 测试和基准测试 的子测试和子基准测试:在所有级别的处理都是一样的。
为测试定义此结构的能力使得可以对特定测试用例进行细粒度的执行,共享 setup 和 teardown,并更好地控制测试并行性。如果你发现了什么其他用途,请分享。
本文由 徐新华 翻译。来自 Go语言中文网博客
原文:Using Subtests and Sub-benchmarks
喜欢就扫描关注我们
推荐阅读
-
围棋子测试和子基准的使用
-
35 岁实现财务*,腾讯程序员手握2300万提前退休?-1000万房产、1000万腾讯股票、加上300万的现金,一共2300万的财产。有网友算了一笔账,假设1000万的房产用于自住,剩下1300万资产按照平均税后20-50万不等进行计算,大约花上26-60年左右的时间才能赚到这笔钱。也就是说,普通人可能奋斗一辈子,才能赚到这笔钱。在很多人还在为中年危机而惶惶不可终日的时候,有的人的35岁,就已经安全着陆,试问哪个打工人不羡慕?但问题是有这样财富积累必然有像样的实力做靠山。没有人可以不劳而获。 看到这里,肯定有人说,那么对于普通人来说,卷可能真就成了唯一的出路。但是卷也有轻松的卷,“偷懒”的卷法,对于程序员而言,刨除掉一时无法改掉的开会传统占用的大部分时间,如何把有限的时间和精力放在真正重要的架构设计、需求设计上,而不是重复的造*,编码、改bug、手动测试。因此在科技改变生活的今天,学会使用AI工具成为程序员们的必备技能。 以全栈式全自动的软件开发工具飞算SoFlu软件机器人为例,作为全球首款面向微服务架构设计和最佳实践的软件机器人,SoFlu软件机器人改变了原来手工编码的作业模式,通过可视化拖拽方式以及参数配置就能实现等同于编写复杂代码的业务逻辑,在设计业务逻辑时就完成了微服务应用开发,做到“业务即图,图即代码”,实现“软件开发,十倍提效”。 而作为飞算SoFlu软件机器人的一个重要组成部分,FuncGPT(慧函数)支持所有类型函数创建。通过自然语言描述Java函数需求,实时生成高质量、高可读性的Java函数代码。生成代码可直接复制到IDEA,或一键导入Java全自动开发工具函数库。 FuncGPT(慧函数)具有五大核心能力: ● 自然语言:通过自然语言即可生成函数,降低软件开发门槛。 ● 秒级函数开发:全自动秒级开发函数,秒级完成,效率千倍提升。 ● 一站式函数开发应用:函数生成即可直接应用,效率、安全有保障。 ● 质量可靠:生成的函数代码符合业界规范、具备良好的可读性,符合最优解。 ● 灵活生成:可以根据开发人员的具体需求生成并支持调整和修改。 以“将文件流转换为File对象”这个需求为例,以下是FuncGPT(慧函数)秒级生成的代码: / 类名:FileConverter // 函数名:convertStreamToFile // 函数功能:将文件流转换为File对象 // POM依赖包:无 import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; public class FileConverter { /** * 将文件流转换为File对象 * @param inputStream 文件流 * @param filePath 文件路径 * @param fileName 文件名 * @return 转换后的File对象 * @throws IOException 如果发生I/O异常 */ public static File convertStreamToFile(InputStream inputStream, String filePath, String fileName) throws IOException { File file = new File(filePath + File.separator + fileName); // 创建File对象 try (FileOutputStream outputStream = new FileOutputStream(file)) { // 创建文件输出流 byte buffer = new byte[1024]; int bytesRead; while ((bytesRead = inputStream.read(buffer)) != -1) { // 从文件流读取数据并写入文件 outputStream.write(buffer, 0, bytesRead); } } return file; // 返回转换后的File对象 } } // 函数示例 // 将文件流转换为File对象示例 // 入参:inputStream,文件流 // 入参:filePath,文件路径 // 入参:fileName,文件名 // 出参:file,转换后的File对象 // 调用示例: // InputStream inputStream = new FileInputStream("example.txt"); // String filePath = "C:\\Users\\User\\Documents"; // String fileName = "example.txt"; // File file = FileConverter.convertStreamToFile(inputStream, filePath, fileName); // System.out.println(file.getAbsolutePath); // 输出结果:例如,将文件流转换为File对象后,文件的绝对路径为:C:\Users\User\Documents\example.txt // 则输出结果为:C:\Users\User\Documents\example.txt 通过分析,不难发现以上代码: