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 工具可以为您提供测试覆盖率信息。但是,如果您是更常用的 Go 编译器套件 gc 的用户,那么在 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 工具生成的第二个只读数据结构,计数器与语句的原始源代码位置相关联。当测试运行完成后,将收集计数器,并通过查看有多少计数器被设置来计算百分比。

尽管那个注释赋值语句可能看起来很昂贵,但它被编译成单个“move”指令。因此,其运行时开销适中,在运行典型(更现实)的测试时仅增加约 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()

没有尝试单独仪器化 `f` 和 `g` 的调用。无论事实如何,它们看起来总是运行了相同的次数,即 `f` 运行的次数。

公平地说,即使是 `gcov` 在这里也有困难。该工具可以正确地进行仪器化,但其显示是基于行的,因此可能会错过一些细微差别。

总体情况

这就是 Go 1.2 的测试覆盖率的故事。一个具有有趣实现的工具不仅可以提供测试覆盖率统计数据,还可以提供易于解释的显示,甚至可以提取性能分析信息。

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

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