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

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

检测代码覆盖率

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

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

ini 复制代码
$ go test -v -cover -coverprofile=c.out

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

ini 复制代码
$ go tool cover -html=c.out

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

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

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

arduino 复制代码
{"bad_op", 2, 2, "?", 0, `unknown operator ?`},

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

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

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

arduino 复制代码
{"another_mult", 2, 3, "*", 6, ""},

可以看到如下错误:

go 复制代码
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目录下的函数:

go 复制代码
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
}

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

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

go 复制代码
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 字节缓冲区的基准测试:

css 复制代码
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在基准测试输出中包含内存分配信息。所有测试在基准测试之前运行,因此只有在测试通过时才能对代码进行基准测试。

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

bash 复制代码
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字节缓冲的结果,下面来看使用其它大小缓冲所得到的结果:

css 复制代码
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启动不同输入的基准测试。作者电脑上的结果如下:

bash 复制代码
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 字节的缓冲区效果最佳。

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

bash 复制代码
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 程序做性能分析

相关推荐
UIUV9 小时前
Go语言入门到精通学习笔记
后端·go·编程语言
匀泪12 小时前
云原生(Kubernetes service微服务)
微服务·云原生·kubernetes
littleschemer12 小时前
Go异步持久化如何防止炸服
go·map并发崩溃
倔强的胖蚂蚁13 小时前
Ollama Modelfile 配置文件 全指南
云原生·开源
AutoMQ15 小时前
AWS 新发布的 S3 Files 适合作为 Kafka 的存储吗?
云原生·消息队列·云计算
不会敲代码117 小时前
从零开始学 Go:协程并发与 Web 开发初探
go
扉页的墨18 小时前
Wails v2 实战:用 Go 写桌面应用,从 0 到 1 构建一个本地笔记工具
go
MY_TEUCK19 小时前
从零开始:使用Sealos Devbox快速搭建云原生开发环境
人工智能·spring boot·ai·云原生·aigc
我叫黑大帅19 小时前
从零实现一个完整 RAG 系统:基于 Eino 框架的检索增强生成实战
后端·面试·go
没有口袋啦1 天前
《基于 GitOps 理念的企业级自动化 CI/CD 流水线》
阿里云·ci/cd·云原生·自动化·k8s