go覆盖率统计

相关链接

原文 The cover story - The Go Programming Language

原文机翻

介绍

从项目一开始,Go 的设计就考虑到了工具。这些工具包括一些最具标志性的 Go 技术,例如文档演示工具 godoc、代码格式化工具 gofmt和 API 重写器 gofix。也许最重要的是 go command,该程序仅使用源代码作为构建规范来自动安装、构建和测试 Go 程序。

Go 1.2 的发布引入了一个新的测试覆盖工具,它采用了一种不同寻常的方法来生成覆盖率统计信息,这种方法建立在 godoc 和朋友们制定的技术之上。

工具支持

首先,一些背景知识:一种 语言支持良好的工具意味着什么?这意味着该语言使编写好的工具变得容易,并且其生态系统支持构建各种风格的工具。

Go 有许多特性使其适用于工具。对于初学者来说,Go 具有易于解析的常规语法。语法旨在避免需要复杂机器进行分析的特殊情况。

在可能的情况下,Go 使用词汇和句法结构来使语义属性易于理解。示例包括使用大写字母来定义导出的名称,以及与 C 传统中的其他语言相比从根本上简化的范围规则。

最后,标准库附带了生产质量包,用于 lex 和解析 Go 源代码。更不寻常的是,它们还包括一个用于漂亮打印 Go 语法树的生产质量包。

这些包组合起来构成了 gofmt 工具的核心,但漂亮的打印机值得单独挑选出来。因为它可以采用任意 Go 语法树并输出标准格式、人类可读、正确的代码,它创造了构建工具来转换解析树并输出修改但正确且易于阅读的代码的可能性。

一个例子是 gofix 工具,它可以自动重写代码以使用新的语言特性或更新的库。Gofix 让我们在 Go 1.0 的准备阶段对语言和库进行了根本性的更改,相信用户只需运行该工具即可将其源代码更新到最新版本。

在 Google 内部,我们使用 gofix 对一个巨大的代码库进行了彻底的更改,这在我们使用的其他语言中几乎是不可想象的。不再需要支持某些 API 的多个版本;我们可以使用 gofix 在一次操作中更新整个公司。

当然,这些软件包支持的不仅仅是这些大型工具。它们还可以轻松编写更简单的程序,例如 IDE 插件。所有这些项目相互构建,通过自动化许多任务使 Go 环境更加高效。

测试覆盖率

测试覆盖率是一个术语,描述了通过运行包的测试执行了多少包的代码。如果执行测试套件导致 80% 的包源语句运行,我们说测试覆盖率为 80%。

在 Go 1.2 中提供测试覆盖的程序是利用 Go 生态系统中工具支持的最新程序。

计算测试覆盖率的常用方法是检测二进制文件。例如,GNU gcov程序在二进制文件执行的分支处设置断点。随着每个分支的执行,断点被清除,分支的目标语句被标记为“已覆盖”。

这种方法是成功的并且被广泛使用。Go 的早期测试覆盖工具甚至以同样的方式工作。但它有问题。很难实现,因为对二进制文件执行的分析具有挑战性。它还需要一种将执行跟踪与源代码联系起来的可靠方法,这也很困难,任何源代码级调试器的用户都可以证明这一点。那里的问题包括不准确的调试信息和使分析复杂化的内联函数等问题。最重要的是,这种方法非常不便携。由于调试支持因系统而异,因此需要对每个体系结构重新进行,并且在某种程度上对每个操作系统都需要重新进行。

不过,它确实有效,例如,如果您是 gccgo 的用户,gcov 工具可以为您提供测试覆盖率信息。但是,如果您是 gc(更常用的 Go 编译器套件)的用户,那么在 Go 1.2 之前您就不走运了。

Go 的测试覆盖率

对于 Go 的新测试覆盖率工具,我们采用了不同的方法来避免动态调试。这个想法很简单:在编译之前重写包的源代码以添加检测,编译并运行修改后的源,并转储统计信息。重写很容易安排,因为该go 命令控制从源代码到测试再到执行的流程。

这是一个例子。假设我们有一个简单的单文件包,如下所示:

包装尺寸 func 大小(一个 int)字符串 { 转变 { 情况 a < 0: 返回“负数” 情况 a == 0: 返回“零” 情况 a < 10: 返回“小” 情况 a < 100: 返回“大” 情况 a < 1000: 返回“巨大” } 返回“巨大” }

这个测试:

包装尺寸 导入“测试” 类型测试结构{ 在 int 输出字符串 } var 测试 = 测试{ {-1, “负”}, {5, “小”}, } func TestSize(t *testing.T) { 对于我,测试 := 范围测试 { 尺寸:=尺寸(test.in) 如果大小!= test.out { t.Errorf("#%d: Size(%d)=%s; 想要 %s", i, test.in, size, test.out) } } }

为了获得包的测试覆盖率,我们通过提供-cover 标志来运行覆盖率启用的测试go test

% go test -cover
PASS
coverage: 42.9% of statements
ok      size    0.026s
%

请注意,覆盖率为 42.9%,这不是很好。在我们询问如何提高这个数字之前,让我们看看它是如何计算的。

启用测试覆盖后,go test 运行“cover”工具,即分发包中包含的一个单独程序,在编译前重写源代码。重写后的 Size 函数如下所示:

func 大小(一个 int)字符串 { GoCover.Count[0] = 1 转变 { 情况 a < 0: GoCover.Count[2] = 1 返回“负数” 情况 a == 0: GoCover.Count[3] = 1 返回“零” 情况 a < 10: GoCover.Count[4] = 1 返回“小” 情况 a < 100: GoCover.Count[5] = 1 返回“大” 情况 a < 1000: GoCover.Count[6] = 1 返回“巨大” } GoCover.Count[1] = 1 返回“巨大” }

程序的每个可执行部分都用一个赋值语句进行注释,当执行该语句时,记录该部分的运行情况。计数器通过同样由覆盖工具生成的第二个只读数据结构与它计数的语句的原始源位置相关联。测试运行完成后,将收集计数器并通过查看设置的数量来计算百分比。

尽管该注释分配可能看起来很昂贵,但它编译为单个“移动”指令。因此,它的运行时开销很小,在运行典型(更现实的)测试时仅增加了大约 3%。这使得将测试覆盖率作为标准开发管道的一部分包含在内是合理的。

查看结果

我们示例的测试覆盖率很差。为了找出原因,我们要求go test 为我们编写一个“覆盖率配置文件”,一个保存收集到的统计数据的文件,以便我们可以更详细地研究它们。这很容易做到:使用-coverprofile 标志指定输出文件:

% go test -coverprofile=coverage.out
PASS
coverage: 42.9% of statements
ok      size    0.030s
%

(该-coverprofile 标志自动设置-cover 为启用覆盖分析。)测试像以前一样运行,但结果保存在一个文件中。为了研究它们,我们自己运行测试覆盖工具,没有go test . 首先,我们可以要求按功能细分覆盖范围,尽管在这种情况下这并不能说明太多,因为只有一个功能:

% go tool cover -func=coverage.out
size.go:    Size          42.9%
total:      (statements)  42.9%
%

查看数据的一种更有趣的方法是获取源代码的 HTML 演示文稿,其中装饰有覆盖率信息。此显示由-html 标志调用:

$ go tool cover -html=coverage.out

运行此命令时,会弹出一个浏览器窗口,显示已覆盖(绿色)、未覆盖(红色)和未检测(灰色)的源。这是一个屏幕转储:

通过这个演示,很明显出了什么问题:我们忽略了几个案例的测试!我们可以准确地看到它们是哪些,这使得我们可以很容易地提高我们的测试覆盖率。

热图

这种测试覆盖率的源代码级方法的一大优势是可以很容易地以不同的方式检测代码。例如,我们不仅可以询问语句是否已执行,还可以询问执行了多少次。

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

  • 设置:每个语句都运行了吗?
  • 计数:每个语句运行了多少次?
  • atomic:类似于 count,但在并行程序中精确计数

默认是“设置”,我们已经看到了。atomic 只有在运行并行算法时需要准确计数时才需要该设置。它使用来自 sync/atomic包的原子操作,这可能非常昂贵。但是,对于大多数用途,该count 模式运行良好,并且与默认set 模式一样,非常便宜。

让我们尝试计算标准包(fmt 格式化包)的语句执行情况。我们运行测试并写出覆盖率配置文件,以便之后我们可以很好地呈现信息。

% go test -covermode=count -coverprofile=count.out fmt
ok      fmt 0.056s  coverage: 91.7% of statements
%

这比我们之前的示例要好得多的测试覆盖率。(覆盖率不受覆盖模式的影响。)我们可以显示功能分解:

% go tool cover -func=count.out
fmt/format.go: init              100.0%
fmt/format.go: clearflags        100.0%
fmt/format.go: init              100.0%
fmt/format.go: computePadding     84.6%
fmt/format.go: writePadding      100.0%
fmt/format.go: pad               100.0%
...
fmt/scan.go:   advance            96.2%
fmt/scan.go:   doScanf            96.8%
total:         (statements)       91.7%

巨大的回报发生在 HTML 输出中:

% go tool cover -html=count.out

以下是pad 该演示文稿中的功能:

注意绿色的强度如何变化。较亮的绿色语句具有更高的执行次数;饱和度较低的绿色代表较低的执行次数。您甚至可以将鼠标悬停在语句上,以查看工具提示中弹出的实际计数。在撰写本文时,计数结果如下(我们已将计数从工具提示移至行首标记,以便于显示):

2933    if !f.widPresent || f.wid == 0 {
2985        f.buf.Write(b)
2985        return
2985    }
  56    padding, left, right := f.computePadding(len(b))
  56    if left > 0 {
  37        f.writePadding(left, padding)
  37    }
  56    f.buf.Write(b)
  56    if right > 0 {
  13        f.writePadding(right, padding)
  13    }

这是关于函数执行的大量信息,这些信息可能对分析有用。

基本块

您可能已经注意到,上一个示例中的计数不是您在右大括号行中所期望的。这是因为,一如既往,测试覆盖率是一门不精确的科学。

不过,这里发生的事情值得解释。我们希望覆盖注释由程序中的分支划分,就像在传统方法中检测二进制文件时那样。但是,通过重写源代码很难做到这一点,因为分支没有明确地出现在源代码中。

覆盖注释所做的是仪器块,通常由大括号包围。一般来说,要做到这一点非常困难。使用的算法的结果是右大括号看起来像是属于它关闭的块,而左大括号看起来像是属于块外。一个更有趣的结果是,在这样的表达式中

f() && g()

没有尝试单独检测对f and的调用g ,不管事实如何,看起来它们都运行了相同的次数,f 运行的次数。

公平地说,gcov 这里甚至有麻烦。该工具可以正确使用仪器,但演示文稿是基于行的,因此可能会遗漏一些细微差别。

大图

这就是 Go 1.2 中关于测试覆盖率的故事。具有有趣实现的新工具不仅可以实现测试覆盖率统计,还可以轻松解释它们的表示,甚至可以提取分析信息。

测试是软件开发和测试覆盖率的重要组成部分,是一种为您的测试策略添加纪律的简单方法。前进,测试并覆盖。

1 Like
关闭