Go 博客

封面故事

Rob Pike
2013年12月2日

引言

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

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

对工具的支持

首先,介绍一些背景:一门语言“支持良好的工具”意味着什么?这意味着该语言易于编写优秀的工具,并且其生态系统支持构建各种类型的工具。

Go 有许多特性使其适合工具开发。首先,Go 具有规则的语法,易于解析。其语法旨在避免需要复杂机制来分析的特殊情况。

在可能的情况下,Go 使用词法和语法结构来简化语义属性的理解。例如,使用大写字母定义导出名称,以及与 C 传统中的其他语言相比,其作用域规则得到了极大的简化。

最后,标准库提供了可用于词法分析和解析 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 命令控制着从源代码到测试再到执行的流程。

下面是一个例子。假设我们有一个简单的、包含一个文件的软件包,像这样

package size

func Size(a int) string {
    switch {
    case a < 0:
        return "negative"
    case a == 0:
        return "zero"
    case a < 10:
        return "small"
    case a < 100:
        return "big"
    case a < 1000:
        return "huge"
    }
    return "enormous"
}

以及这个测试

package size

import "testing"

type Test struct {
    in  int
    out string
}

var tests = []Test{
    {-1, "negative"},
    {5, "small"},
}

func TestSize(t *testing.T) {
    for i, test := range tests {
        size := Size(test.in)
        if size != test.out {
            t.Errorf("#%d: Size(%d)=%s; want %s", i, test.in, size, test.out)
        }
    }
}

要获取软件包的测试覆盖率,我们可以通过向 go test 提供 -cover 标志来运行启用覆盖率的测试

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

注意,覆盖率为 42.9%,这不太理想。在我们讨论如何提高这个数字之前,先看看它是如何计算的。

当启用测试覆盖率时,go test 会运行“cover”工具(随发行版包含的另一个程序),在编译之前重写源代码。下面是重写后的 Size 函数的样子

func Size(a int) string {
    GoCover.Count[0] = 1
    switch {
    case a < 0:
        GoCover.Count[2] = 1
        return "negative"
    case a == 0:
        GoCover.Count[3] = 1
        return "zero"
    case a < 10:
        GoCover.Count[4] = 1
        return "small"
    case a < 100:
        GoCover.Count[5] = 1
        return "big"
    case a < 1000:
        GoCover.Count[6] = 1
        return "huge"
    }
    GoCover.Count[1] = 1
    return "enormous"
}

程序的每个可执行部分都被添加了一个赋值语句进行注释,当该语句执行时,就记录该部分已运行。计数器通过另一个由 cover 工具生成的只读数据结构,与它所计数的语句的原始源代码位置关联。测试运行完成后,收集计数器,并通过查看有多少计数器被设置来计算百分比。

尽管这种注释赋值看起来可能开销很大,但它会被编译成一条简单的“移动”指令。因此,它的运行时开销很小,在运行典型(更实际)的测试时只会增加大约 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 标志来将覆盖模式设置为以下三种之一

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

默认设置是‘set’,我们已经见过。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()

没有尝试单独检测对 fg 的调用。无论事实如何,看起来它们总是运行了相同的次数,也就是 f 运行的次数。

公平地说,即使是 gcov 在这里也存在问题。那个工具检测得很正确,但展示是基于行的,因此可能会遗漏一些细节。

总结

这就是 Go 1.2 中关于测试覆盖率的故事。一个具有有趣实现的全新工具不仅提供了测试覆盖率统计信息,还提供了易于理解的展示方式,甚至可以提取性能分析信息。

测试是软件开发的重要组成部分,而测试覆盖率是为您的测试策略增加规范性的简单方法。前进吧,测试,并覆盖。

下一篇文章:Go Playground 内部
上一篇文章:Go 1.2 发布了
博客索引