Testing in Go: philosophy and tools

Ben Hoyt

Go 测试:理念与工具

原文由 Ben Hoyt 发布,订阅该博客

Go 编程语言自带用于编写和运行测试的工具:标准库中的testing包,以及用于运行测试套件的go test命令。和语言本身一样,Go 在测试方面的理念是极简主义:使用轻量级的testing包,再配合用纯 Go 编写的辅助函数。其背后的想法是,测试就是代码,既然 Go 开发者已经懂得如何运用语言的抽象和类型来编写 Go 代码,就没必要再为写测试去学一门古怪的领域特定语言。

由 Brian Kernighan 和 Alan Donovan 合著的The Go Programming Language总结了这一理念。在第 11 章关于测试的部分写道:

许多刚接触 Go 的人都会对 Go 测试框架的极简感到意外。其他语言的测试框架提供了用于识别测试函数的机制(通常借助反射或元数据)、在测试运行前后执行“前置(setup)”和“后置(teardown)”操作的钩子,以及用于断言常见谓词、比较值、格式化错误信息和中止失败测试(通常通过异常实现)的工具函数库。尽管这些机制能让测试写得非常简洁,但写出来的测试往往给人一种像是另一种外来语言写成的感觉。

为了更直观地说明这一点,下面是用testing包和纯 Go 编写的一个针对绝对值函数Abs()的简单测试:

    func TestAbs(t *testing.T) {
        got := Abs(-1)
        if got != 1 {
            t.Errorf("Abs(-1) = %d; want 1", got)
        }
    }

与之对比,下面是使用流行的(尽管在我看来并不符合 Go 惯用法)Ginkgo库编写的同一测试,该库提供了为 Go 编写RSpec风格测试的能力:

    Describe("Abs", func() {
        It("returns correct abs value for -1", func() {
            got := Abs(-1)
            Expect(got).To(Equal(1))
        })
    })

函数DescribeExpect等让测试“读起来像英语”,但也意味着突然要去学一整套新的子语言。包括 Donovan 在内的 Go 贡献者认为,语言本身已经内置了==!=这样的工具,那又何必需要To(Equal(x))呢?

当然,Go 也并不阻止开发者使用这类库,因此从其他语言转过来的开发者常常觉得用它们比用原生的testing更熟悉。一个相对轻量的库是testify/assert,它提供了像assert.Equal()这样的常用断言函数,以及testify/suite,它提供了包括前置和后置处理在内的测试套件工具。“Awesome Go”网站上提供了这类第三方包的一份详尽清单

一个不在testing包中但很有用的测试工具是reflect.DeepEqual(),这是标准库中一个利用反射来判断“深度相等”的函数,即在跟随指针并递归深入 map、数组等结构之后再判断相等。当测试需要比较 JSON 对象或包含指针的结构体之类的数据时,它就很有用。在此基础上进一步封装的两个库是 Google 的go-cmp包和 Daniel Nichter 的deep,它们与DeepEqual类似,但不会只返回一个布尔值,而是会生成人类可读的差异对比,指出哪些地方不相等。例如,下面是一个使用 go-cmp 对MakeUsers()函数的测试(故意写错以展示效果):

    func TestMakeUser(t *testing.T) {
        got := MakeUser("Bob Smith", "[email protected]", 42)
        want := &User{
            Name:  "Bob Smith",
            Email: "[email protected]",
            Age:   42,
        }
        if diff := cmp.Diff(want, got); diff != "" {
            t.Errorf("MakeUser() mismatch (-want +got):\n%s", diff)
        }
    }

其人类可读的输出如下:

    user_test.go:16: MakeUser() mismatch (-want +got):
          &main.User{
            Name:  "Bob Smith",
        -   Email: "[email protected]",
        +   Email: "[email protected]",
            Age:   42,
          }

内置 testing 功能

内置的testing包包含了多种函数,可用于记录信息和报告失败、在运行时跳过测试,或仅以“short”模式运行测试。short 模式提供了一种跳过那些运行时间长或需要大量前置准备的测试的方法,在开发过程中很有用。该模式通过命令行参数-test.short启用。

Go 的测试运行器默认按顺序执行测试,但提供了一个可选的Parallel()函数,允许将显式标记的测试在多个核心上并行运行。

在 Go 1.14 中,testing包新增了Cleanup()函数,用于注册一个在测试完成时调用的函数。这是一种简化清理工作(teardown)的内置方式,例如在测试结束后删除数据表:

    func createDatabase(t *testing.T) {
        // ... code to create a test database
        t.Cleanup(func() {
            // ... code to delete the test database
            // runs when the test finishes (success or failure)
        })
    }

    func TestFetchUser(t *testing.T) {
        createDatabase(t) // creates database and registers cleanup
        user, err := FetchUser("[email protected]")
        if err != nil {
            t.Fatalf("error fetching user: %v", err)
        }
        expected := &User{"Bob Smith", "[email protected]", 42}
        if !reflect.DeepEqual(user, expected) {
            t.Fatalf("expected user %v, got %v", expected, user)
        }
    }

Go 1.15新增了一个测试辅助函数TempDir(),它会为当前测试创建(并在结束后清理)一个临时目录。向testing包添加新功能的门槛很高,但 Go 核心团队的 Russ Cox对此表示了认可:“临时目录似乎在足够多类型的测试中都会用到,理应成为testing本身的一部分。

表驱动测试

Go 中一种用于在测试各种边界情况时避免重复的常见惯用法被称为“表驱动测试”。这种技术会在一个“slice”(Go 中对可变长数组视图的称呼)中遍历所有测试用例,并在每次迭代中报告失败:

    func TestAbs(t *testing.T) {
        tests := []struct {
            input    int
            expected int
        }{
            {1, 1},
            {0, 0},
            {-1, 1},
            {-maxInt, maxInt},
            {maxInt, maxInt},
        }
        for _, test := range tests {
            actual := Abs(test.input)
            if actual != test.expected {
                t.Errorf("Abs(%d) = %d; want %d", test.input, actual, test.expected)
            }
        }
    }

t.Errorf()调用会报告失败但不会中止测试的执行,因此可以一次报告多个失败。这种表驱动测试的风格在标准库的测试中十分常见(例如fmt 测试)。子测试(Subtests)是 Go 1.7 引入的一项功能,它让开发者可以从命令行单独运行某个子测试,并能更好地控制失败处理和并行执行。

Mock 与接口

Go 一个广为人知的语言特性是其结构化类型的接口(interface),有时也被称为“编译期鸭子类型”。“Go 中的接口提供了一种描述对象行为的方式:如果某个东西能做到这件事,那么它就能在这里被使用。”每当需要在运行时改变行为时,接口就显得尤为重要,测试自然也不例外。例如,正如 Go 核心贡献者 Andrew Gerrand 在其 2014 年“Testing Techniques”演讲的幻灯片中所说,一个文件格式解析器不应该像这样传入一个具体的文件类型:

    func Parse(f *os.File) error { ... }

相反,Parse()应该只接收一个仅实现所需功能的精简接口。在这种情况下,无处不在的io.Reader就是一个很好的选择:

    func Parse(r io.Reader) error { ... }

这样一来,解析器就可以接收任何实现了io.Reader的输入,包括文件、字符串缓冲区和网络连接。这也让测试变得容易得多(很可能会用到strings.Reader)。

如果测试只用到某个大型接口的一小部分,例如多方法 API 中的某一个方法,可以创建一个新的结构体类型,通过嵌入(embedding)该接口来满足 API 约定,然后仅重写会被调用的那个方法。这段 Go Playground 代码展示了这一技巧的完整示例。

还有各种第三方工具,例如GoMockmockery,可以根据接口定义自动生成 mock 代码。不过,Gerrand更倾向于手写 fake:

[像 gomock 这样的 mock 库]固然不错,但总体来看,我发现手写的 fake 往往更容易推理,也更能让人看清到底发生了什么,不过我不是企业级 Go 开发者,所以也许有人确实需要那些工具,我也不确定,但这是我的建议。

可测试示例

Go 的包文档是从源代码中的注释生成的。与在代码注释中大量使用标记的 Javadoc 或 C# 文档系统不同,Go 的做法是让源代码中的注释在源码里本身就保持可读,而不是充斥着各种标记。

它在文档示例上也采用了类似的思路:这些是可运行的代码片段,会在运行测试时被自动执行,然后被包含到生成的文档中。与 Python 的doctest颇为相似,可测试示例会向标准输出写入内容,并将输出与预期输出进行比较,以避免文档中的示例出现回归。下面是一个关于Abs()函数的可测试示例:

    func ExampleAbs() {
        fmt.Println(Abs(5))
        fmt.Println(Abs(-42))
        // Output:
        // 5
        // 42
    }

示例函数必须位于*_test.go文件中,并以Example为前缀。当测试运行器执行时,会解析Output:注释并将其与实际输出进行比较,如果不一致就会导致测试失败。这些示例会作为可运行的 Go Playground 代码片段被包含在生成的文档中,例如在strings包中就能看到。

基准测试

除了测试之外,testing包还允许运行计时的基准测试。标准库中大量使用基准测试来确保执行速度没有出现回退。可以使用go test配合-bench=选项自动运行基准测试。知名的 Go 作者 Dave Cheney 在其文章“How to write benchmarks in Go”中对此有很好的总结。

作为示例,下面是标准库中针对strings.TrimSpace()函数的基准测试(注意其中采用的表驱动方式以及使用b.Run()来创建子基准测试):

    func BenchmarkTrimSpace(b *testing.B) {
        tests := []struct{ name, input string }{
            {"NoTrim", "typical"},
            {"ASCII", "  foo bar  "},
            {"SomeNonASCII", "    \u2000\t\r\n x\t\t\r\r\ny\n \u3000    "},
            {"JustNonASCII", "\u2000\u2000\u2000☺☺☺☺\u3000\u3000\u3000"},
        }
        for _, test := range tests {
            b.Run(test.name, func(b *testing.B) {
                for i := 0; i < b.N; i++ {
                    TrimSpace(test.input)
                }
            })
        }
    }

go test工具会报告各项数据;而像benchstat这样的程序可用于对比修改前后的耗时。benchstat 的输出常常被包含在 Go 的提交信息中,以展示性能提升。例如,来自变更 152917的输出:

    name                      old time/op  new time/op  delta
    TrimSpace/NoTrim-8        18.6ns ± 0%   3.8ns ± 0%  -79.53%  (p=0.000 n=5+4)
    TrimSpace/ASCII-8         33.5ns ± 2%   6.0ns ± 3%  -82.05%  (p=0.008 n=5+5)
    TrimSpace/SomeNonASCII-8  97.1ns ± 1%  88.6ns ± 1%   -8.68%  (p=0.008 n=5+5)
    TrimSpace/JustNonASCII-8   144ns ± 0%   143ns ± 0%     ~     (p=0.079 n=4+5)

这表明TrimSpace的 ASCII 快速路径让纯 ASCII 输入的速度提升了约五倍,尽管“SomeNonASCII”子测试慢了约 9%。

要诊断哪里运行缓慢,可以使用内置的性能分析工具,例如在运行测试时使用-cpuprofile选项。内置的go tool pprof能以多种格式展示分析结果,包括火焰图

go test 命令

Go 对于测试应该放在哪里(命名为*_test.go的文件中)以及测试函数如何命名(必须以Test为前缀)有着明确的主张。不过,这种“有主见”的好处在于,go test工具可以准确地知道去哪里查找以及如何运行测试。不需要 makefile 或元数据来描述测试的位置——只要文件和函数按标准方式命名,Go 就知道该去哪里找。

go test命令表面上看起来很简单,但它提供了许多用于运行和筛选测试与基准测试的选项。下面是一些示例:

    go test             # run tests in current directory
    go test package     # run tests for given package
    go test ./...       # run tests for current dir and all sub-packages
    go test -run=foo    # run tests matching regex "foo"
    go test -cover      # run tests and output code coverage
    go test -bench=.    # also run benchmarks
    go test -bench=. -cpuprofile cpu.out
                        # run benchmarks, record profiling info

Go test 的-cover模式会生成代码覆盖率分析文件,可以使用go tool cover -html=coverage.out以 HTML 形式查看。在解释Go 代码覆盖率工具的工作原理时,Go 联合创始人 Rob Pike 曾说:

对于 Go 新的测试覆盖率工具,我们采用了一种不同于[给二进制文件插桩]、避免动态调试的方法。思路很简单:在编译前重写包的源代码以加入插桩逻辑,编译并运行修改后的源码,然后转储统计数据。这种重写很容易实现,因为go命令掌控着从源码到测试再到执行的整个流程。

总结

Go 的testing库简单而可扩展,而go test运行器则与之形成了很好的互补,提供了测试执行、基准测试、性能分析和代码覆盖率报告等功能。仅靠原生的testing包就能走得很远——我发现 Go 的极简方式是一种倒逼机制,促使人们换个角度思考测试,并充分利用接口和结构体组合等原生语言特性。但如果确实需要引入第三方库,也只需一个go get即可。

本文章由 muse-spark-1.2-contributor 进行翻译

评论