0%

Go 语言学习笔记(6):测试与工具链

Go 语言原生提供了测试框架,支持单元测试、性能测试等等,这篇文章将学习 Go 的测试框架,同时还会介绍 Go 内置的工具链。

测试

单元测试

单元测试(unit test)除用来测试逻辑算法是否符合预期之外,还承担着监控代码质量的责任。任何时候都可以用简单的命令来验证全部功能,并找出任何因修改而引入的错误。它于性能测试、代码覆盖率等等一起保障了代码总是在可控范围内。其实写单元测试本身就是对即将需要实现的算法做复核预演。

工具链和标准库自带单元测试框架,这使得测试工作相对容易:

  • 测试代码放到当前包以 _test.go 结尾的文件中
  • 测试函数以 Test 为名称前缀
  • 测试命令以(go test)忽略以 _. 开头的测试文件
  • 正常编译操作(go build/install)会忽略测试文件
1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "testing"

func add(x, y int) int {
return x + y
}

func TestAdd(t *testing.T) {
if add(1, 2) != 3 {
t.FailNow()
}
}
1
2
3
4
5
$ go test -v
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok t1 0.007s

标准库 testing 提供了专用类型 T 来控制测试结果和行为。使用 Parallel 可有效利用多核并行优势,缩短测试时间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"testing"
"time"
"os"
)

func TestA(t *testing.T) {
t.Parallel()
time.Sleep(time.Second * 2)
}

func TestB(t *testing.T) {
if os.Args[len(os.Args) - 1] == "b" {
t.Parallel()
}
time.Sleep(time.Second * 2)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
go test -v
=== RUN TestA
=== PAUSE TestA
=== RUN TestB
--- PASS: TestB (2.00s)
=== CONT TestA
--- PASS: TestA (2.00s)
PASS
ok t1 4.015s

go test -v -args "b"
=== RUN TestA
=== PAUSE TestA
=== RUN TestB
=== PAUSE TestB
=== CONT TestA
=== CONT TestB
--- PASS: TestA (2.00s)
--- PASS: TestB (2.00s)
PASS
ok t1 2.015s

可以看到只有一个测试函数调用 Parallel 方法并没有效果,且 go test 执行参数 parallel 必须大于 1(默认为 GOMAXPROCS)。

单元测试代码一样要写的简洁优雅,很多时候,可以用一种类似于数据表的模式来批量输入条件并依次对比结果。这种方式将测试数据和测试逻辑分离,更便于维护。

某些时候,需要为测试用例提供初始化和清理操作,但是 testing 并没有 setup/teardown 机制。解决方法是自定义一个名为 TestMain 函数,go test 会改为执行该函数,而不再是具体的测试用例。

1
2
3
4
5
6
7
8
9
10
func TestMain(m *testing.M) {
// setup

// 调用具体的测试用例
code := m.Run()

// teardown

os.Exit(code)
}

M.run 会调用具体的测试用例,但麻烦的是不能为每个测试文件写一个 TestMain。要实现用例组合套件,需要借助 MainStart 自行构建 M 对象。通过与命令行参数相配合,即可实现不同测试组合。

接下来介绍 Example,Example 代码最大的用处不是测试,而是导入到 GoDoc 等工具生成的帮助文档中。它通过对比输出结果和内部 output 注释是否一致来判断是否成功。如果没有 output 注释,那么该示例函数就不会被执行。

1
2
3
4
5
6
7
8
func ExampleAdd(t *testing.T) {
fmt.Println(add(1, 2))
fmt.Println(add(2, 2))

//Output:
// 3
// 4
}

性能测试

性能测试函数以 Benchmark 为名称前缀,同样保存在 *_test.go 文件中。

1
2
3
4
5
6
7
8
9
10
11
package main

import (
"testing"
)

func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = add(1, 2)
}
}

测试工具默认不会执行性能测试,需要使用 bench 参数。它通过逐步调整 B.N 值,反复执行测试函数,直到能获取到准确的测试结果。如果希望仅执行性能测试,可以使用 run=None 忽略掉所有单元测试用例。默认就是以并发方式执行测试,但是可以用 cpu 参数来设定多个并发限制来观察结果。

1
2
3
4
5
6
7
$ go test -bench .
goos: darwin
goarch: amd64
pkg: t1
BenchmarkAdd-8 1000000000 0.316 ns/op
PASS
ok t1 4.420s

性能测试关心的不仅仅是执行时间,而且还包括在堆上的内存分配。因为内存分配和垃圾回收的相关操作也应该计入消耗成本。

1
2
3
4
5
6
7
8
9
10
11
package main

import (
"testing"
)

func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = add(1, 2)
}
}
1
2
3
4
5
6
7
go test -bench Heap -benchmem --gcflags "-N -l"
goos: darwin
goarch: amd64
pkg: t1
BenchmarkHeap-8 1112157 1098 ns/op 10240 B/op 1 allocs/op
PASS
ok t1 5.999s

代码覆盖率

如果说单元测试和性能测试关注代码质量,那么代码覆盖率(code coverage)就是度量测试自身完整和有效性的一种手段。通过覆盖率值,可以分析出测试代码的编写质量,检测它是否提供了足够的测试条件,量化测试本身,让白盒测试真正起到应有的质量保障作用。但是这并不意味着要追求形式上的数字百分比,代码覆盖率为改进测试提供了一个可发现缺陷的机会。只有测试本身的质量得到保障,才能让它免于成为形式主义的摆设。

1
2
3
4
go test -run "Add" -cover
PASS
coverage: 100.0% of statements
ok t1 0.008s

为了获取更详细的信息,可以指定 covermodecoverprofile 参数。

性能监控

引发性能问题的原因无外乎执行时间过长、内存占用过多,以及意外阻塞。通过捕获或者监控相关执行状态数据,就可以定位引发问题的原因,从而有针对性地改进算法。有两种捕获方式:

  • 在测试时输出并保存相关数据,进行初期评估。
  • 在运行阶段通过 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
1. 根据自己的系统下载最新的 Go 二级制版本

wget https://go.dev/dl/go1.18.1.linux-amd64.tar.gz

2. 删除系统中已有的 go,并解压安装包

rm -rf /usr/local/go && tar -C /usr/local -xzf go1.18.1.linux-amd64.tar.gz

3. 添加 `/usr/local/go/bin` 到 PATH 环境变量中

export PATH=$PATH:/usr/local/go/bin

4. 验证 Go 版本
# go version
go version go1.18.1 linux/amd64

接下来我们简单测试 Go 的使用:

  • 查看 GOPATH 的值
1
2
# go env GOPATH
/root/go

如果没有设置,可以自行设置该环境变量,例如设置

1
2
# echo "export GOPATH=/root/go" >> .bash_profile
# source ~/.bash_profile
  • 进入工作空间源码目录
1
mkdir -p $GOPATH/src && cd $GOPATH/src
  • 创建用于测试的包目录
1
mkdir demo && cd demo
  • 创建测试源文件 hello.go
1
2
3
4
5
6
7
package main

import "fmt"

func main() {
fmt.Println("hello, world!")
}
  • 测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# go run hello.go
hello, world!

# go mod init hello
# go build
# go install

# cd $GOPATH
# tree
.
|-- bin
| `-- hello
|-- pkg
| `-- mod
| `-- cache
| `-- lock
`-- src
`-- demo
|-- go.mod
`-- hello.go

6 directories, 4 files

工具

接下来介绍常用内置工具的使用方法:

  • 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 命令
    • 按文件名顺序提取命令并执行
    • 串行执行,出错后终止后续命令的执行