使用子测试和子基准测试

未匹配的标注

本文为官方 Go Blog 的中文翻译,详见 翻译说明

马塞尔·范·罗威岑(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"},     // 错误的位置名称
        {"12:31", "America/New_York", "7:31"}, // 应该是 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() // 这里过度的杀伤了,但出于说明目的。
    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, FailFatal 都适用.

并行控制

子测试允许对并行性进行细粒度的控制. 要了解如何以这种方式使用子测试, 理解并行测试的语义很重要.

每个测试都与一个测试函数相关联. 如果测试的函数在其 testing.T 实例上调用 Parallel 方法, 则该测试称为并行测试. 并行测试永远不会与顺序测试同时运行, 并且其执行将被挂起, 直到返回其调用测试函数 (即父测试的函数) 为止. -parallel 标志定义可以并行运行的最大并行测试数.

测试将阻塞, 直到其测试功能返回并且所有子测试都已完成. 这意味着由顺序测试运行的并行测试将在运行任何其他连续的顺序测试之前完成.

对于由 Run 创建的测试和顶级测试的行为相同. 实际上, 顶层测试是作为隐藏的主测试的子测试实现的.

并行运行一组测试

上面的语义允许彼此并行运行一组测试, 但不能与其他并行测试并行运行:

func TestGroupedParallel(t *testing.T) {
    for _, tc := range testCases {
        tc := tc // 捕获范围变量
        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>
    // 此运行直到并行子测试完成后才会返回.    
    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 级层次结构: 包级测试被构造为一组单独的测试和基准. 现在, 该结构已递归地扩展到那些单独的测试和基准. 实际上, 在实施过程中, 对顶级测试和基准进行了跟踪, 就好像它们是隐式主测试和基准的子测试和子基准一样: 各个级别的处理方法实际上都是相同的.

测试具有定义此结构的能力, 可以对特定的测试用例进行细粒度的执行, 共享安装和拆卸, 以及更好地控制测试并行性. 我们很高兴看到人们发现其他用途. 来试试吧.

本文章首发在 LearnKu.com 网站上。

本译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。

原文地址:https://learnku.com/docs/go-blog/subtest...

译文地址:https://learnku.com/docs/go-blog/subtest...

上一篇 下一篇
Summer
贡献者:2
讨论数量: 0
发起讨论 只看当前版本


暂无话题~