系统测试中的Go代码覆盖率统计

背景

传统软件测试技术主要基于测试人员对业务的理解,但由于经验的局限性、被测系统的复杂性以及与真实业务数据的差距,肯定存在测试不充分的情况,所以,虽然整个测试流程很规范,但最终软件质量还是不尽如人意。随着分布式、微服务架构、大数据技术的出现,软件越来越复杂,迭代越来越快,测试的挑战性越来越大。引入系统测试的代码覆盖率统计,可以帮助研发识别无效代码,辅助测试提高测试覆盖度等

什么是代码覆盖率

  1. 代码覆盖率指的是在对代码进行测试时,测试用例覆盖了代码中多少的语句、分支、函数或条件等,以百分比的形式表示。代码覆盖率是衡量测试用例覆盖效果的一种指标。
  2. 代码覆盖率分为不同层次的覆盖,比如语句覆盖率分支覆盖率函数覆盖率等。不同层次的覆盖率反映了测试用例对代码执行的影响程度和覆盖程度,通常情况下,应该尽量追求达到更高的覆盖率,以保证代码的可靠性和稳定性。
  3. 代码覆盖率和测试用例的设计和执行息息相关,合理的测试用例设计可以更好地覆盖代码,提高覆盖率。因此,应该充分考虑测试用例的设计,以保证更好的覆盖率和代码质量。

应用价值

  1. 识别无效代码
  2. 系统测试阶段,通过实时覆盖率报告,定位未覆盖过的代码块,确认是否需要被覆盖,完善测试用例覆盖度
  3. 临时修改代码,确认是否有未覆盖的代码
  4. 通过观察覆盖率报告中的代码覆盖频次,可以帮助我们找到代码中的热点,优化关键路径上的代码,提高系统的性能和运行效率

覆盖率统计方案

  • 静态代码覆盖扫描工具 golangci-lint

    # 进入go项目根路径下执行
    golangci-lint  run --disable-all  --enable unused
    

------_da078ac0-fed9-411f-b11c-14b6b9bbae58

  • 单元测试覆盖率统计

    go test -coverprofile=coverage.out
    go tool cover -html=coverage.out -o=report.html
    
  • 系统测试覆盖率统计

    我们知道 jacoco 是一个用于 Java 代码覆盖率度量和报告生成的工具,那么 Go 语言是否有类似的比较成熟的统计工具呢,答案是有。业内 Go 语言用的比较多的覆盖率框架有 goc 和 gopher,goc的优势在于支持覆盖率计数清零、支持基于 Pull Request 的增量代码覆盖率等特性

goc简介

goc 由七牛云团队研发,是专为 Go 语言打造的一个综合覆盖率收集系统,尤其适合复杂的测试场景,比如系统测试时的代码覆盖率收集以及精准测试。
目前已开源 → GitHub地址

框架图

Pasted-Graphic-1-1

快速上手

goc部署实施流程

  1. goc命令行工具,两种方式获取

    • 自行从github上下载源码编译
    • 使用goc cli上编译好的版本,基于源码v2版本做了一些改进,代码仓 → goc-sd
  2. goc server部署,用于与被测项目交互,收集覆盖率数据等

    goc server --host 10.xxx.xxx.239:7779
    

    需要确保被测项目能够访问goc server

  3. 代码编译

    makefile方式编译的项目,可以修改项目文件的Makefile,增加goc编译方式,示例

    cbuild:
        goc build -o rta -v -a $(GOFLAGS) -tags=jsoniter cmd/main.go --gochost "10.xxx.xxx.239:7779"
    

    这里的gochost填写的即步骤2中的goc server的socket地址
    如果想最小化改动Go编译命令,也可以在编译被测项目时不指定gochost,在被测项目启动时添加环境变量即可

    export GOC_CUSTOM_HOST="10.xxx.xxx.239:7779"
    
  4. 将以下命令添加到flow流水线任务中Go项目构建命令

    git clone https://xxx.com/qimao/qaarm/goc.git
    chmod +x goc/goc && mv goc/goc /usr/local/bin
    

    makefile方式编译的,可直接修改makefile命令

    # make build
    make cbuild
    

    直接编译的项目参考步骤3中的命令
    被测项目启动后,首先会发起与goc server建立连接的请求
    image-20230626223410984
    goc server收到请求后,完成连接建立
    image-20230626223915707
    到此步骤,已经可以开始愉快的收集被测项目的代码覆盖率数据了~

收集覆盖率数据和测试报告

  • 原生方式

    • Api
      • 获取被测项目注册信息
        接口地址: /v2/agents
        请求方法: GET
        请求参数:
        参数类型是否必填描述
        idstrings筛选被测项目注册id
        statusint筛选被测项目实例注册状态,1:disconnected,2:connected

        请求示例:
      # 请求项目注册id为1519,状态是连接中的注册信息
      curl https://xxx.qimao.com/server/v2/agents?id=1519&status=2 
      
      {
          "items": [
              {
                  "id": "1519",
                  "rpc_remoteip": "10.xxx.xxx.34",
                  "watch_remoteip": "",
                  "hostname": "rta-web-tuia-9f9c7fdc4-k47p4",
                  "cmdline": "/rta web --media=tuia",
                  "pid": "1",
                  "extra": "",
                  "token": "cbf28011c66246bf98f5eb9c3634d1f1",
                  "status": 2
              }
          ]
      }
      
      • 获取覆盖率数据
        接口地址: /v2/cover/profile
        请求方法: GET
        请求参数:
        参数类型是否必填描述
        idstrings筛选被测项目注册的id
        skippatternstrings过滤被测项目包名
        extrastring筛选被测项目extra信息

        请求示例:
      # 请求项目注册id为1,2,3的覆盖率数据
      # extra参数可以从/v2/agents接口返回中获取
      curl https://xxx.qimao.com/server/v2/cover/profile?id=1,2,3 
      
      # 返回profile的值即为覆盖率的实时统计数据
      {"profile": "mode: count\nkol/api/data/data.pb.go:33.44,35.29 2 0\nkol/api/data/data.pb.go:35.29,39.3 3\n"}
      
    • 命令行
      [httpd@bigdata_platform_shanghai_test goc]$ goc2 profile get -h
      Usage:
        goc profile get [flags]
      
      Flags:
            --extra string    specify the regex expression of extra, only profile with extra information will be downloaded
        -h, --help            help for get
            --host string     specify the host of the goc server (default "127.0.0.1:7777")
            --id strings      specify the ids of the services
        -o, --output string   download cover profile
            --skip strings    skip specific packages in the profile
      
      Global Flags:
            --gocdebug   run goc in debug mode
      
  • 采集工具封装
    介绍到这里,整个系统测试覆盖率数据收集的流程跑通了,但是要真正投产,还需要解决一些实际使用过程中遇到的问题,如:

    • 如何完成定时采集,定时更新覆盖率
    • 重启服务,重新发版,已经收集的覆盖率数据会丢失
    • 代码变更后,如何完成新老覆盖率数据的合并
      ...

    为了解决上述问题,更加方便的管理覆盖率数据,诞生了goc-report小工具,codeup地址,与goc的命令行工具使用方式类似,克隆下来使用Go编译成可执行文件即可使用,goc-report原理是与goc server提供的api交互,处理覆盖率数据,并生成覆盖率报告

    goc-report使用场景

    • 定时采集,定时更新覆盖率

      [httpd@bigdata_platform_shanghai_test goc]$ goc-report update -h
      update goc profile
      
      Usage:
        goc-report update [flags]
      
      Flags:
        -s, --app string          指定需要处理覆盖率数据的被测服务
        -h, --help                help for update
        -i, --interval duration   执行定时任务的时间间隔 (default -1s)
        -p, --path string         工作目录(绝对路径) (default "/opt/case/goc")
            --project string      被测服务代码仓库的名称
      

      这里有个参数-i,未传值则只更新一次,传入时间间隔,如30s,则每30s更新一次覆盖率数据
      处理流程

      1. 根据传入app参数筛选出当前活跃中的被测项目注册id
      2. 根据注册id筛选出被测项目的实时覆盖率数据
      3. 将覆盖率数据持久化到本地文件
      4. 合并上一次得到的覆盖率文件,清除已经合并的文件
      5. 更新被测项目分支代码
      6. 生成覆盖率报告到指定目录,通过nginx代理访问
        假设工作目录设置为/opt/case/goc,那么最终生成的报告路径为/opt/case/goc/report/${app}/report.html
    • 清除被测项目的覆盖率数据

      [httpd@bigdata_platform_shanghai_test goc]$ goc-report clean -h
      clean goc profile
      
      Usage:
        goc-report clean [flags]
      
      Flags:
        -s, --app string   指定需要处理覆盖率数据的被测项目
        -h, --help         help for clean
      
  • 系统测试覆盖率报告生成和查看

    • 生成报告工具还是采用go原生的方式,即go tool cover命令,该命令已封装在goc-report中

    • 值得注意的是,在生成某个被测项目的覆盖率报告之前,需要将被测项目的测试分支下载到GOROOT的src目录下,因为go tool cover命令在执行的过程中,会从GOROOT下找到覆盖率数据中对应包的源代码,完成html报告的渲染

    • 最终的覆盖率报告生成的路径即上文提到的${path}/report/${app}/report.html,通过nginx代理访问
      灰色代表未跟踪,即未纳入统计范围
      红色代表未覆盖
      绿色代表已覆盖,颜色越深,覆盖次数越多
      系统测试覆盖率报告

goc源码简析

goc的覆盖率数据收集的原理

goc build大致流程

func (b *Build) Build() {
	// 1. 拷贝至临时目录
	b.copyProjectToTmp()
    // 5. 清理临时目录
	defer b.clean()
	log.Donef("project copied to temporary directory")
	// 2. 更新go.mod文件,主要是解决本地依赖问题
	b.updateGoModFile()
	// 3. 注入插桩变量
	b.Inject()
	// 4. 在临时目录进行代码编译,即执行go build
	b.doBuildInTemp()
}

代码注入流程

打桩的原理是借助AST语法树遍历整个文件,在识别到if、swith、select等地方,插入一行

  • 通过go list -json命令,找出被测项目所有包
func (b *Build) listPackages(dir string) map[string]*Package {
	listArgs := []string{"list", "-json"}
	if goflags.BuildTags != "" {
		listArgs = append(listArgs, "-tags", goflags.BuildTags)
	}
	listArgs = append(listArgs, "./...")
	cmd := exec.Command("go", listArgs...)
	cmd.Dir = dir
	var errBuf bytes.Buffer
	cmd.Stderr = &errBuf
	out, err := cmd.Output()
    ...
	dec := json.NewDecoder(bytes.NewBuffer(out))
	pkgs := make(map[string]*Package)
	for {
		var pkg Package
		if err := dec.Decode(&pkg); err != nil {
			...
		}
		pkgs[pkg.ImportPath] = &pkg
	}
	return pkgs
}
  • 注入覆盖率收集相关代码流程
    • 遍历所有项目包,找到项目中所有的main包
    • 往main包注入插桩变量-addCounters()
    • 向 main package 的依赖包注入插桩变量,这里忽略了Go标准库和第三方库
    • 为每个 main 包注入 websocket handler
    • 在项目根目录注入所有插桩变量的声明+定义
    func (b *Build) Inject() {
        var seen = make(map[string]*PackageCover)
        // 所有插桩变量定义声明
        allDecl := ""
        pkgs := b.Pkgs
        for _, pkg := range pkgs {
            if pkg.Name == "main" {
                // 该 main 二进制所关联的所有插桩变量的元信息
                // 每个 main 之间是不相关的,需要重新定义
                allMainCovers := make([]*PackageCover, 0)
                // 注入 main package
                mainCover, mainDecl := b.addCounters(pkg)
                // 收集插桩变量的定义和元信息
                allDecl += mainDecl
                allMainCovers = append(allMainCovers, mainCover)
                // 向 main package 的依赖注入插桩变量
                for _, dep := range pkg.Deps {
                    if packageCover, ok := seen[dep]; ok {
                        allMainCovers = append(allMainCovers, packageCover)
                        continue
                    }
                    // 依赖需要忽略 Go 标准库和 go.mod 引入的第三方
                    if depPkg, ok := pkgs[dep]; ok {
                        // 注入依赖的 package
                        packageCover, depDecl := b.addCounters(depPkg)
                        // 收集插桩变量的定义和元信息
                        allDecl += depDecl
                        allMainCovers = append(allMainCovers, packageCover)
                        // 避免重复访问
                        seen[dep] = packageCover
                    }
                }
                // 为每个 main 包注入 websocket handler
                b.injectGocAgent(b.getPkgTmpDir(pkg.Dir), allMainCovers)
                ...
            }
        }
        // 在工程根目录注入所有插桩变量的声明+定义
        b.injectGlobalCoverVarFile(allDecl)
        // 添加自定义 websocket 依赖
        // 用户代码可能有 gorrila/websocket 的依赖,为避免依赖冲突,以及可能的 replace/vendor,
        // 这里直接注入一份完整的 gorrila/websocket 实现
        websocket.AddCustomWebsocketDep(b.GlobalCoverVarImportPathDir)
        log.Donef("websocket library injected")
        log.StopWait()
        log.Donef("global cover variables injected")
    }
    

注入代码后的项目

  • 我们不妨将clean方法注释掉,启动编译流程,进入到临时编译目录,看看注入插桩代码后的项目架构和go文件
    首先会在项目根目录生成一个新包,包内注入了一份完整的 gorrila/websocket 实现,用于与goc server通信
    Pasted-Graphic
    此外,我们还注意到包下有个cover.go文件,打开会发现里面存放的就是goc注入的插桩变量,它是一个匿名结构体
    Pasted-Graphic-1
    然后将该包导入到其他项目包中,并在每个代码块入口,注入只属于自己的结构体对象,进行执行次数累加,完成统计工作
    Pasted-Graphic-3
    统计结构体解析
        // 643034616338343535333432代表被测项目下的包唯一键,9代表是此包下的第9个go文件,
        var GoCover_9_643034616338343535333432 = struct {
        // Count:一个长度为 2 的 uint32 数组,用于记录每个基本块(basic block)的执行次数
        Count     [2]uint32
        // Pos:一个长度为 3 * 2 的 uint32 数组,用于记录每个基本块的位置信息。每个基本块的位置信息由三个 uint32 数值表示,分别表示起始行号、起始列号和偏移量
        Pos       [3 * 2]uint32
        // NumStmt:一个长度为 2 的 uint16 数组,用于记录每个基本块中的语句数量
        NumStmt   [2]uint16
        // BlockName:表示基本块所在的文件路径和文件名
        BlockName string
    } {
        BlockName: "rta/pkg/service/exporter/stat_mock.go",
        Pos: [3 * 2]uint32{
            13, 76, 0x20048, // [0]
            80, 107, 0x20049, // [1]
        },
        NumStmt: [2]uint16{
            9, // 0
            5, // 1
        },
    }
    
    还有一些技术实现细节,如
    • 如何在被测项目启动时建立与goc server的rpc连接
    • goc server如何进行覆盖率的收集
    • 在k8s部署场景下,如何实现多实例的覆盖率数据合并
      这里由于篇幅关系,不做过多介绍,感兴趣的小伙伴欢迎交流~

总结

代码覆盖率是判断代码书写的质量,识别无效代码,评判测试覆盖度的重要工具

  • 静态代码
    对于静态的代码,要识别代码没有被使用,可以使用golangci-lint工具
  • 单元测试
    可以使用go test -cover工具
  • 测试环境下的系统测试
    可使用本文介绍的goc工具完成

参考

展示评论