作者| 阿里云智能事业群高级测试开发工程师 刘璐
最初,PouchContainer 结合 TravisCI 与 Codecov 工具,为每次 PR 提交运行测试并展示单元测试覆盖率。对于一些添加集成测试的 PR,集成测试的增减所带来的测试覆盖率变化并没有纳入到测试覆盖率的统计中。
集成测试覆盖率的缺失,使得开发者缺少对项目测试覆盖率的更完整认知。为了更全面的展示 PouchContainer 的测试覆盖率,现在 PouchContainer 已经加入了集成测试覆盖率的统计功能。本文主要介绍集成测试覆盖率统计在 PouchContainer 中的实现。
Go 测试覆盖率
在介绍集成测试覆盖率统计实现之前,我们需要了解 Golang 的覆盖率统计的原理。Golang 的覆盖率统计,是通过在编译之前重写包的源代码,加入统计信息,然后编译、运行、收集测试覆盖率。有关 Go 测试覆盖率的原理可参考 The cover story (https://blog.golang.org/cover),接下来的内容,主要参考上述文章,并具体列出执行过程。
首先,给出一个待测 Size() 函数,它有多个 switch 分支,代码如下:
package size
func Size(a int) string {
switch {
case a < 0:
return "negative"
case a == 0:
return "zero"
case a < 10:
return "small"
}
return "enormous"
}
对应的测试代码如下:
$ cat size_test.go
package size
import (
"testing"
"fmt"
)
type Test struct {
in int
out string
}
var tests = []Test{
{-1, "negative"},
{5, "small"},
}
func TestSize(t *testing.T) {
fmt.Println("a")
for i, test := range tests {
size := Size(test.in)
if size != test.out {
t.Errorf("#%d: Size(%d)=%s; want %s", i, test.in, size, test.out)
}
}
}
执行 go test -x -cover -coverprofile=./size.out
命令,运行测试并统计测试覆盖率。其中,-x
参数打印上述命令的执行过程(需注意:打印的执行步骤信息不完整,如果手动执行输出的步骤,则会运行失败,这是因为 go test 的一些执行步骤并没有打印信息),-cover
参数开启测试覆盖率统计功能,-coverprofile
参数指定存储测试覆盖率文件,运行结果如下:
$ go test -x -cover -coverprofile=./size.out
WORK=/var/folders/d2/0gxc6wf16hb6t8ng0w00czpm0000gn/T/go-build982568783
mkdir -p $WORK/test/_test/
mkdir -p $WORK/test/_test/_obj_test/
cd $WORK/test/_test/_obj_test/
/usr/local/go/pkg/tool/darwin_amd64/cover -mode set -var GoCover_0 -o .size.go /Users/letty/work/code/go/src/test/size.go
cd /Users/letty/work/code/go/src/test
/usr/local/go/pkg/tool/darwin_amd64/compile -o $WORK/test/_test/test.a -trimpath $WORK -p test -complete -buildid 6033df309978241f19d83a0e6bad252ee3ba376e -D _/Users/letty/work/code/go/src/test -I $WORK -pack $WORK/test/_test/_obj_test/size.go ./size_test.go
cd $WORK/test/_test
/usr/local/go/pkg/tool/darwin_amd64/compile -o ./main.a -trimpath $WORK -p main -complete -D "" -I . -I $WORK -pack ./_testmain.go
cd .
/usr/local/go/pkg/tool/darwin_amd64/link -o $WORK/test/_test/test.test -L $WORK/test/_test -L $WORK -w -extld=clang -buildmode=exe $WORK/test/_test/main.a
$WORK/test/_test/test.test -test.coverprofile=./size.out -test.outputdir /Users/letty/work/code/go/src/test
a
PASS
coverage: 60.0% of statements
ok test 0.006s
从上述输出的倒数第二行可知,测试覆盖率为 60%。分析 go test
的执行步骤,第五行调用 /usr/local/go/pkg/tool/darwin_amd64/cover
工具,这个工具重写待测源码,在代码中加入计数点,用以统计测试覆盖率。第 8-13 行编译待测文件和 _testmain.go
文件(这个文件是 go test
工具生成的,具体实现细节可以参见https://github.com/golang/go/blob/3f150934e274f9ce167e1ed565fb3e60b8ea8223/src/cmd/go/internal/test/test.go#L1887),生成 test.test
测试执行文件。第 13 行,执行 test.test
测试文件,传入测试相关参数,即可运行测试。
查看 cover
命令的帮助信息,再次执行 cover
命令,可以查看被重写后的测试代码:
$ cat .size.go
package size
func Size(a int) string {
GoCover_0.Count[0] = 1
switch {
case a < 0:
GoCover_0.Count[2] = 1
return "negative"
case a == 0:
GoCover_0.Count[3] = 1
return "zero"
case a < 10:
GoCover_0.Count[4] = 1
return "small"
}
GoCover_0.Count[1] = 1
return "enormous"
}
var GoCover_0 = struct {
Count [5]uint32
Pos [3 * 5]uint32
NumStmt [5]uint16
} {
Pos: [3 * 5]uint32{
3, 4, 0x9001a, // [0]
12, 12, 0x130002, // [1]
5, 6, 0x14000d, // [2]
7, 8, 0x10000e, // [3]
9, 10, 0x11000e, // [4]
},
NumStmt: [5]uint16{
1, // 0
1, // 1
1, // 2
1, // 3
1, // 4
},
}
查看 go test
运行测试后的覆盖率统计文件,信息如下:
$ cat size.out
mode: set
test/size.go:3.26,4.9 1 1
test/size.go:12.2,12.19 1 0
test/size.go:5.13,6.20 1 1
test/size.go:7.14,8.16 1 0
test/size.go:9.14,10.17 1 1
文件的第一行标识覆盖率统计模式为 set
,go test
提供 set、count、atomic 三种模式:
set
模式仅统计语句是否运行;
count
模式统计语句运行的次数;
atomic
模式与 count
类似,统计语句运行次数,适用于多线程测试。
第二行开始的格式为:name.go:line.column,line.column numberOfStatements count
,即文件名、代码的起始位置、语句的行数以及被运行的次数。本次示例代码中,待统计的语句共 5 行,统计模式为 set
,共有 3 个 count 被置为 1(读者可以将 covermode 设置为 count,观察 count 输出有何变化),所以最终的测试覆盖率结果为 60%。
PouchContainer 测试覆盖率
PouchContainer 集成 CodeCov 工具,每次运行 TravisCI 会将测试覆盖率文件上传至 CodeCov 网站,完成覆盖率的可视化展示与持续追踪。
TravisCI 与 CodeCov 可以很容易的集成,只需在测试路径下生成一个 coverage.txt 名字的覆盖率统计文件,并在 .tarvis.yml
文件中调用 CodeCov 的脚本,即可上传覆盖率统计文件,具体命令可以参考 Makefile 中 TEST_FLAGS= make build-integration-test 里面的实现,感兴趣的同学也可以直接查看 CodeCov 脚本,了解其实现细节。
接下来,我们从单测和集成测试覆盖率统计两方面展开,详细阐述 PouchContainer 的实现细节。
make unit-test
命令,即可实现覆盖率统计收集。单测覆盖率统计的实现可以可以参考 Makefile。需要注意的是,覆盖率统计时需要排除一些无关 package,例如 vendor 目录、types 目录等,否则会影响测试覆盖率的准确性。go build
编译,源码中没有插入计数器,无法统计测试覆盖率。实现统计 pouch daemon 的测试覆盖率的 PR 参见https://github.com/alibaba/pouch/pull/1338),这个 PR(由于代码的不断迭代,最新的代码位置已改变,请读者参照本文所对应的 commit 代码)中,我们做了如下工作:
根目录下新增 main_test.go 测试文件
hack/build 脚本中,新增 testserver 函数用于编译 main package,生成可执行测试文件
hack/make.sh 脚本中,后台启动步骤 2 生成的测试文件,并运行 API 和命令行测试
测试结束后,给测试进程发送信号,并收集测试覆盖率
接下来将详细讲述实现细节,首先,新增 main_test.go 测试文件,并在文件中定义一个测试函数 TestMain
,代码如下:
package main
import (
"os"
"os/signal"
"strings"
"syscall"
"testing"
)
func TestMain(t *testing.T) {
var (
args []string
)
for _, arg := range os.Args {
switch {
case strings.HasPrefix(arg, "DEVEL"):
case strings.HasPrefix(arg, "-test"):
default:
args = append(args, arg)
}
}
waitCh := make(chan int, 1)
os.Args = args
go func() {
main()
close(waitCh)
}()
signalCh := make(chan os.Signal, 1)
signal.Notify(signalCh, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM, syscall.SIGHUP)
select {
case <-signalCh:
return
case <-waitCh:
return
}
}
通过添加 main_test.go
文件,可以使我们使用现有的 go test
工具编译 pouch daemon
,当运行如下命令时,go test
将编译当前路径下以 _test
结尾的文件所属的 package,即我们需要的 main
package,然后链接到 go test
提供的测试主程序中(即前面提到的 _testmain.go
文件),生成测试可执行文件:
# go test -c -race -cover -covermode=atomic -o pouchd-test -coverpkg $pkgs
其中 \$pkg 指定需要统计测试覆盖率的包名,go test
调用 cover
工具对指定的 package 源码重写,加入测试覆盖率计数器;-o
参数指示仅编译不运行,且指定测试二进制名为 pouchd-test
。执行上述命令后,即可得到一个调用 main()
函数的测试二进制文件。
第三步,启动 pouch-test
运行测试代码,由于测试代码中调用 pouch daemon
的入口 main()
函数,即可达到启动 pouch daemon
并提供服务的目的。具体命令如下:
# pouchd-test -test.coverprofile=$DIR/integrationcover.out DEVEL --debug
其中,-test
前缀的参数由 go test
处理,DEVEL
之后的参数,则会传递给 main()
函数。此时,正常执行测试用例,测试结束后杀掉 pouchd-test
进程,go test
工具会打印出测试覆盖率,并生成覆盖率文件,完成集成测试覆盖率的统计。
从上述步骤可以看到,统计集成测试覆盖率的主要工作在于提供一个 main_test.go
文件,接下来我们分析一下这个文件做了哪些工作。
首先,文件中定义了一个测试函数 TestMain()
,这是入口函数,执行测试可执行文件时,会调用这个函数。
函数中 16-27 行进行了参数处理,过滤 -test
开头以及 DEVEL
参数,并将余下参数全部赋值给 os.Args
。这是因为 go test
默认将第一个非破折号 -
开头的参数,交由测试函数处理,main_test.go
代码中,过滤参数并重新赋值 os.Args
,将参数传给 main()
函数,使得我们可以如常使用 daemon 参数。
第 28-31 行调用 main 函数,启动 daemon 服务。第 33-40 行,接收指定信号并直接退出。注意,我们还定义了一个 waitCh channel
,用于 main
函数退出时,通知测试函数退出,以防止出现 main
函数调用自身而其引起的程序永不退出问题。
有关集成测试覆盖率统计的实现方法,还可以参考这篇文章 《Generating Coverage Profiles for Golang Integration Tests》(https://www.cyphar.com/blog/post/20170412-golang-integration-coverage)。
结语
集成测试覆盖率的统计,需要灵活运用 Golang 提供的工具,并根据自身项目代码特点适配测试文件。加入集成测试覆盖率统计后,PouchContainer 的覆盖率从仅统计单测时的 18% 提升至 60%,这将更准确展示测试现状。