在一个流程规范的项目里,如果想发布一行代码到生产环境,都需要完整的测试流程,如果是向外网发布一个功能或版本,那么更需要进行完整的功能测试,其中就包括白盒测试和黑盒测试。尤其是在游戏行业,因为玩法逻辑复杂,很多逻辑很难考虑到,因此覆盖率验证是一个必备的一个流程。比如现在的团队就要求放出的游戏版本的代码覆盖率需要达到100%才算是达到发版标准,因此覆盖率测试在保证代码质量上扮演者重要角色。我们通过覆盖率可以看到我们的代码有哪些是已经跑到的,有哪些是特殊情况绕过了尚未验证,因此通过代码覆盖率我们可以对整个代码逻辑有充分的认识和判断。这篇文章主要介绍如何对Go的代码建立覆盖率,而Go的覆盖率建立依赖go test和go tool cover。
一个简单的项目建覆盖率
首先看下项目组织,一共有两个文件,main.go是我们的实现的程序,main_test.go是我们准备建覆盖率而新建的文件。
.
├── main.go
└── main_test.go
main.c内容如下:
package main
// main.go
import (
"fmt"
)
type Foo struct {
name string
age int
}
func main() {
v := 1
if v == 1 {
//声明初始化
var foo1 Foo
fmt.Printf("foo1 --> %#v\n ", foo1) //main.Foo{age:0, name:""}
foo1.age = 1
fmt.Println(foo1.age)
//struct literal 初始化
foo2 := Foo{}
fmt.Printf("foo2 --> %#v\n ", foo2) //main.Foo{age:0, name:""}
foo2.age = 2
fmt.Println(foo2.age)
//指针初始化
foo3 := &Foo{}
fmt.Printf("foo3 --> %#v\n ", foo3) //&main.Foo{age:0, name:""}
foo3.age = 3
fmt.Println(foo3.age)
} else {
//new 初始化
foo4 := new(Foo)
fmt.Printf("foo4 --> %#v\n ", foo4) //&main.Foo{age:0, name:""}
foo4.age = 4
fmt.Println(foo4.age)
//声明指针并用 new 初始化
var foo5 *Foo = new(Foo)
fmt.Printf("foo5 --> %#v\n ", foo5) //&main.Foo{age:0, name:""}
foo5.age = 5
fmt.Println(foo5.age)
}
}
test_main.c内容如下,实现了一个TestSystem的函数,里面调用了main()函数。
package main
import (
"testing"
)
func TestSystem(t *testing.T) {
main()
}
执行指令生成go.mod,我们生成了名为example的module。
go mod init example
此时的项目结构如下
.
├── go.mod
├── main.go
└── main_test.go
接下来对使用go test建立代码覆盖率,输出文件指定为coverage.out
junshideMacBook-Pro:gogogo junshili$ go test -coverprofile=coverage.out
foo1 --> main.Foo{name:"", age:0}
1
foo2 --> main.Foo{name:"", age:0}
2
foo3 --> &main.Foo{name:"", age:0}
3
PASS
coverage: 63.6% of statements
ok example 0.287s
从上面的输出可以看出,这次代码测试的覆盖率为63.6%,耗时0.287s。
此时当前目录生成了新文件coverage.out,打开看一下里面记录了什么
junshideMacBook-Pro:gogogo junshili$ cat coverage.out
mode: set
example/main.go:14.13,17.12 2 1
example/main.go:17.12,35.3 12 1
example/main.go:35.8,47.3 8 0
coverage.out的可读性比较差,我们很难从其内容分析出覆盖率,因此可以使用go tool生成html,以可视化界面呈现代码覆盖率情况。
go tool cover -html=coverage.out
指令执行后会自动从终端跳转到网页打开,内容呈现如下:
红色表示需要跑到的代码但是还没跑到(如else 分支),绿色代表这一行代码已经被跑到了(v == 1分支),灰色表示这些代码无需关注(结构体定义,import等),不属于覆盖率范围内,可以忽略。
带子目录的项目建覆盖率
上面的例子只含有一个main.go的文件,实际的项目不可能只有一个main文件。一个比较完整的项目中会含有多个子目录,代表各种业务功能模块。大多时候我们都是对于某个子目录下进行修改,因此我们更关注如何对指定目录下的代码进行覆盖率检查。
我们的项目组织是这样的,main.go在项目跟目录下,gotest是我们这次新增的代码,我们需要关注这些新加的代码,而main.go作为一直存在的框架代码,无需再跑覆盖率了。
.
├── go.mod
├── gotest
│ ├── add.go
│ └── divide.go
├── main.go
└── main_test.go
项目代码如下,main.go中或根据一定条件选择调用gotest.Add还是gotest.Division。
main.go
package main
// main.go
import (
"fmt"
"example/gotest"
)
func main() {
v := 1
if v == 1 {
x,_ := gotest.Add(1.0, 2.0)
fmt.Println("gotest.Add:", x);
} else {
x,_ := gotest.Division(2.0, 1.0)
fmt.Println("gotest.Division:", x);
}
}
gotest/add.go
package gotest
import (
"errors"
)
func Add(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("除数不能为0")
}
return a + b, nil
}
gotest/divide.go
package gotest
import (
"errors"
)
func Division(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("除数不能为0")
}
return a / b, nil
}
而main_test.go我们无需修改,跟第一个例子的一样。
先执行go test指令生成二进制文件,注意指令参数是-coverpkg example/gotest
,指定了需要进行go test的代码目录,如果是指定多个目录,需要用”,”分隔符分隔。
go test -complete -c -covermode=count -coverpkg example/gotest -o example
执行后生成二进制文件example,启动时带上参数,指定输出覆盖率文件coverage.cov
junshideMacBook-Pro:gogo junshili$ ./example -test.coverprofile coverage.cov
gotest.Add: 3
PASS
coverage: 33.3% of statements in example/gotest
使用go tool渲染覆盖率文件,覆盖率以html的形式呈现
go tool cover -html=coverage.cov
可以看到,gotest的两个文件都生成了覆盖率,add.go跑了66.7%的覆盖率,divison.go跑了0%覆盖率。这是因为main.go中只调用了add.go,divide.go还没调用。
阻塞类函数建立覆盖率
如果你的main.go是一直阻塞或者循环等待,如果想建立覆盖率那必须保证main正常退出,ctrl-c kill掉进程是不会输出覆盖率文件的,对于这种模式的主函数,需要保证优雅退出。比如ctrl-c的例子,我们需要捕捉这个信号,然后再让main正常退出,此时的覆盖率才会正常生成,例子如下:
package main
// main.go
import (
"fmt"
"example/gotest"
"os"
"os/signal"
)
func main() {
v := 2
if v == 1 {
x,_ := gotest.Add(1.0, 2.0)
fmt.Println("gotest.Add:", x);
} else {
x,_ := gotest.Division(2.0, 1.0)
fmt.Println("gotest.Division:", x);
}
signalChan := make(chan os.Signal, 1)
cleanupDone := make(chan bool)
signal.Notify(signalChan, os.Interrupt)
go func() {
for _ = range signalChan { //遍历捕捉到的Ctrl+C信号
fmt.Println("收到终端信号,停止服务")
cleanup()
cleanupDone <- true
}
}()
<-cleanupDone //阻塞进程
}
func cleanup() {
fmt.Println("清理")
}
输出
junshideMacBook-Pro:gogo junshili$ ./example -test.coverprofile coverage.cov
gotest.Division: 2
^C收到终端信号,停止服务
清理
PASS
coverage: 33.3% of statements in example/gotest
此时ctrl-c kill掉进程一样有覆盖率文件输出,使用go tool cover也可以正常打开。