Go编程基础-11. 测试

5

测试

测试是自动化测试的简称,即编写简单的程序来确保产品代码在特定输入下产生预期的输出。这些测试通常要么是精心设计的以检测某种功能,要么是随机性的以扩大测试覆盖面。

软件测试领域非常广泛。测试任务几乎占据了所有程序员的一部分时间,有时候甚至是全部时间。关于测试的资料有数千本书和数百万字的博文。在每种主流的程序设计语言中,都有很多软件包专门用来构建测试,其中一些还包含很多理论,并且这个领域吸引了很多拥有众多追随者的先驱。这些足够使得程序员相信,为了写好测试,他们必须掌握一种新的技能。

Go 的测试方法看上去相对简单。它依赖于命令 go test 和一些能用 go test 运行的测试函数的编写约定。这个相对轻量级的机制对单纯的测试很有效,并且这种方式也自然地扩展到基准测试和文档系统的示例。

实际上,编写测试代码和编写原始程序并没有什么不同。我们编写聚焦于任务的部分功能的简单函数。我们必须谨防条件边界,思考数据结构,并且合理地设计如何根据合适的输入得到输出。这和编写常规的 Go 代码没有区别,不需要新的注解、约定和工具。

1. go test 工具

go test 子命令是 Go 语言包的测试驱动程序,这些包根据某些约定组织在一起。在一个包目录中,以 _test.go 结尾的文件不是 go build 命令编译的目标,而是 go test 编译的目标。

_test.go 文件中,有三种函数需要特殊对待,即功能测试函数、基准测试函数和示例函数。功能测试函数是以 Test 前缀命名的函数,用来检测一些程序逻辑的正确性。go test 运行测试函数,并且报告结果是 PASS 还是 FAIL。基准测试函数的名称以 Benchmark 开头,用来测试某些操作的性能,go test 汇报操作的平均执行时间。示例函数的名称以 Example 开头,用来提供机器检查过的文档。

go test 工具扫描 _test.go 文件来寻找特殊函数,并生成一个临时的 main 包来调用它们,然后编译和运行,并汇报结果,最后清空临时文件。

2. Test 函数

2.1 基于用例的测试(表测试法)

示例:

func TestIsPalindrome(t *testing.T) {
    var tests = []struct {
        input string
        want  bool
    }{
        {"", true},
        {"a", true},
        {"aa", true},
        {"ab", false},
        {"kayak", true},
        {"detartrated", true},
        {"A man, a plan, a canal: Panama", true},
        {"Evil did I dwell; lewd did I live.", true},
        {"Able was I ere I saw Elba", true},
        {"été", true},
        {"Et se resservir, ivresse reste.", true},
        {"palindrome", false},
        {"desserts", false},
    }
    for _, test := range tests {
        if got := IsPalindrome(test.input); got != test.want {
            t.Errorf("IsPalindrome(%q) = %v, want %v", test.input, got, test.want)
        }
    }
}

新的测试可以通过了:

```sh
$ go test gopl.io/ch11/word2
ok gopl.io/ch11/word2 0.015s

这种基于表的测试方式在 Go 里面很常见,将所有测试输入和预期输出存入一个 map,来批量运行测试用例。根据需要添加新的表项目很直观,并且由于断言逻辑没有重复,我们可以花点精力让输出的错误消息更好看一点。

当前调用 t.Errorf 输出的失败的测试用例信息没有包含整个跟踪栈信息,也不会导致程序宕机或者终止执行,这和很多其他语言的测试框架中的断言不同。测试用例彼此是独立的。如果测试表中的一个条目造成测试失败,那么其他的条目仍然会继续测试,这样我们就可以在一次测试过程中发现多个失败的情况。

如果我们真的需要终止一个测试函数,比如由于初始化代码失败或者避免已有的错误产生令人困惑的输出,我们可以使用 t.Fatalt.Fatalf 函数来终止测试。这些函数的调用必须和 Test 函数在同一个 goroutine 中,而不是在测试创建的其他 goroutine 中。

测试错误消息一般格式是 “f(x)=y, want z”,这里 f(x) 表示需要执行的操作和它的输入,y 是实际的输出结果,z 是期望得到的结果。出于方便,对于 f(x) 我们会使用 Go 的语法,比如在上面的回文例子中,我们使用 Go 的格式化来显示较长的输入,避免重复输入。在基于表的测试中,输出 x 是很重要的,因为一条断言语句会在不同的输入情况下执行多次。错误消息要避免样板文字和冗余信息。在测试一个布尔函数的时候,比如上面的 IsPalindrome,可以省略 “want z” 部分,因为它没有给出有用信息。如果 xyz 都比较长,可以输出准确代表各部分的概要信息。在程序员诊断一个测试失败的时候,测试用例的作者必须努力帮助程序员。

2.2 基于随机输入的测试

基于表的测试方便针对精心选择的输入检测函数是否工作正常,以测试逻辑上引人关注的用例。另一种方式是随机测试,通过构建随机输入来扩展测试的覆盖范围。

如果给出的输入是随机的,我们怎么知道函数输出什么内容呢?这里有两种策略。一种方式是额外写一个函数,这个函数使用低效但是清晰的算法,然后检查这两种实现的输出是否一致。另一种方式是构建符合某种模式的输入,这样我们可以知道它们对应的输出是什么。

下面的例子使用了第二种方式,randomPalindrome 函数产生一系列的回文字符串,这些输出在构建的时候就确定是回文字符串了。

import (
    "math/rand"
    "time"
)

func randomPalindrome(rng *rand.Rand) string {
    n := rng.Intn(25) // 随机字符串最大长度是 24
    runes := make([]rune, n)
    for i := 0; i < (n + 1) / 2; i++ {
        r := rune(rng.Intn(0x1000)) // 随机字符最大是 '\u0999'
        runes[i] = r
        runes[n - 1 - i] = r
    }
    return string(runes)
}

func TestRandomPalindromes(t *testing.T) {
    seed := time.Now().UTC().UnixNano()
    t.Logf("Random seed: %d", seed)
    rng := rand.New(rand.NewSource(seed))
    for i := 0; i < 1000; i++ {
        p := randomPalindrome(rng)
        if !IsPalindrome(p) {
            t.Errorf("IsPalindrome(%q) = false", p)
        }
    }
}

由于随机测试的不确定性,在遇到测试用例失败的情况下,一定要记录足够的信息以便于重现这个问题。在该例子中,函数 IsPalindrome 的输入 p 告诉我们所需要知道的所有信息,但是对于那些拥有更复杂输入的函数来说,记录伪随机数生成器的种子(如我们所做的那样)会比转储整个输入数据结构要简单得多。有了随机数的种子,我们可以简单地修改测试代码来准确地重现错误。

通过使用当前时间作为伪随机数的种子源,在测试的整个生命周期中,每次运行的时候都会得到新的输入。如果你的项目使用自动化系统来定期运行测试,这一点很重要。

2.3 测试 main 函数

go test 测试 main 函数一般有两种方式:

  1. 将 main 包视作一个普通库来测试:在 test 中模拟 main 函数的逻辑,处理输入参数,调用对应内部函数进行测试(只测内部调用函数,不测 main 本身)。这种模式能够很好地契合表测试法:

    // echo.go
    package main
    
    import (
        "flag"
        "fmt"
        "io"
        "os"
        "strings"
    )
    
    var (
        n = flag.Bool("n", false, "omit trailing newline")
        s = flag.String("s", " ", "separator")
    )
    
    var out io.Writer = os.Stdout // 测试过程中被更改
    
    func main() {
        flag.Parse()
        if err := echo(!*n, *s, flag.Args()); err != nil {
            fmt.Fprintf(os.Stderr, "echo: %v\n", err)
            os.Exit(1)
        }
    }
    
    func echo(newline bool, sep string, args []string) error {
        fmt.Fprint(out, strings.Join(args, sep))
        if newline {
            fmt.Fprintln(out)
        }
        return nil
    }
    
    // echo_test.go
    package main
    
    import (
        "bytes"
        "fmt"
        "testing"
    )
    
    func TestEcho(t *testing.T) {
        var tests = []struct {
            newline bool
            sep     string
            args    []string
            want    string
        }{
            {true, " ", []string{}, "\n"},
            {false, " ", []string{}, ""},
            {true, "\t", []string{"one", "two", "three"}, "one\ttwo\tthree\n"},
            {true, ",", []string{"a", "b", "c"}, "a,b,c\n"},
            {false, ":", []string{"1", "2", "3"}, "1:2:3"},
        }
        for _, test := range tests {
            descr := fmt.Sprintf("echo(%v, %q, %q)", test.newline, test.sep, test.args)
            out = new(bytes.Buffer) // 捕获的输出, 测试代码和待测试代码在同一个包中,可以直接修改包级别的变量
            if err := echo(test.newline, test.sep, test.args); err != nil {
                t.Errorf("%s failed: %v", descr, err)
                continue
            }
            got := out.(*bytes.Buffer).String()
            if got != test.want {
                t.Errorf("%s = %q, want %q", descr, got, test.want)
            }
        }
    }
    
  2. 使用 os.Args 来模拟命令行参数,然后调用 main() 函数:缺点是 main 函数没有返回值,无法便捷、自动化地确定函数的执行结果,只能通过输出、日志判断。

    // main.go
    package main
    
    import (
        "fmt"
        "os"
    )
    
    func main() {
        if len(os.Args) < 2 {
            fmt.Println("No arguments provided")
            return
        }
        fmt.Println("Arguments:", os.Args[1:])
    }
    
    // main_test.go
    package main
    
    import (
        "os"
        "testing"
    )
    
    func TestMain(m *testing.M) {
        os.Exit(m.Run())
    }
    
    func TestSimulateCommandLine(t *testing.T) {
        // 保存原始的 os.Args
        originalArgs := os.Args
        defer func() { os.Args = originalArgs }()
    
        // 设置 os.Args 模拟命令行调用
        os.Args = []string{"cmd", "arg1", "arg2"}
    
        // 调用 main 函数
        main()
    }
    

2.4 白盒测试 & Monk测试

测试的分类方式之一是基于对所要进行测试的包的内部了解程度。黑盒测试假设测试者对包的了解仅通过公开的 API 和文档,而包的内部逻辑则是不透明的。相反,白盒测试可以访问包的内部函数和数据结构,并且可以做一些常规用户无法做到的观察和改动。例如,白盒测试可以检查包的数据类型不可变性在每次操作后是否得到维护。(白盒这个名字是传统说法,净盒(clear box)的说法或许更准确。)

这两种方法是互补的。黑盒测试通常更加健壮,每次程序更新后基本不需要修改。它们也会帮助测试的作者关注包的用户并且能够发现 API 设计的缺陷。反之,白盒测试可以对实现的特定之处提供更详细的覆盖测试。

上面已经给出了这两种测试方法的例子。TestIsPalindrome 函数仅调用导出的函数 IsPalindrome,所以它是一个黑盒测试;TestEcho 函数调用 echo 函数并且更新全局变量 out,无论函数 echo 还是变量 out 都是未导出的,所以它是一个白盒测试。

在开发 TestEcho 的时候,我们修改了 echo 函数,从而在输出结果时使用一个包级别的变量,以便该测试用一个额外的实现代替标准输出来记录后面要检查的数据。通过同样的技术,我们可以使用易于测试的伪实现来替换部分产品代码。这种伪实现的优点是更易于配置、预测和观察,并且更可靠。它们还能够避免带来副作用,比如更新产品数据库或者刷信用卡。

下面的代码演示了向用户提供存储服务的 Web 服务中的限额逻辑。当用户使用的额度超过 90% 的时候,系统自动发送一封告警邮件。

package storage

import (
    "fmt"
    "log"
    "net/smtp"
)

func bytesInUse(username string) int64 {
    return 0 // ...
}

// 邮件发送者配置
// 注意:永远不要把密码放到源代码中
const sender = "[email protected]"
const password = "correcthorsebatterystaple"
const hostname = "smtp.example.com"
const template = "Warning: you are using %d bytes of storage, %d%% of your quota."

func CheckQuota(username string) {
    used := bytesInUse(username)
    const quota = 1000000000 // 1GB
    percent := 100 * used / quota
    if percent < 90 {
        return // OK
    }
    msg := fmt.Sprintf(template, used, percent)
    auth := smtp.PlainAuth("", sender, password, hostname)
    err := smtp.SendMail(hostname+":587", auth, sender, []string{username}, []byte(msg))
    if err != nil {
        log.Printf("smtp.SendMail(%s) failed: %s", username, err)
    }
}

我们想测试这个功能,但是并不想真的发送邮件出去。所以我们把发送邮件的逻辑移动到独立的函数中,并且把它存储到一个不可导出的包级别的变量 notifyUser 中。(一种简单的Mock手段,将需要Monk的方法设置为全局变量,在测试代码中替换为一个低成本的替代函数,测试完成后再defer恢复。缺点:侵入式的,需要源代码配合)

package storage

import (
    "fmt"
    "log"
    "net/smtp"
)

var notifyUser = func(username, msg string) {
    auth := smtp.PlainAuth("", sender, password, hostname)
    err := smtp.SendMail(hostname+":587", auth, sender, []string{username}, []byte(msg))
    if err != nil {
        log.Printf("smtp.SendMail(%s) failed: %s", username, err)
    }
}

func CheckQuota(username string) {
    used := bytesInUse(username)
    const quota = 1000000000 // 1GB
    percent := 100 * used / quota
    if percent < 90 {
        return // OK
    }
    msg := fmt.Sprintf(template, used, percent)
    notifyUser(username, msg)
}

现在我们可以写个简单的测试,这个测试用伪造的通知机制而不是发送一封真实的邮件。这个测试记录需要通知的用户和通知的内容。

package storage

import (
    "strings"
    "testing"
)

func TestCheckQuotaNotifiesUser(t *testing.T) {
    var notifiedUser, notifiedMsg string
    notifyUser = func(user, msg string) {
        notifiedUser, notifiedMsg = user, msg
    }

    // 模拟已使用 980MB 的情况
    const user = "[email protected]"
    CheckQuota(user)
    if notifiedUser == "" && notifiedMsg == "" {
        t.Fatalf("notifyUser not called")
    }
    if notifiedUser != user {
        t.Errorf("wrong user (%s) notified, want %s", notifiedUser, user)
    }
    const wantSubstring = "98% of your quota"
    if !strings.Contains(notifiedMsg, wantSubstring) {
        t.Errorf("unexpected notification message <<%s>>, want substring %q", notifiedMsg, wantSubstring)
    }
}

这里有一个问题,在这个测试函数返回之后,CheckQuota 因为仍然使用该测试的伪通知实现 notifyUser,所以再次在其他测试中调用它时就不能正常工作了。(对于全局变量的更新一直都是存在风险的。)我们必须修改这个测试让它恢复 notifyUser 原来的值,这样后面的测试才不会受影响。我们必须在所有的测试执行路径上都这样做,包括测试失败和宕机。通常这种情况下建议使用 defer

func TestCheckQuotaNotifiesUser(t *testing.T) {
    // 保存留待恢复的 notifyUser
    saved := notifyUser
    defer func() { notifyUser = saved }()

    // 设置测试的伪通知 notifyUser
    var notifiedUser, notifiedMsg string
    notifyUser = func(user, msg string) {
        notifiedUser, notifiedMsg = user, msg
    }

    // ...测试其余的部分...
}

这种方式可以用来临时保存并恢复各种全局变量,包括命令行选项、调试参数,以及性能参数;也可以用来安装和移除钩子程序来让产品代码调用测试代码;或者将产品代码设置为少见却很重要的状态,比如超时、错误,甚至是交叉并行执行。以这种方式使用全局变量是安全的,因为 go test 一般不会并发执行多个测试。

2.5 外部测试包/独立测试包

  1. 问题背景

    • 低级包(如net/url)的测试可能需要导入高级包(如net/http)
    • 这可能导致包循环引用,违反Go规范
  2. 解决方案:外部测试包

    • 在包目录中创建以_test结尾的包
    • Go test工具单独编译和运行这些测试
    • 允许自由导入其他包,避免循环引用
  3. 包文件分类
    执行go list -f={{.TestGoFiles}} fmt命令:

    • GoFiles:产品代码文件
    • TestGoFiles:包内测试文件
    • XTestGoFiles:外部测试文件
  4. 特殊访问权限

    • 使用export_test.go暴露包内部功能
    • 为外部测试提供"后门"访问
  5. 实际应用示例

    • fmt包使用export_test.go暴露isSpace函数
    • 允许外部测试验证fmt.isSpace和unicode.IsSpace的一致性

总结:

  • 包内测试:针对单个包内功能,可通过export_test.go提供测试后门
  • 外部测试:可自由引入其他包,避免循环依赖,进行深入测试

外部测试包是Go语言中解决包循环引用问题的有效方法,同时也为更全面、灵活的测试提供了可能。通过合理使用包内测试、外部测试和export_test.go,可以实现对包的全面测试,同时保持良好的代码结构和依赖关系。

2.6 编写有效测试

许多初学 Go 的人对其测试框架的极简主义设计感到惊讶。与其他语言的测试框架相比,Go 省去了通过反射或元数据识别测试函数的机制、测试前后的钩子、以及用于断言、值比较、错误消息格式化和终止失败测试的工具库。这些机制虽然能使测试编写得更加精细,但也使得这些测试看起来像是用另一种语言编写的。此外,虽然它们能准确报告测试结果是 PASS 还是 FAIL,但对维护者而言,这些报告方式可能并不友好,例如模糊的错误消息 “assert: .==1” 或者冗长的堆栈跟踪信息。

Go 对测试的看法完全不同。它期望测试编写者自己完成大部分工作,通过定义函数来避免重复,就像他们为普通程序所做的那样。测试的过程不是机械地填表格,而是有用户界面的,尽管其用户也是它的维护者。一个好的测试在发生错误时不会崩溃,而是输出问题的简洁、清晰描述,以及其他与上下文相关的信息。理想情况下,维护者不需要通过阅读源代码来探究测试失败的原因。一个好的测试不应在发现一次测试失败后就终止,而是要在一次运行中尝试报告多个错误,因为错误发生的方式本身可能揭示错误的原因

有效的测试要求我们尽可能地保留上下文,因此在测试中需要避免一些过早或过度的抽象,例如:

// 一个糟糕的断言函数
func assertEqual(x, y int) {
    if x != y {
        宕机(fmt.Sprintf("%d != %d", x, y))
    }
}

func TestSplit(t *testing.T) {
    words := strings.Split("a:b:c", ":")
    assertEqual(len(words), 3)
}

在这种情况下,断言函数过早抽象,把这个特定测试的失败归因为两个整数之间的不同。我们丧失了提供有意义语境的机会。我们可以通过从具体的信息开始来提供一个更好的错误输出,如下面的示例所示。只有当重复模式出现在一组测试中时才可以引入抽象。

func TestSplit(t *testing.T) {
    s, sep := "a:b:c", ":"
    words := strings.Split(s, sep)
    if got, want := len(words), 3; got != want {
        t.Errorf("Split(%q, %q) 返回 %d 个词,期望 %d",
            s, sep, got, want)
    }
}

2.7 避免脆弱的测试

如果一个应用在遇到新的合法输入时经常崩溃,那么这个程序是有缺陷的。同样,如果程序发生可靠改动时测试用例奇怪地失败了,那么这个测试用例也是脆弱的。就像有缺陷的程序会让用户感到沮丧,脆弱的测试也会激怒维护者。最脆弱的测试在产品代码发生任何改动时都会失败,无论这些改动是好是坏。这类测试通常被称为变化探测器(change detector)或现状探测器(status quo test),处理它们的时间将使其曾经带来的好处消失殆尽。

如果一个被测试的函数产生了复杂输出,比如长字符串、详细数据结构或文件,比较吸引人的做法是检查输出是否完全匹配某些“幸运值”。然而,随着程序的发展,输出的部分内容可能会以好的方式发生变化,不仅输出会变化,复杂输入的函数也经常会崩溃,因为测试中使用的输入不再合法。

避免写出脆弱测试的最简单方法就是仅检查你关心的属性。首先测试程序中越来越简单和稳定的接口,然后是它们的内部函数。选择性地设置断言。例如,不要检查字符串的精确匹配,而是寻找在程序进化过程中不会改变的子串。通常情况下,很值得写一个稳定的函数来从复杂的输出中提取核心内容,这样断言才会可靠。虽然这看起来预先会做很多工作,但这是值得的,否则这些时间将会被花在修复那些奇怪失败的测试上。

3. 测试覆盖率

一个测试套件覆盖待测试包的比例称为测试的覆盖率。覆盖率无法直接通过数量来衡量,任何事情都是动态的,即使最微小的程序都无法精确地测量。但还是有办法帮助我们将测试精力放到最有潜力的地方。

语句覆盖率是一种最简单且广泛使用的方法之一。一个测试套件的语句覆盖率是指部分语句在一次执行中至少执行一次。本节将使用 Go 的 cover 工具,这个工具被集成到了 go test 中,用来衡量语句覆盖率并帮助识别测试之间的明显差别。

下面的代码是基于表的测试,用来测试一个第三方的表达式求值器:

// test
package ch11

import (
	"fmt"
	. "gopl.io/ch7/eval"  
	"math"
	"testing"
)

func TestCoverage(t *testing.T) {
	var tests = []struct {
		input string
		env   Env
		want  string // Parse/Check 返回的错误或者 Eval 返回的结果
	}{
		{"x % 2", nil, "unexpected '%'"},
		{"!true", nil, "unexpected '!'"},
		{"sqrt(1, 2)", nil, "call to sqrt has 2 args, want 1"},
		{"sqrt(A / pi)", Env{"A": 87616, "pi": math.Pi}, "167"},
		{"pow(x, 3) + pow(y, 3)", Env{"x": 9, "y": 10}, "1729"},
		{"5 / 9 * (F - 32)", Env{"F": -40}, "-40"},
	}
	for _, test := range tests {
		expr, err := Parse(test.input)
		if err == nil {
			err = expr.Check(map[Var]bool{})
		}
		if err != nil {
			if err.Error() != test.want {
				t.Errorf("%s: got %q, want %q", test.input, err, test.want)
			}
			continue
		}
		got := fmt.Sprintf("%.6g", expr.Eval(test.env))
		if got != test.want {
			t.Errorf("%s: %v => %s, want %s", test.input, test.env, got, test.want)
		}
	}
}

go test cover的使用方式:

# 1. 基本使用,只返回覆盖率 (注意:必须将待测试的源码路径作为参数传入,只有有源码的情况下才可以测试覆盖率)
go test -v -run=TestCoverage -cover gopl.io/ch7/eval

# 2. 将分析结果保存为文件 (windows下目标文件需要加双引号)
go test -v -run=TestCoverage -coverprofile="c.out" gopl.io/ch7/eval

# 3. 将分析结果在网页中展示
go tool cover -html="c.out"

覆盖率测试

进阶:

# 1. 显示执行的频次 -covermode=count
go test -v -run=TestCoverage -coverprofile="c.out" -covermode=count gopl.io/ch7/eval

# 2. 将分析结果在网页中展示
go tool cover -html="c.out"

覆盖率测试-频次

4. Benchmark函数

基准测试是一种在特定工作负载下检测程序性能的方法。在 Go 中,基准测试函数类似于测试函数,但前缀为 Benchmark,并且拥有一个 testing.B 参数。这个参数提供了大多数与 testing.T 相同的方法,同时增加了一些与性能检测相关的方法。testing.B 还提供了一个整数成员 N,用于指定被检测操作的执行次数。

下面是 IsPalindrome 函数的基准测试,它在一个循环中调用 IsPalindromeN 次:

package ch11

import (
	"unicode"
)

// IsPalindrome 基础实现
func IsPalindrome(str string) bool {
	// 1. 预处理
	var letters []rune
	for _, r := range str {
		if unicode.IsLetter(r) {
			letters = append(letters, unicode.ToLower(r))
		}
	}
	// 2. 判断是否回文
	for i := range letters {
		if letters[i] != letters[len(letters)-i-1] {
			return false
		}
	}
	return true
}

// IsPalindrome2 优化实现
func IsPalindrome2(str string) bool {
	// 1. 预处理,兼容unicode字符
	letters := make([]rune, 0, len(str))
	for _, r := range str {
		if unicode.IsLetter(r) {
			letters = append(letters, unicode.ToLower(r))
		}
	}
	// 2. 判断是否回文
	n := len(letters) / 2
	for i := 0; i < n; i++ {
		if letters[i] != letters[len(letters)-i-1] {
			return false
		}
	}
	return true
}

package ch11

import (
	"fmt"
	"testing"
)

func TestIsPalindrome(t *testing.T) {
	isPalindrome := IsPalindrome("A man, a plan, a canal: Panama")
	fmt.Println(isPalindrome)
}

func BenchmarkIsPalindrome(b *testing.B) {
	for i := 0; i < b.N; i++ {
		IsPalindrome("A man, a plan, a canal: Panama")
	}
}

func BenchmarkIsPalindrome2(b *testing.B) {
	for i := 0; i < b.N; i++ {
		IsPalindrome2("A man, a plan, a canal: Panama")
	}
}

我们使用以下命令执行基准测试。与测试不同,默认情况下不会运行任何基准测试-bench 标记的参数指定了要运行的基准测试。它是一个匹配 Benchmark 函数名称的正则表达式,默认值不匹配任何函数。模式 . 使它匹配包 word 中所有的基准测试函数,因为这里只有一个基准测试函数,所以和指定 -bench=IsPalindrome效果一样。(注意此处是根据正则进行匹配的,如果有两个Benchmark函数 BenchmarkIsPalindrome和BenchmarkIsPalindrome2,两个都会执行)

$ go test -bench=.
PASS
BenchmarkIsPalindrome-8 1000000 1035 ns/op
ok gopl.io/ch11/word2 2.179s

PS: 哪怕加上-bench= Test方法也会跟着一起执行。如果想要只执行benchmark, 加上-run=None。

基准测试名称的数字后缀 8 表示 GOMAXPROCS 的值,这对于并发基准测试很重要。报告告诉我们每次 IsPalindrome 调用耗费 1.035 微秒,这是 1,000,000 次调用的平均值。

由于基准测试运行器开始时并不清楚操作的耗时,因此最初使用较小的 N 值进行检测,然后为了检测稳定的运行时间,推断出足够大的 N 值。使用基准测试函数来实现循环而不是在测试驱动程序中调用代码的原因是,在基准测试函数中可以在循环外执行一些必要的初始化代码,而这段时间不会被计算在每次迭代的时间中。如果初始化代码干扰了结果,testing.B 提供了方法来停止、恢复和重置计时器,但这些方法很少用到。

最快的程序通常是那些进行内存分配次数最少的程序。命令行标记 -benchmem 在报告中包含了内存分配统计数据。这里是优化前后的内存分配对比:

$ go test -bench=. -benchmem
PASS
BenchmarkIsPalindrome 1000000 1026 ns/op 304 B/op 4 allocs/op

优化后:

$ go test -bench=. -benchmem
PASS
BenchmarkIsPalindrome 2000000 807 ns/op 128 B/op 1 allocs/op

通过一次 make 调用分配完全部所需的内存,减少了 75% 的分配次数并且减少了一半的内存分配。

这种基准测试告诉我们给定操作的绝对耗时,但在许多情况下,我们关注的是两个不同操作之间的相对耗时。例如,如果一个函数需要 1 毫秒来处理 1000 个元素,那么处理 10,000 个或 1,000,000 个元素又需要多久呢?另一个例子:I/O 缓冲区的最佳大小是多少?对一个应用使用一系列大小进行基准测试可以帮助我们选择最小的缓冲区并带来最佳的性能表现。再比如:对于一个任务,哪种算法表现最佳?对两个不同的算法使用相同的输入,在重要或具有代表性的工作负载下进行基准测试,通常可以显示出每个算法的优点和缺点。

性能比较函数只是普通的代码(由我们自行根据测试需求编写)。它们通常是带有一个参数的函数,被多个不同的 Benchmark 函数传入不同的值来调用,如下所示:

func benchmark(b *testing.B, size int) {
    // ...
}

func Benchmark10(b *testing.B) { benchmark(b, 10) }
func Benchmark100(b *testing.B) { benchmark(b, 100) }
func Benchmark1000(b *testing.B) { benchmark(b, 1000) }

参数 size 指定了输入的大小,每个 Benchmark 函数传入的值都不同,但在每个函数内部是一个常量。

5. 性能剖析(profile)

毫无疑问,对性能的崇拜会导致滥用。程序员们浪费了大量时间来思考或担心他们非关键部分代码的执行速度,并且在考虑到程序的调试和维护时,这些优化尝试事实上会带来负面的影响。我们必须忘记微小的性能提升,必须说在 97% 的情况下,过早优化是万恶之源。

然而,我们不可以错过那关键的 3% 的情况。一个好的程序员不会因为这个就自满,明智的方法是他应该仔细地查看关键代码;当然,仅在关键代码明确之后。通常情况下,先入为主地认定程序哪些部分是关键代码是错误的。使用了检测工具的程序员会发现一个普遍经验:他们的直觉是错的。

当我们希望仔细地查看程序的速度时,发现关键代码的最佳技术就是性能剖析。性能剖析是通过自动化手段在程序执行过程中基于一些性能事件的采样来进行性能评测,然后再从这些采样中推断分析,得到的统计报告就称作为性能剖析(profile)。

Go 支持很多种性能剖析方式,每一种都和不同方面的性能指标相关,但它们都需要记录一些相关的事件,每一个都有一个相关的栈信息——即事件发生时活跃的函数调用栈。工具 go test 内置支持一些类别的性能剖析。

  • CPU 性能剖析识别出执行过程中需要 CPU 最多的函数。在每个 CPU 上执行的线程每隔几毫秒会定期被操作系统中断,在每次中断过程中记录一个性能剖析事件,然后恢复正常执行。
  • 堆性能剖析识别出负责分配最多内存的语句。性能剖析库对协程内部内存分配调用进行采样,因此每个性能剖析事件平均记录了分配的 512KB 内存。
  • 阻塞性能剖析识别出那些阻塞协程最久的操作,例如系统调用、通道发送和接收数据,以及获取锁等。性能分析库在一个 goroutine 每次被上述操作之一阻塞的时候记录一个事件。

获取待测试代码的性能剖析报告很容易,只需要像下面一样指定一个标记即可。当一次使用多个标记时需要注意,获取性能分析报告的机制是当获取其中一个类别的报告时会覆盖掉其他类别的报告。

$ go test -cpuprofile=cpu.out
$ go test -blockprofile=block.out
$ go test -memprofile=mem.out

尽管具体的做法对于短暂的命令行工具和长时间运行的服务器程序有所不同,但为非测试程序添加性能剖析支持也很容易。性能剖析对于长时间运行的程序尤其有用,所以 Go 运行时的性能剖析特性可以让程序员通过 runtime API 来启用。

在我们获取性能剖析结果后,我们需要使用 pprof 工具来分析它。这是 Go 发布包的标准部分,但因为不经常使用,所以通过 go tool pprof 间接来使用它。它有很多特性和选项,但基本的用法只有两个参数:产生性能剖析结果的可执行文件和性能剖析日志。

为了使得性能剖析过程高效并节约空间,性能剖析日志里面没有包含函数名称,而是使用它们的地址。这就意味着 pprof 工具需要可执行文件才能理解数据内容。虽然通常情况下 go test 工具在测试完成之后就丢弃了用于测试而临时产生的可执行文件,但在性能剖析启用的时候,它保存并把可执行文件命名为 pkg.test,其中 pkg 是被测试包的名字

下面的命令演示如何获取和显示简单的 CPU 性能剖析。以上述基准测试为例。通常情况下最好对我们关心的具有代表性的具体负载而构建的基准测试进行性能剖析。对测试用例进行基准测试永远没有代表性,这也是我们使用过滤器 -run=NONE 来禁用它们的原因。

$ go test -run=None -bench=IsPalindrome -benchmem -cpuprofile="cpu.log" -v
goos: windows
goarch: amd64
pkg: codelens.net/golang/study/11_test
cpu: Genuine Intel(R) 0000
BenchmarkIsPalindrome
BenchmarkIsPalindrome-8          5684258               202.2 ns/op           248 B/op          5 allocs/op
BenchmarkIsPalindrome2
BenchmarkIsPalindrome2-8        11593059                97.92 ns/op          128 B/op          1 allocs/op
PASS

# go tool pprof 时需要可执行文件+性能剖析日志
$ go tool pprof -text -nodecount=10 .\ch11.test.exe "cpu.log"              
File: ch11.test.exe
Build ID: C:\Users\skquax\Documents\code\go\src\study\11_test\11_test.test.exe2024-06-30 00:44:48.9941056 +0800 CST
Type: cpu
Time: Jun 30, 2024 at 12:48am (CST)
Duration: 2.75s, Total samples = 3190ms (116.07%)
Showing nodes accounting for 2360ms, 73.98% of 3190ms total
Dropped 55 nodes (cum <= 15.95ms)
Showing top 10 nodes out of 96
      flat  flat%   sum%        cum   cum%
     490ms 15.36% 15.36%     1080ms 33.86%  codelens.net/golang/study/11_test.IsPalindrome2
     380ms 11.91% 27.27%      870ms 27.27%  runtime.mallocgc
     290ms  9.09% 36.36%     1190ms 37.30%  codelens.net/golang/study/11_test.IsPalindrome
     260ms  8.15% 44.51%      260ms  8.15%  runtime.stdcall3
     210ms  6.58% 51.10%      210ms  6.58%  runtime.nextFreeFast (inline)
     190ms  5.96% 57.05%      190ms  5.96%  unicode.ToLower
     150ms  4.70% 61.76%      760ms 23.82%  runtime.growslice
     150ms  4.70% 66.46%      150ms  4.70%  unicode.IsLetter (inline)
     140ms  4.39% 70.85%      140ms  4.39%  runtime.stdcall2
     100ms  3.13% 73.98%      120ms  3.76%  runtime.scanblock

标记 -text 指定输出的格式,在这个例子中,首先出现的是一个文本表格,表格中每行一个函数,这些函数是根据消耗 CPU 最多的规则排序的“热函数”。标记 -nodecount=10 限制输出的结果共 10 行。对于较明显的性能问题,这个文本格式的输出或许已经足够暴露问题了。

对于更微妙的问题,最好使用 pprof 的图形显示格式之一。这些格式需要 Graphviz,它可以从 Graphviz 下载。标记 -web 渲染了程序中函数的有向图,并标记出函数的 CPU 消耗数值,用颜色突出“热函数”。

这里只讨论了 Go 的性能剖析工具的皮毛。要了解更多内容,可以阅读 Go 博客文章“Go 程序性能检测”。

6. Example函数

go test 特殊对待的第三种函数就是示例函数,它们的名字以 Example 开头。示例函数既没有参数也没有返回值。以下是 IsPalindrome 的一个示例函数

func ExampleIsPalindrome() {
	isPalindrome := IsPalindrome("A man, a plan, a canal: Panama")
	fmt.Println(isPalindrome)
	// Output:
	// true
}

示例函数有三个主要目的。

  1. 首要目的是作为文档。相较于乏味的描述,一个好的例子能够最简洁直观地展示库函数的功能。示例还能演示同一 API 中类型和函数之间的交互,而文档则总是侧重于介绍某个特定的点,要么是类型,要么是函数,或整个包。与带注释的例子不同,示例函数是真实的 Go 代码,必须通过编译检查,所以它们随着代码的演变也不会过时。
基于 `Example` 函数的后缀,基于 Web 的文档服务器 `godoc` 可以将示例函数与它所演示的函数或包相关联。因此,`ExampleIsPalindrome` 将和函数 `IsPalindrome` 的文档显示在一起。如果有一个示例函数名为 `Example`,那么它将与包 `word` 关联。

Example Godoc

  1. 示例函数的第二个目的是它们可以通过 go test 运行的可执行测试。如果一个示例函数最后包含一个类似这样的注释 Output:,测试驱动程序将执行这个函数并检查输出内容是否匹配注释中的文本。(必须包含这个注释才会执行,只有完成的示例内容+预期结果,才是一个完整的Example测试函数)

  2. 示例函数的第三个目的是提供手动实验代码。在 golang.org 上的 godoc 文档服务器使用 Go Playground,让用户可以在 Web 浏览器中编辑和运行每个示例函数,如下图所示。这通常是了解特定函数功能或语言特性最快捷的方法。

交互式用例