大家好,我是七牛云视频业务保障负责人李意扬。

开始之前,我先打一个广告:七牛云是一家提供「云+数据」服务的一体化 PaaS 平台,除了 CDN 和存储业务之外,还有 RTC、直播、机器数据分析等业务。大家如果有这方面的需求,可以尝试用一下我们七牛云的产品。

我今天分享的题目是「如何收集 Go 的实时覆盖率」,我先给出我们七牛云的答案:做一个 Go 版的 JaCoCo。

当前业界没有完善的 Go 集成测试覆盖率解决方案,为此我们开发了一个工具,就是 Goc!今天我会介绍下 Goc 的使用方法,简单分享它的原理,最后再带大家看一下借助 Goc 可以做哪些有趣的事情。

 Go 单测覆盖率

Go 官方提供了单测覆盖率收集的方法。举个例子,有两个文件,被测业务代码 kcp.go 和单测代码 kcp-test.go ,我们用 go test 工具运行单测,打开 -cover 选项就可以收集到单测覆盖率,再开启 -coverprofile 选项,就可以将覆盖率报告输出到本地。

如何解读这个覆盖率报告?Go 不是对每一行去统计,而是把整个代码拆分成了一个个语句块。覆盖率报告的每一行开头表示该语句块所在的文件名,接着是语句块的起始行和起始列,然后是语句块的结束行、结束列,倒数第二列为语句块内的总行数,最后一列为该语句块在这次测试中被执行到的次数。结合所有的语句数量和执行次数便可计算出整个代码的覆盖率了。

但测试不仅仅只有单测,测试同学平时接触最多的反而是手工、集测、系统和自动化测试,对于这些测试手段与被测程序分离、处于不同二进制的情况,需要新的覆盖率收集方法。

 Goc vs 传统的集测覆盖率收集

目前业界的覆盖率收集方案如下:

1. 首先在原工程中新增单测代码,添加测试用例 TestMainStart,在该用例最后调用被测业务 main。

func TestMainStart(t *teing.T){ van args[]string for _, arg := range os.Args{ if !strings.HasPrefix(arg, "-test"){ args = append(args,arg) } } os.Angs = args main()}

2. 然后使用 go test -c 选项将测试用例编译成可执行文件 cover.test。

→  go test -coverpkg="./..." -c -o cover.test

3. 最后用 -cover.run 指定运行我们封装过的测试用例 TestMainStart。

→  ./cover.test -test.run "TestMainStart" -test.coverprofile=cover.out

cover.test 运行起来后,我们就可以进行各种手工、集测、系统和自动化测试了。由于 cover.test 这个二进制本质上是一个 go test 的单测代码,所以当程序退出后会自动输出覆盖率报告,我们也就能得到被测业务的集测覆盖率了。


但该方案有很多缺点:

1. 工程中需要新增测试代码,如果工程中有多个 main 函数,就得为每一个 main 函数编写测试用例。

2. 改变了程序启动的方式,必须加上 -test.run 和 -test.coverprofile 两个参数。

3. 被测程序退出后才能得到覆盖率报告,无法获取实时覆盖率。

4. 覆盖率报告只能输出到本地,需要编写脚本集中收集。

5. 分布式微服务架构下,需要对每个服务、多份报告合并才能得到整个系统的覆盖率。

让我们看看 Goc 如何解决这些缺点:

1. 如果原工程是用 go build 命令编译的,那么现在只需替换为 goc build 命令编译,即可得到一个支持覆盖率收集的可执行文件。原工程代码无须做任何改动。

2. 使用 Goc 编译出的二进制,不会破坏原程序的启动方式。

3. 被测程序不再需要退出就能收集覆盖率。如下面动图所示。左侧是一个被测的 HTTP 服务。右侧是用 goc 命令获取的实时覆盖率。上文介绍了报告最后一列代表了语句块执行的次数,当我用 curl 命令访问了被测 HTTP 服务后,可以看到部分语句块执行次数从 0 变成了 1,整个测试过程被测业务正常运行。

4. 覆盖率报告统一收集、自动合并。如下动图所示,左边是两个被测的 HTTP 服务,一个监听在 5000 端口,另一个监听在 5001 端口。首先访问其中 5000 端口的服务,可以看到部分语句块执行次数从 0 变到了 1。然后访问 5001 端口的服务,语句块执行次数从 1 变成了 2。Goc 所展示的覆盖率报告不再是单个服务的,而是把被测系统看作了一个整体。另外动图中还展示了 Goc 实时清除/重置覆盖率的能力,测试人员可以结合该能力对某个功能反复测试。


Goc 的原理

接下来我们简单介绍一下 Goc 的原理。

回顾语句覆盖率的定义:

测试时运行被测程序后,程序中被执行到可知性语句的比率。

从这个定义出发我们有一个朴素的想法,能不能在每一行运行完后加入一些统计计算的逻辑去统计覆盖率呢?

一些语言正是这样做的,比如 Python。Python 是带解释器的动态语言,标准库提供了方法 sys.settrace 给解释器设置一个回调函数,当解释器每执行一行代码就会调用一次回调函数,传入当前行的行号,最后统计所有执行到的行数,便可得到覆盖率。

C++ 没有解释器,所以只能在运行前往代码中插入技术器。但 C++ 认为每一行统计非常低效,对性能损失很大。所以,它把代码分成了很多的基本块,在基本块之间的跳转处插入一个计数器,等待程序结束之后统计这些计数器的结果,便可得到全局的覆盖率。

Java 的做法和 C++ 非常类似,但没有在汇编层做插桩,而是在字节码处插桩。Java 虚拟机提供了动态改变字节码的能力,Jacoco 在跳转处插入探针 Probe 做覆盖率收集。

 我们回到 Go,看一下 go test 单元测试怎么收集覆盖率的。Go 也是把代码分成了很多语句块。比如说这段代码就分成了三块:

 但和 C++、Java 在块之间跳转处插桩不同, Go 在语句块中插入了计数器:

上述计数器的定义如下:

var GoCover = struct { Count [3]uint32 Pos [3 * 3]uint32 NumStmt [3]uint16} { Pos: [3 * 3]uint32{ 5, 7, 0xc000d, // [0] 7, 9, 0x3000c, // [1] 9, 11, 0x30008, // [2] } NumStmt: [3]uint16{ 2, // 0 1, // 1 1, // 2 },}

Count 这个字段代表计数器,Pos 就是代表了语句块起始行、起始列、结束行和结束列,NumStmt 代表了语句块内的语句数。

这个方案是源码级的插桩,所以势必会破坏源码。Goc 首先会把整个工程拷贝到临时目录。

接着使用标准库 ast/parser 解析出语法树,在每个语句块中插入计数器写入临时目录,最后调用原生的 go build/install/run 命令去编译插过桩的代码。

除了计数器,我们还为每个 main 包注入了一个 HTTP API。调用暴露的 HTTP 接口会计算聚合每个计数器,并返回服务当前的覆盖率。插桩服务启动后,首先会访问 goc server 这个注册中心,上报自己 ip 地址和 HTTP API 端口。goc server 注册中心存储了每个被测服务的信息。

客户端向 goc server 注册中心获取覆盖率报告时,中心会拉取所有服务的覆盖率并合并成单份报告,然后返回给客户端。

在实际公测中,有用户反馈在云原生场景下集成 goc 非常不便,比如 docker/k8s 启动服务必须加 -p 参数,将外部的端口映射到内部的端口,才能让注册中心 goc server 访问到被插桩的服务。这个问题的本质是因为 goc server 注册中心和被插桩服务分属不同网络,它们通过 NAT 实现网络转换。

Goc 在 v2 版本给出了改进方案,v2 将所有内部通信构建在了 websocket 长连接之上,整个覆盖率收集系统只需保证 goc server 能被访问即可,对测试系统的部署侵入降到了最低,更适合云原生业务的测试。

 Goc 扩展

由于 Goc 输出的覆盖率报告和 Go 官方的单测覆盖率报告完全一致。一些已有的分析单测覆盖率的系统可以直接用来分析 Goc 收集的集测覆盖率,比如说 SonarQube、Codecov、Covralls。

借助 Goc 覆盖率实时收集的能力还可以做精准测试。这里有两个方案:

1. Goc v1 版中,cmd: goc profile 轮询获取全量覆盖率

2. Goc v2 版中,cmd: goc watch 实时推送增量覆盖率,下图展示的是实时推送的增量覆盖率

 最后再展示我们实现的一个 VS Code 插件 demo,动图中随着访问不同的 HTTP API 接口,代码相应的位置被高亮了起来,研发和测试同学可以基于该插件实现精准的白盒测试。

上述 VS Code 插件使用的公开接口如下表所示。

Goc v1 接口

功能

GET /v1/cover/profile

获取覆盖率

POST /v1/cover/clear

重置覆盖率

GET /v1/cover/list

获取服务列表

(仅服务名、ip)

POST /v1/cover/remove

删除服务

Goc v2 接口

功能

GET /v2/cover/profile

获取覆盖率

DELETE /v2/cover/profile

重置覆盖率

GET /v2/agents

获取服务列表

(服务名、ip、服务在线状态)

DELETE /v2/agents

删除服务

ws://xxx/v2/cover/ws/watch

实时推送覆盖率