Go 中子测试和子基准测试的使用

415 阅读13分钟
原文链接: mp.weixin.qq.com

介绍

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

喜欢就扫描关注我们