浅谈 Golang 代码覆盖率

浅谈 Golang 代码覆盖率

Table of Contents

前言

平时开发的时候,大家都会接触到对 Golang 代码编写单元测试,其中我们常常会接触到代码覆盖率的概念。

通过编写单元测试,并提高代码覆盖率,我们能尽可能减少错误的发生,从而提高代码逻辑的健壮性,本文浅谈一下 Golang 代码覆盖率的一些细节。

覆盖率技术

测试覆盖率是一个术语,是指通过运行单元测试,测试逻辑能使多少业务逻辑代码得到执行与验证,计算方式为已覆盖行数/总行数。 如果执行单元测试导致50%的语句得到了运行,则测试覆盖率为50%。

传统的计算测试覆盖率的方法是对二进制可执行文件进行埋点,整体思路是在二进制文件中设置断点,当每个分支被执行的时候,断点即被清除,并且目标分支的语句会被标记为已覆盖。

这种方法是成功和广泛使用的。但是有如下几个问题:

  1. 实现难度大。分析二进制文件的执行是困难的,它需要将执行跟踪绑定回源代码的可靠方法,这里的问题包括不正确的调试信息和诸如内联功能的问题等, 使分析变得复杂。
  2. 可移植性差。 对于每个机器架构需要重新编写,在某种程度上,可能对于每个操作系统都需要重新编写,因为从系统到系统的调试支持差异很大。

Go 的早期测试覆盖工具甚至以相同的方式工作,Go 1.2 的发布引入了一个 test coverage 的新工具, 它采用插桩源码的形式,整体思路是在编译之前重写测试包的源码以埋点,然后再进行编译和运行,并统计覆盖信息。

这个方案实现起来也较为简单,因为 Go 控制了从测试到执行的全部流程,只需在执行 go 命令时,悄悄的重写测试代码,执行并统计数据即可。

但是同时这个方案的问题也是有的,显然它会修改目标程序的测试源码,即此方式是具有侵入性的,实际执行测试的代码是重写后的源码。

下面会提供一个 demo 进行演示,梳理一下 go test -cover 以及 go tool cover 的常见使用,并一起来看下它是如何重写测试源码,并统计覆盖率的。

Go 覆盖率计算

以下面目标代码为例:

package demo

func WhoAmI(name string) string {
	switch name {
	case "xunzhuo":
		return "first name"
	case "liuxunzhuo":
		return "full name"
	case "liu":
		return "last name"
	case "bitliu":
		return "nick name"
	default:
		return "unknown"
	}
}

func HowOldAmI(age string) int {
	switch age {
	case "22":
		return 22
	case "23":
		return 23
	default:
		return 24
	}
}

我们编写一个对其的单元测试:

package demo

import (
	"testing"

	"github.com/stretchr/testify/require"
)

func TestFirstName(t *testing.T) {
	testcases := []struct {
		description string
		input       string
		expect      string
	}{
		{
			description: "who is xunzhuo",
			input:       "xunzhuo",
			expect:      "first name",
		},
	}

	for _, testcase := range testcases {
		whoami := WhoAmI(testcase.input)
		require.Equal(t, testcase.expect, whoami)
	}
}

func TestFirstAndLastName(t *testing.T) {
	testcases := []struct {
		description string
		input       string
		expect      string
	}{
		{
			description: "who is xunzhuo",
			input:       "xunzhuo",
			expect:      "first name",
		},
		{
			description: "who is liu",
			input:       "liu",
			expect:      "last name",
		},
	}

	for _, testcase := range testcases {
		whoami := WhoAmI(testcase.input)
		require.Equal(t, testcase.expect, whoami)
	}
}

go test 命令接受 -covermode 标志将覆盖模式设置为三种设置之一:

  • set: 每个语句是否执行?
  • count: 每个语句执行了几次?
  • atomic: 类似于 count, 但表示的是并行程序中的精确计数。

go test 通过 -coverprofile 指定输出的测试报告文件。

go test 不指定 covermode 也可以直接传入 -cover,默认的是 set 模式。

下面通过命令生成了三种模式下的测试报告:

❯ go test -v -covermode=count -coverprofile=count.out ./
=== RUN   TestFirstName
--- PASS: TestFirstName (0.00s)
=== RUN   TestFirstAndLastName
--- PASS: TestFirstAndLastName (0.00s)
PASS
coverage: 30.0% of statements
ok      github.com/xunzhuo/demo/demo    0.484s  coverage: 30.0% of statements
❯ go test -v -covermode=atomic -coverprofile=atomic.out ./
=== RUN   TestFirstName
--- PASS: TestFirstName (0.00s)
=== RUN   TestFirstAndLastName
--- PASS: TestFirstAndLastName (0.00s)
PASS
coverage: 30.0% of statements
ok      github.com/xunzhuo/demo/demo    0.433s  coverage: 30.0% of statements
❯ go test -v -covermode=set -coverprofile=set.out ./
=== RUN   TestFirstName
--- PASS: TestFirstName (0.00s)
=== RUN   TestFirstAndLastName
--- PASS: TestFirstAndLastName (0.00s)
PASS
coverage: 30.0% of statements
ok      github.com/xunzhuo/demo/demo    0.333s  coverage: 30.0% of statements

测试报告可读性较差,如生成的 atomic.out:

mode: atomic
github.com/xunzhuo/demo/demo/about.go:3.33,4.14 1 3
github.com/xunzhuo/demo/demo/about.go:5.17,6.22 1 2
github.com/xunzhuo/demo/demo/about.go:7.20,8.21 1 0
github.com/xunzhuo/demo/demo/about.go:9.13,10.21 1 1
github.com/xunzhuo/demo/demo/about.go:11.16,12.21 1 0
github.com/xunzhuo/demo/demo/about.go:13.10,14.19 1 0
github.com/xunzhuo/demo/demo/about.go:18.32,19.13 1 0
github.com/xunzhuo/demo/demo/about.go:20.12,21.12 1 0
github.com/xunzhuo/demo/demo/about.go:22.12,23.12 1 0
github.com/xunzhuo/demo/demo/about.go:24.10,25.12 1 0

go tool cover 提供命令去展示上述的覆盖率报告,通过 -func 以命令行方式展示:

❯ go tool cover -func=atomic.out
github.com/xunzhuo/demo/demo/about.go:3:        WhoAmI          50.0%
github.com/xunzhuo/demo/demo/about.go:18:       HowOldAmI       0.0%
total:                                          (statements)    30.0%

通过 -html 以可视化方式展示:

❯ go tool cover -html=atomic.out

在浏览器中可以看到 atomic 模式的覆盖率报告,提供三种颜色也区分:

  • 绿色代表已覆盖,深浅代表覆盖的次数
  • 灰色代表已覆盖,覆盖次数较少
  • 红色代表未被覆盖

image-20230130152328738

前文也提到多次 golang 覆盖率技术是基于重写代码并埋点,我们一起来看看具体是如何重写代码并生成报告的。

go tool cover 提供命令去展示重写后的代码,-mode 通过 mode 去指定目标代码的覆盖模式,与 go test 一致,支持 set、count、atomic。

Count 模式埋点代码

以下为例,count 模式下重写后的代码:

❯ go tool cover -mode=count about_test.go > count_test.go

生成的代码为:

//line about_test.go:1
package demo

import (
	"testing"

	"github.com/stretchr/testify/require"
)

func TestFirstName(t *testing.T) {GoCover.Count[0]++;
	testcases := []struct {
		description string
		input       string
		expect      string
	}{
		{
			description: "who is xunzhuo",
			input:       "xunzhuo",
			expect:      "first name",
		},
	}

	for _, testcase := range testcases {GoCover.Count[1]++;
		whoami := WhoAmI(testcase.input)
		require.Equal(t, testcase.expect, whoami)
	}
}

func TestFirstAndLastName(t *testing.T) {GoCover.Count[2]++;
	testcases := []struct {
		description string
		input       string
		expect      string
	}{
		{
			description: "who is xunzhuo",
			input:       "xunzhuo",
			expect:      "first name",
		},
		{
			description: "who is liu",
			input:       "liu",
			expect:      "last name",
		},
	}

	for _, testcase := range testcases {GoCover.Count[3]++;
		whoami := WhoAmI(testcase.input)
		require.Equal(t, testcase.expect, whoami)
	}
}

var GoCover = struct {
	Count     [4]uint32
	Pos       [3 * 4]uint32
	NumStmt   [4]uint16
} {
	Pos: [3 * 4]uint32{
		9, 22, 0x250022, // [0]
		22, 25, 0x30025, // [1]
		28, 46, 0x250029, // [2]
		46, 49, 0x30025, // [3]
	},
	NumStmt: [4]uint16{
		2, // 0
		2, // 1
		2, // 2
		2, // 3
	},
}

可以看到,执行完之后,源码里多了个GoCover变量,其有三个比较关键的属性:

  • Count uint32 数组,数组中每个元素代表相应基本块 (basic block) 被执行到的次数
  • Pos 代表的各个基本块在源码文件中的位置,三个为一组。比如这里的21代表该基本块的起始行数,23代表结束行数,0x2000d比较有趣,其前 16 位代表结束列数,后 16 位代表起始列数。通过行和列能唯一确定一个点,而通过起始点和结束点,就能精确表达某基本块在源码文件中的物理范围
  • NumStmt 代表相应基本块范围内有多少语句 (statement)

变量会在每个执行逻辑单元设置个计数器,比如 GoCover.Count[0]++, 而这就是所谓插桩了。通过这个计数器能很方便的计算出这块代码是否被执行到,以及执行了多少次。相信大家一定见过表示 go 覆盖率结果的 coverprofile 数据,类似下面:

github.com/xunzhuo/demo/demo/about.go:3.33,4.14 1 3

这里的内容就是通过类似上面的变量GoCover得到。其基本语义为 “文件:起始行.起始列,结束行.结束列 该基本块中的语句数量 该基本块被执行到的次数

可以看到覆盖的次数,每次执行都会去 +1,可以看出它不仅关注了代码是否被覆盖,同时关注了代码被覆盖的次数,这能看出重要代码是否被多次覆盖,对我们单测编写有指导作用。

Set 模式埋点代码

我们再来看看 set 模式下重写后的代码以及它的区别:

go tool cover -mode=set about_test.go > set_test.go

代码如下:

//line about_test.go:1
package demo

import (
	"testing"

	"github.com/stretchr/testify/require"
)

func TestFirstName(t *testing.T) {GoCover.Count[0] = 1;
	testcases := []struct {
		description string
		input       string
		expect      string
	}{
		{
			description: "who is xunzhuo",
			input:       "xunzhuo",
			expect:      "first name",
		},
	}

	for _, testcase := range testcases {GoCover.Count[1] = 1;
		whoami := WhoAmI(testcase.input)
		require.Equal(t, testcase.expect, whoami)
	}
}

func TestFirstAndLastName(t *testing.T) {GoCover.Count[2] = 1;
	testcases := []struct {
		description string
		input       string
		expect      string
	}{
		{
			description: "who is xunzhuo",
			input:       "xunzhuo",
			expect:      "first name",
		},
		{
			description: "who is liu",
			input:       "liu",
			expect:      "last name",
		},
	}

	for _, testcase := range testcases {GoCover.Count[3] = 1;
		whoami := WhoAmI(testcase.input)
		require.Equal(t, testcase.expect, whoami)
	}
}

var GoCover = struct {
	Count     [4]uint32
	Pos       [3 * 4]uint32
	NumStmt   [4]uint16
} {
	Pos: [3 * 4]uint32{
		9, 22, 0x250022, // [0]
		22, 25, 0x30025, // [1]
		28, 46, 0x250029, // [2]
		46, 49, 0x30025, // [3]
	},
	NumStmt: [4]uint16{
		2, // 0
		2, // 1
		2, // 2
		2, // 3
	},
}

可以看到覆盖的次数始终为 1,可以理解为 set 模式,只关注是否被覆盖,并不关注热点代码被覆盖的次数。

Atomic 模式埋点代码

我们最后来看看 atomic 模式下重写后的代码以及它的区别:

go tool cover -mode=atomic about_test.go > atomic_test.go

代码如下:

//line about_test.go:1
package demo; import _cover_atomic_ "sync/atomic"

import (
	"testing"

	"github.com/stretchr/testify/require"
)

func TestFirstName(t *testing.T) {_cover_atomic_.AddUint32(&GoCover.Count[0], 1);
	testcases := []struct {
		description string
		input       string
		expect      string
	}{
		{
			description: "who is xunzhuo",
			input:       "xunzhuo",
			expect:      "first name",
		},
	}

	for _, testcase := range testcases {_cover_atomic_.AddUint32(&GoCover.Count[1], 1);
		whoami := WhoAmI(testcase.input)
		require.Equal(t, testcase.expect, whoami)
	}
}

func TestFirstAndLastName(t *testing.T) {_cover_atomic_.AddUint32(&GoCover.Count[2], 1);
	testcases := []struct {
		description string
		input       string
		expect      string
	}{
		{
			description: "who is xunzhuo",
			input:       "xunzhuo",
			expect:      "first name",
		},
		{
			description: "who is liu",
			input:       "liu",
			expect:      "last name",
		},
	}

	for _, testcase := range testcases {_cover_atomic_.AddUint32(&GoCover.Count[3], 1);
		whoami := WhoAmI(testcase.input)
		require.Equal(t, testcase.expect, whoami)
	}
}

var GoCover = struct {
	Count     [4]uint32
	Pos       [3 * 4]uint32
	NumStmt   [4]uint16
} {
	Pos: [3 * 4]uint32{
		9, 22, 0x250022, // [0]
		22, 25, 0x30025, // [1]
		28, 46, 0x250029, // [2]
		46, 49, 0x30025, // [3]
	},
	NumStmt: [4]uint16{
		2, // 0
		2, // 1
		2, // 2
		2, // 3
	},
}
var _ = _cover_atomic_.LoadUint32

可以看到和 count 类似,只是覆盖的次数是通过 "sync/atomic" 去安全的增加,在并发场景下,覆盖次数会更加精确。

结束语

计算机科学家 Edsger Dijkstra 曾说过:Program testing can be used to show the presence of bugs, but never to show their absence! (测试能证明缺陷存在,而无法证明没有缺陷)所以测试不可能是完整的,实现 100% 的测试覆盖率听起来很美,但是在具体实践中通常是不可行的,也不是值得推荐的做法。应该对更需要测试的地方添加测试代码,而不是一味的为每个方法都加入测试代码,经验表明:

  • 高代码覆盖率并不能保证高产品质量,但低代码覆盖率一定说明大部分逻辑没有被自动化测到。后者通常会增加问题遗留到线上的风险,当引起注意。
  • 没有普适的针对所有产品的严格覆盖率标准。实际上这更应该是业务或技术负责人基于自己的领域知识,代码模块的重要程度,修改频率等等因素,自行在团队中确定标准,并推动成为团队共识。
  • 低代码覆盖率并不可怕,能够主动去分析未被覆盖到的部分,并评估风险是否可接受,会更加有意义。

贴一篇 Google 发布的代码覆盖的最佳实践 ,这篇博客提到,其经验表明,重视代码覆盖率的团队通常会更加容易培养卓越工程师文化,因为这些团队在设计产品之初就会考虑可测性问题,以便能更轻松的实现测试目标。而这些措施反过来会促使工程师编写更高质量的代码,更注重模块化.

comments powered by Disqus

Related Posts

Envoy Gateway v0.6 发布

Envoy Gateway v0.6 发布

本文介绍 Envoy Gateway v0.6.0 版本发布有哪些变化

Read More
从 Envoy Gateway 看 Gateway API 扩展性

从 Envoy Gateway 看 Gateway API 扩展性

本文通过 Envoy Gateway 的实现,介绍三种 Gateway API 的扩展方式

Read More
Endpoints vs EndpointSlice

Endpoints vs EndpointSlice

本文简单的比较了一下 `Endpoints` 和 `EndpointSlice` 的优劣

Read More