Go 博客

使用子测试和子基准测试

Marcel van Lohuizen
2016 年 10 月 3 日

简介

在 Go 1.7 中,testing 包在 TB 类型上引入了一个 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 之前,无法对基准测试使用相同的基于表格的方法。基准测试测试整个函数的性能,因此遍历基准测试只会将所有基准测试作为单个基准测试进行测量。

一个常见的解决方法是定义单独的顶级基准测试,每个基准测试都使用不同的参数调用一个公共函数。例如,在 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>
}

如果任何封闭的子测试运行,设置和拆卸代码将运行,并且最多运行一次。即使任何子测试调用 SkipFailFatal,也是如此。

并行性的控制

子测试允许对并行性进行细粒度控制。要了解如何在使用子测试的方式,需要了解并行测试的语义。

每个测试都与一个测试函数相关联。如果测试函数在其 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 添加子测试和子基准测试,使您能够以自然的方式编写结构化的测试和基准测试,这些测试和基准测试可以很好地融入现有工具。可以这样想:早期版本的测试包具有 1 级层次结构:包级测试结构化为一组单独的测试和基准测试。现在,该结构已递归地扩展到这些单独的测试和基准测试。实际上,在实现中,顶级测试和基准测试被跟踪,就像它们是隐式主测试和基准测试的子测试和子基准测试一样:处理在所有级别上都是相同的。

测试定义此结构的能力使能够细粒度地执行特定测试用例、共享设置和拆卸以及更好地控制测试并行性。我们很高兴看到人们发现的其他用途。尽情享受。

下一篇文章:介绍 HTTP 跟踪
上一篇文章:更小的 Go 1.7 二进制文件
博客索引