在一个流程规范的项目里,如果想发布一行代码到生产环境,都需要完整的测试流程,如果是向外网发布一个功能或版本,那么更需要进行完整的功能测试,其中就包括白盒测试和黑盒测试。尤其是在游戏行业,因为玩法逻辑复杂,很多逻辑很难考虑到,因此覆盖率验证是一个必备的一个流程。比如现在的团队就要求放出的游戏版本的代码覆盖率需要达到100%才算是达到发版标准,因此覆盖率测试在保证代码质量上扮演者重要角色。我们通过覆盖率可以看到我们的代码有哪些是已经跑到的,有哪些是特殊情况绕过了尚未验证,因此通过代码覆盖率我们可以对整个代码逻辑有充分的认识和判断。这篇文章主要介绍如何对Go的代码建立覆盖率,而Go的覆盖率建立依赖go test和go tool cover。

一个简单的项目建覆盖率

首先看下项目组织,一共有两个文件,main.go是我们的实现的程序,main_test.go是我们准备建覆盖率而新建的文件。

  1. .
  2. ├── main.go
  3. └── 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

指令执行后会自动从终端跳转到网页打开,内容呈现如下:
截屏2021-05-29 上午12.10.11.png

红色表示需要跑到的代码但是还没跑到(如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还没调用。

截屏2021-05-29 上午12.10.23.png

阻塞类函数建立覆盖率

如果你的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也可以正常打开。
截屏2021-05-29 上午12.10.32.png