Go 语言原生提供了测试框架,支持单元测试、性能测试等等,这篇文章将学习 Go 的测试框架,同时还会介绍 Go 内置的工具链。
测试
单元测试
单元测试(unit test)除用来测试逻辑算法是否符合预期之外,还承担着监控代码质量的责任。任何时候都可以用简单的命令来验证全部功能,并找出任何因修改而引入的错误。它于性能测试、代码覆盖率等等一起保障了代码总是在可控范围内。其实写单元测试本身就是对即将需要实现的算法做复核预演。
工具链和标准库自带单元测试框架,这使得测试工作相对容易:
- 测试代码放到当前包以
_test.go
结尾的文件中 - 测试函数以 Test 为名称前缀
- 测试命令以(go test)忽略以
_
或.
开头的测试文件 - 正常编译操作(go build/install)会忽略测试文件
1 | package main |
1 | $ go test -v |
标准库 testing 提供了专用类型 T 来控制测试结果和行为。使用 Parallel 可有效利用多核并行优势,缩短测试时间。
1 | package main |
1 | go test -v |
可以看到只有一个测试函数调用 Parallel 方法并没有效果,且 go test 执行参数 parallel
必须大于 1(默认为 GOMAXPROCS)。
单元测试代码一样要写的简洁优雅,很多时候,可以用一种类似于数据表的模式来批量输入条件并依次对比结果。这种方式将测试数据和测试逻辑分离,更便于维护。
某些时候,需要为测试用例提供初始化和清理操作,但是 testing 并没有 setup/teardown 机制。解决方法是自定义一个名为 TestMain 函数,go test 会改为执行该函数,而不再是具体的测试用例。
1 | func TestMain(m *testing.M) { |
M.run 会调用具体的测试用例,但麻烦的是不能为每个测试文件写一个 TestMain。要实现用例组合套件,需要借助 MainStart 自行构建 M 对象。通过与命令行参数相配合,即可实现不同测试组合。
接下来介绍 Example,Example 代码最大的用处不是测试,而是导入到 GoDoc 等工具生成的帮助文档中。它通过对比输出结果和内部 output 注释是否一致来判断是否成功。如果没有 output
注释,那么该示例函数就不会被执行。
1 | func ExampleAdd(t *testing.T) { |
性能测试
性能测试函数以 Benchmark 为名称前缀,同样保存在 *_test.go
文件中。
1 | package main |
测试工具默认不会执行性能测试,需要使用 bench 参数。它通过逐步调整 B.N 值,反复执行测试函数,直到能获取到准确的测试结果。如果希望仅执行性能测试,可以使用 run=None
忽略掉所有单元测试用例。默认就是以并发方式执行测试,但是可以用 cpu 参数来设定多个并发限制来观察结果。
1 | $ go test -bench . |
性能测试关心的不仅仅是执行时间,而且还包括在堆上的内存分配。因为内存分配和垃圾回收的相关操作也应该计入消耗成本。
1 | package main |
1 | go test -bench Heap -benchmem --gcflags "-N -l" |
代码覆盖率
如果说单元测试和性能测试关注代码质量,那么代码覆盖率(code coverage)就是度量测试自身完整和有效性的一种手段。通过覆盖率值,可以分析出测试代码的编写质量,检测它是否提供了足够的测试条件,量化测试本身,让白盒测试真正起到应有的质量保障作用。但是这并不意味着要追求形式上的数字百分比,代码覆盖率为改进测试提供了一个可发现缺陷的机会。只有测试本身的质量得到保障,才能让它免于成为形式主义的摆设。
1 | go test -run "Add" -cover |
为了获取更详细的信息,可以指定 covermode
和 coverprofile
参数。
性能监控
引发性能问题的原因无外乎执行时间过长、内存占用过多,以及意外阻塞。通过捕获或者监控相关执行状态数据,就可以定位引发问题的原因,从而有针对性地改进算法。有两种捕获方式:
- 在测试时输出并保存相关数据,进行初期评估。
- 在运行阶段通过 Web 接口获取实时数据,分析一段时间内的健康状况。
另外我们可以使用自定义计数器(expvar)提供更多与逻辑相关的参考数据。
例如直接对现有的标准库 net/http
进行 benchmark:
1 | go test -run None -bench . -memprofile mem.out -cpuprofile cpu.out net/http |
所支持的采样数据包括:
- -cpuprofile:保存执行时间采样到指定文件
- -memprofile:保存内存分配采样到指定文件
- -memprofilerate:内存分配采样起始值
- -blockprofile:保存阻塞时间采样到指定文件
- -blockprofilerate:阻塞时间采样起始值
可以使用交互模式查看,或者用命令行直接输出单向结果:
1 | go tool pprof cpu.out mem.out |
在线采集检测数据需要注入 http/pprof
包,用流量器访问指定路径,就可以看到不同的监控项。
工具链
Go 安装
我们可以选择源码安装 Go,也可以选择从官网上下载对应系统的二进制包来安装 Go。Go 已经实现了自举(即用 Go 语言编写的编译器),为了从源码安装 Go,首先需要安装 GO 编译器。具体源码安装步骤,可以参考 https://go.dev/doc/install/source。
这里重点介绍如何从二级制安装 Go:
1 | 1. 根据自己的系统下载最新的 Go 二级制版本 |
接下来我们简单测试 Go 的使用:
- 查看 GOPATH 的值
1 | # go env GOPATH |
如果没有设置,可以自行设置该环境变量,例如设置
1 | # echo "export GOPATH=/root/go" >> .bash_profile |
- 进入工作空间源码目录
1 | mkdir -p $GOPATH/src && cd $GOPATH/src |
- 创建用于测试的包目录
1 | mkdir demo && cd demo |
- 创建测试源文件
hello.go
1 | package main |
- 测试
1 | # go run hello.go |
工具
接下来介绍常用内置工具的使用方法:
go build
:该命令默认每次都会重新编译除标准库以外的所有依赖包。该命令可以通过-gcflags
可以指定编译器参数,通过-ldflags
来指定链接器参数go install
:和 build 参数相同,但会将编译结果安装到 bin、pkg 目录。go install
支持增量编译,在没有修改的情况下,会直接链接 pkg 目录中的静态包go get
:将第三方包下载到 GOPATH 列表的第一个工作空间。go env
:显示全部或指定环境参数go clean
:清理工作目录,删除编译和安装遗留的目标文件
编译
编译不仅仅是执行 go build
命令,还有一些额外的注意内容:
- 如果习惯使用 GDB 调试器,建议编译时加上
-gcflags "-N -l"
参数阻止优化和内联,否则调试时会有各种找不到的情况。但是发布时,参数-ldflags "-w -s"
会让链接器剔除符号表和调试信息 - 交叉编译:所谓交叉编译,是指在一个平台上编译出其他平台所需的可执行文件。Go 实现自举后,交叉编译更为方便,只需要使用 GOOS、GOARCH 环境变量指定目标平台和架构就行。此时还可以使用
go install
命令为目标平台预编译好标准库,避免每次go build
都必须完整编译 - 条件编译:除在代码中使用 runtime.GOOS 进行判断外,编译器本身就支持文件级别的条件编译。将平台和架构信息添加到文件名尾部,编译器会选择对应的源码文件进行编译。另外代码中的 build 编译指令告诉编译器:当前源码文件只能用于指定环境。除了预定义 build 指令外,也可以通过命令行 tags 参数传递自定义指令。
- 预处理:即利用
go generate
命令扫描源文件,找出所有的go:generate
注释,提取其中的命令并执行。预处理设计的初衷是为包的开发者准备的,可以用其完成一些自动处理命令。除此之外,还可以用来完成基于模板生成代码(类似泛型的功能)或者将资源文件转换成源码等工作- 命令必须放在
.go
源文件中 - 命令必须以
//go:generate
开头 - 每个文件可以有多条 generate 命令
- 命令支持环境变量
- 必须显式执行
go generate
命令 - 按文件名顺序提取命令并执行
- 串行执行,出错后终止后续命令的执行
- 命令必须放在