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 添加的子测试和子基准测试允许您以自然的方式编写结构化的测试和基准测试,并能很好地融入现有工具。可以这样理解:早期版本的 testing 包有一个 1 级层次结构:包级测试被构造为一组单独的测试和基准测试。现在,这种结构已扩展到这些单独的测试和基准测试,并且是递归的。事实上,在实现中,顶级测试和基准测试被跟踪,就好像它们是隐式主测试和基准测试的子测试和子基准测试一样:所有级别的处理方式都相同。

测试定义这种结构的能力实现了对特定测试用例的精细执行、共享的设置和拆卸,以及对测试并行性的更好控制。我们很期待看到人们还会发现哪些其他用法。尽情享用吧。

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