likes
comments
collection
share

云原生系列Go语言篇-编写测试Part 2

作者站长头像
站长
· 阅读数 26

本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。

检测代码覆盖率

代码覆盖率是一个非常有用的工具,可以知道是否漏掉了某些明显的状况。但达到100%的测试覆盖率并不能保证在某些输入下代码中没有错误。首先,我们会学习如何使用go test展示代码覆盖率,然后我们会了解仅依赖代码覆盖率的局限性。

go test命令中添加-cover标记可以计算覆盖率信息,并在测试输出中添加摘要。如果再加上一个-coverprofile 的参数,可将覆盖率信息保存到一个文件中。我们再回到第15章的GitHub代码库sample_code/table目录中,收集代码覆盖率信息:

$ go test -v -cover -coverprofile=c.out

如果检测表格测试的代码覆盖率,测试输出会显示一行信息,代码覆盖率为87.5%。虽然这是有用的信息,但我们更希望看到漏掉了哪些测试。Go 附带的cover工具会生成包含了这些信息的 HTML 表示:

$ go tool cover -html=c.out

运行该命令,应该会打开浏览器并能看到如图12-1的页面:

云原生系列Go语言篇-编写测试Part 2

图12-1:初始测试代码覆盖率

每个测试过的文件都会出现在左上角的组合框中。源代码有三种颜色。灰色表不可测试的代码行,绿色表已被测试覆盖的代码,红色表未经测试的代码。通过观察颜色,可以看出我们没有对default分支编写测试,即对函数传递错误的运算符时。下面将这种情况添加到测试列表中:

{"bad_op", 2, 2, "?", 0, `unknown operator ?`},

重新运行go test -v -cover -coverprofile=c.outgo tool cover -html=c.out,可在图12-2中看到测试代码覆盖率为100%。

云原生系列Go语言篇-编写测试Part 2

图12-2:最终测试代码覆盖率

代码覆盖率非常棒,但也有不足。虽然有100%的覆盖率,但代码中却有一个bug。不知读者有没有注意到?如果没有,可以添加另一个测试用例然后运行测试:

{"another_mult", 2, 3, "*", 6, ""},

可以看到如下错误:

table_test.go:57: Expected 6, got 5

在乘法用例中有一处笔误。对乘法使用了加号。(复制、粘贴代码时要格外小心!)修改代码,再次运行go test -v -cover -coverprofile=c.outgo tool cover -html=c.out,测试会正常通过。

警告:代码覆盖率很有必要,但并不足够。覆盖率为100%的代码仍可能存在bug。

基准测试

确定代码是快或慢非常复杂。我们不用自己计算,应使用Go测试框架内置的基准测试。下面来看第15章的GitHub代码库sample_code/bench目录下的函数:

func FileLen(f string, bufsize int) (int, error) {
    file, err := os.Open(f)
    if err != nil {
        return 0, err
    }
    defer file.Close()
    count := 0
    for {
        buf := make([]byte, bufsize)
        num, err := file.Read(buf)
        count += num
        if err != nil {
            break
        }
    }
    return count, nil
}

这个函数计算文件中的字数。它接收两个参数,文件名和用于读取文件的缓冲大小(稍后会讲到第二个参数的作用)。

在测试其速度前,应当测试代码运行是否正常。以下是简单的测试:

func TestFileLen(t *testing.T) {
    result, err := FileLen("testdata/data.txt", 1)
    if err != nil {
        t.Fatal(err)
    }
    if result != 65204 {
        t.Error("Expected 65204, got", result)
    }
}

下面来看运行该函数需要多长时间。我们的目标是找出该使用多大的缓冲区读取文件。

注:在花时间坠入优化的深渊之前,请明确程序需要进行优化。如果程序已经足够快,满足了响应要求,并且使用的内存量在接受范围之内,那么将时间花在新增功能和修复bug上会更好。业务的需求决定了何为"足够快"和"接受范围之内"。

在 Go 中,基准测试是测试文件中以单词Benchmark开头的函数,它们接受一个类型为*testing.B的参数。这种类型包含了*testing.T的所有功能,以及用于基准测试的额外支持。首先看一个使用 1 字节缓冲区的基准测试:

var blackhole int

func BenchmarkFileLen1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        result, err := FileLen("testdata/data.txt", 1)
        if err != nil {
            b.Fatal(err)
        }
        blackhole = result
    }
}

blackhole 包级变量是有作用的。我们将 FileLen 的结果写入这个包级变量,以确保编译器不会自负到优化掉对 FileLen 的调用,而对基准测试产生破坏。

每个 Go 基准测试都必须有一个循环,从 0 迭代到 b.N。测试框架会一遍又一遍地调用我们的基准测试函数,每次传递更大的 N 值,直到确保时间结果准确为止。马上会在输出中看到这一点。

我们通过向go test传递-bench标记来运行基准测试。该标记接收一个正则表达式来描述要运行的基准测试名称。使用-bench=.来运行所有基准测试。第二个标记-benchmem在基准测试输出中包含内存分配信息。所有测试在基准测试之前运行,因此只有在测试通过时才能对代码进行基准测试。

以下是运行基准测试我电脑上的输出:

BenchmarkFileLen1-12  25  47201025 ns/op  65342 B/op  65208 allocs/op

运行含内存分配信息的基准测试输出有5列。分别如下:

  • BenchmarkFileLen1-12

    基准测试的名称,中间杠,加用于测试的GOMAXPROCS的值。

  • 25

    产生稳定输出运行测试的次数。

  • 47201025 ns/op

    该基准测试运行单次通过的时间,单位是纳秒(1秒为1,000,000,000纳秒)。

  • 65342 B/op

    基准测试单次通过所分配的字节数。

  • 65208 allocs/op

    基准测试单次通过堆上分配字节的次数。其值小于等于字节的分配数。

我们已经得到1字节缓冲的结果,下面来看使用其它大小缓冲所得到的结果:

func BenchmarkFileLen(b *testing.B) {
    for _, v := range []int{1, 10, 100, 1000, 10000, 100000} {
        b.Run(fmt.Sprintf("FileLen-%d", v), func(b *testing.B) {
            for i := 0; i < b.N; i++ {
                result, err := FileLen("testdata/data.txt", v)
                if err != nil {
                    b.Fatal(err)
                }
                blackhole = result
            }
        })
    }
}

和使用t.Run启动表格测试类似,我们使用b.Run启动不同输入的基准测试。作者电脑上的结果如下:

BenchmarkFileLen/FileLen-1-12          25  47828842 ns/op   65342 B/op  65208 allocs/op
BenchmarkFileLen/FileLen-10-12        230   5136839 ns/op  104488 B/op   6525 allocs/op
BenchmarkFileLen/FileLen-100-12      2246    509619 ns/op   73384 B/op    657 allocs/op
BenchmarkFileLen/FileLen-1000-12    16491     71281 ns/op   68744 B/op     70 allocs/op
BenchmarkFileLen/FileLen-10000-12   42468     26600 ns/op   82056 B/op     11 allocs/op
BenchmarkFileLen/FileLen-100000-12  36700     30473 ns/op  213128 B/op      5 allocs/op

结果符合预期;随着缓冲区大小的增加,分配次数减少,代码运行速度更快,直至缓冲区大于文件的大小。当缓冲区大于文件大小时,会有额外的分配导致输出减慢。如果我们预期文件大致是这个大小,那么10,000 字节的缓冲区效果最佳。

但是有一个改动可以进一步提高性能。现在每次从文件获取下一组字节时都重新分配缓冲区。这是没必要的。如果我们在循环之前进行字节切片分配,然后重新运行基准测试,会看到提升:

BenchmarkFileLen/FileLen-1-12          25  46167597 ns/op     137 B/op  4 allocs/op
BenchmarkFileLen/FileLen-10-12        261   4592019 ns/op     152 B/op  4 allocs/op
BenchmarkFileLen/FileLen-100-12      2518    478838 ns/op     248 B/op  4 allocs/op
BenchmarkFileLen/FileLen-1000-12    20059     60150 ns/op    1160 B/op  4 allocs/op
BenchmarkFileLen/FileLen-10000-12   62992     19000 ns/op   10376 B/op  4 allocs/op
BenchmarkFileLen/FileLen-100000-12  51928     21275 ns/op  106632 B/op  4 allocs/op

现在分配的次数相同且较小,每个缓冲区大小仅需四次分配。有意思的是,我们现在可以作出权衡。如果内存紧张,可以使用较小的缓冲区大小,在牺牲性能的情况下节约内存。

Go代码性能调优

如果基准测试显示存在性能或内存问题,下一步是确定问题的具体原因。Go 包含了分析工具,可从正在运行的程序中收集 CPU 和内存使用数据,还有用于可视化和解释生成的数据的工具。甚至可以暴露一个 Web 服务端点,远程从运行的 Go 服务中收集分析信息。

讨论性能调优工具不在我们的范畴。线上有许多很好的资源提供相关信息。一个不错的起点是 Julia Evans 的博文使用 pprof 对 Go 程序做性能分析