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 工具,它自动重写代码以使用新的语言特性或更新的库。在 Go 1.0 推出之前,gofix 使我们能够对语言和库进行根本性的更改,并有信心用户只需运行该工具即可将其源代码更新到最新版本。

在 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 发布
博客索引