吃透 Golang 基础:测试

发布于:2025-06-24 ⋅ 阅读:(21) ⋅ 点赞:(0)

go test

在这里插入图片描述
go test命令是一个按照一定的约定和组织来测试代码的程序。在包目录内,所有以xxx_test.go为后缀名的源文件在执行go build时不会被构建为包的一部分,它们是go test测试的一部分。

xxx_test.go中,有三种类型的函数:测试函数、基准(benchmark)函数、示例函数。

测试函数是以Test为函数名前缀的函数,用于测试程序的一些逻辑行为是否正确;go test命令会调用这些测试函数并报告测试结果是PASS还是FAIL

基准函数是以Benchmark为函数名前缀的函数,用于衡量一些函数的性能。go test会多次运行基准函数以计算一个平均的执行时间。

示例函数是以Example为函数名前缀的函数,提供一个由编译器保证正确性的示例文档。

go test会遍历所有xxx_test.go文件中符合上述命名规则的函数,生成一个临时的 main 包用于调用相应的测试函数,接着构建并运行、报告测试结果,最后清理测试中生成的临时文件。

测试函数

每个测试函数必须导入testing包,函数签名如下:

func TestName(t *testing.T) {
    // ... ... ...
}

测试函数名必须以Test开头,可选的后缀名必须以大写字母开头:

func TestSin(t *testing.T) { /* ... ... ... */ }
func TestCos(t *testing.T) { /* ... ... ... */ }
func TestLog(t *testing.T) { /* ... ... ... */ }

参数t用于报告测试失败和附加的日志信息。下例实现的函数是一个用于判断字符串是否为回文串的函数:

package word

func IsPalindrome(s string) bool {
    for i := range s {
        if s[i] != s[len(s) - 1 - i] {
            return false
        }
    }
    return true
}

在相同的目录下,word_test.go测试文件中包含TestPalindromeTestNonPalindrome两个测试函数:

package word

import "testing"

func TestPalindrome(t *testing.T) {
    if !IsPalindrome("detartrated") {
        t.Error(`isPlaindrome("detartrated") = false`)
    }
    if !IsPalindrome("kayak") {
        t.Error(`IsPalindrome("kayak") = false`)
    }
}

func TestNonPalindrome(t *testing.T) {
    if IsPalindrome("palindrome") {
        t.Error(`IsPalindrome("palindrome") = true`)
    }
}

在该目录下,于命令行当中输入go test(如果没有参数来指定包,那么将默认采用当前目录对应的包,和go build一样),构建和运行测试:

go test
PASS
ok      test/word       0.449s

下例在测试文件当中引入了更复杂的例子:

func TestFrenchPalindrome(t *testing.T) {
    if !IsPalindrome("été") {
        t.Error(`IsPalindrome("été") = false`)
    }
}

func TestCanalPalindrome(t *testing.T) {
    input := "A man, a plan, a canal: Panama"
    if !IsPalindrome(input) {
        t.Errorf(`IsPalindrome(%q) = false`, input)
    }
}

再次运行 go test,会得到这两个测试语句报错的反馈:

go test
--- FAIL: TestFrenchPalindrome (0.00s)
    word_test.go:22: IsPalindrome("été") = false
--- FAIL: TestCanalPalindrome (0.00s)
    word_test.go:29: IsPalindrome("A man, a plan, a canal: Panama") = false
FAIL
exit status 1
FAIL    test/word       0.362s

先编写测试用例并观察到测试用例触发了和用户报告的错误相同的描述是一个好的测试习惯,只有这样我们才能定位到我们真正要解决的问题。

先写测试用例的另外一个好处是,运行测试通常比手工描述报告处理更快,这使得我们可以快速迭代。如果测试集有很多运行缓慢的测试,我们可以通过只选择运行某些特定的测试来加快测试的速度。

go test加上参数-v来打印每个测试函数的名字和运行时间。

go test -v
=== RUN   TestPalindrome
--- PASS: TestPalindrome (0.00s)
=== RUN   TestNonPalindrome
--- PASS: TestNonPalindrome (0.00s)
=== RUN   TestFrenchPalindrome
    word_test.go:22: IsPalindrome("été") = false
--- FAIL: TestFrenchPalindrome (0.00s)
=== RUN   TestCanalPalindrome
    word_test.go:29: IsPalindrome("A man, a plan, a canal: Panama") = false
--- FAIL: TestCanalPalindrome (0.00s)
FAIL
exit status 1
FAIL    test/word       0.147s

参数-run对应一个正则表达式,只有测试函数名被它正确匹配的测试函数才会被go test测试命令运行:

go test -run="French|Canal"
--- FAIL: TestFrenchPalindrome (0.00s)
    word_test.go:22: IsPalindrome("été") = false
--- FAIL: TestCanalPalindrome (0.00s)
    word_test.go:29: IsPalindrome("A man, a plan, a canal: Panama") = false
FAIL
exit status 1
FAIL    test/word       0.147s

现在我们的任务就是修复上述的错误。第一个 BUG 产生的原因是我们采用了 byte 而不是 rune 序列,所以像“été”中的é等非ASCII字符不能正确处理。第二个 BUG 是因为没有忽略空格和小写字母所导致的。基于上述两个 BUG,重写 IsPalindrome 函数:

package word

import "unicode"

func IsPalindrome(s string) bool {
    var letters []rune
    for _, r := range s {
        if unicode.IsLetter(r) {
            letters = append(letters, unicode.ToLower(r))
        }
    }
    for i := range letters {
        if letters[i] != letters[len(letters)-1-i] {
            return false
        }
    }
    return true
}

同时,我们将所有的测试数据合并到一张测试表格当中:

package word

import "testing"

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 I did dwell; lewd did I live.", true},
		{"Able was I ere I saw Elba", true},
		{"été", true},
		{"Et se resservir, ivresse reste.", true},
		{"palindrome", false}, // non-palindrome
		{"desserts", false},   // semi-palindrome
	}
	for _, test := range tests {
		if got := IsPalindrome(test.input); got != test.want {
			t.Errorf("IsPalindrome(%q) = %v", test.input, got)
		}
	}
}

现在再次运行go test,会发现所有测试都通过了。

上面这种表格驱动的测试在 Go 当中很常见,我们可以很容易地向表格中添加新的测试数据,并且后面的测试逻辑也没有冗余,使得我们可以有更多的精力去完善错误信息。

对于失败的测试用例,t.Errorf不会引起 panic 异常或是终止测试的执行。即使表格前面的数据导致了测试的失败,表格后面的测试依然会执行。

如果我们确实要在表格测试当中出现失败测试用例时停止测试,那么我们可以使用t.Fatalt.Fatalf来停止当前函数的测试。它们必须在和测试函数同一个 goroutine 内被调用。

测试失败的信息形式一般是f(x)=y, want z

随机测试

表格驱动的测试便于构造基于精心挑选的测试数据的测试用例。另一种测试的思路是随机测试,也就是通过构造更广泛的随机输入来测试探索函数的行为。

对于一个随机输入,如何知道希望的输出结果呢?有两种处理策略:第一个是编写另一个对照函数,使用简单和清晰的算法,虽然效率较低,但是行为和要测试的函数是一致的,然后针对相同的随机输入,检查两者输出的结果。第二种是生成的随机输入数据遵循特定的模式,这样我们就可以知道期望的输出的模式。

下例采用第二种方法,使用randomPalindrome函数随机生成回文字符串:

import "math/rand"
// randomPalindrome returns a palindrome whose length and contents
// are derived from the pseudo-random number generator rng.
func randomPalindrome(rng *rand.Rand) string {
	n := rng.Intn(25) // random length up to 24
	runes := make([]rune, n)
	for i := 0; i < (n+1)/2; i++ {
		r := rune(rng.Intn(0x1000)) // random rune up to '\u0999'
		runes[i] = r
		runes[n-1-i] = r
	}
	return string(runes)
}

下面是对它的测试语句块。在该测试函数中,首先根据时间生成一个随机数种子,传递给randomPalindrome用于生成随机的回文串。之后,调用IsPalindrome对这个回文串进行测试:

func TestRandomPalindromes(t *testing.T) {
	// Initialize a pseudo-random number generator.
	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)
		}
	}
}

测试一个命令

go test甚至可以用来对可执行程序进行测试。如果一个包的名字是main,那么在构建时会生成一个可执行程序,不过main包可以作为一个包被测试器代码导入。

下例包含两个函数,分别是 main 函数和 echo 函数。echo 函数完成真正的工作,main 函数用于处理命令后输入的参数,以及 echo 可能返回的错误:

// Echo prints its command-line arguments.
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 // modified during testing

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 函数,然后检测它的输出是否正确,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) // captured output
        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)
        }
    }
}

要注意的是测试代码和产品代码(即 main 函数所在的 go 文件)放在同一个包中。虽然是 main 包,也具有 main 入口函数,但在测试的时候 main 包只是 TestEcho 测试函数导入的一个普通包,里面 main 函数并没有被导出,而是被忽略了。

白盒测试

一种测试分类的方法是基于测试着是否需要了解被测试对象内部的工作原理。黑盒测试只需要测试包公开的文档和 API 行为,内部的实现对测试代码是透明的。相反,白盒测试有访问包内部函数和数据结构的权限,因此可以做到一些普通客户端服务实现的测试。例如,一个白盒测试可以在每个操作之后检测不变量的数据类型。

黑盒和白盒测试两种测试方法是互补的。黑盒测试一般更健壮,随着软件的完善,其测试代码很少需要被更新,它们可以帮助测试者了解真实客户的需求,也可以帮助发现 API 设计的不足之处。相反,白盒测试则可以对内部一些棘手的实现提供更多的测试覆盖

我们已经见过两种测试方法了。TestIsPalindrome仅仅使用导出的IsPalindrome函数进行测试,因此它属于黑盒测试。而TestEcho测试调用了内部的echo函数,并更新了内部的out包级变量,二者都是未导出的,属于白盒测试。

下例演示了为用户提供网络存储的 web 服务中的配额检测逻辑。当用户使用了超过 90%的存储配额之后,将发送提醒邮件,下述代码存放在storage.go文件当中:

// in storage.go
package storage

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

func bytesInUse(username string) int64 { return 0 }

// NOTE: Never put password in source code!
const sender = "notifications@example.com"
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
	percent := 100 * used / quota
	if percent < 90 {
		return // OK
	}
	msg := fmt.Sprintf(template, used, quota)
	auth := smtp.PlainAuth("", sender, hostname, password)
	err := smtp.SendMail(hostname+":587", auth, sender, []string{username}, []byte(msg))
	if err != nil {
		log.Printf("smtp.SendMail(%s) failed: %s", username, msg)
	}
}

我们想测试这段代码,但是不希望真地发送邮件,因此我们将发送邮件的处理逻辑放在一个私有的notifyUser函数当中。

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.SendEmail(%s) failed: %s", username, err)
	}
}

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

现在我们可以在测试中用伪邮件发送函数替代真实的邮件发送函数。它只是简单记录要通知的用户和邮件的内容。

func TestCheckQuotaNotifiesUser(t *testing.T) {
	// Save and restore original notifyUser.
	saved := notifyUser
	defer func() { notifyUser = saved }()

	var notifiedUser, notifiedMsg string
	notifyUser = func(user, msg string) {
		notifiedUser, notifiedMsg = user, msg
	}

	// ...simulate a 980MB-used condition...

	const user = "joe@example.org"
	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进行测试。我们想要模拟一个使用980 MB内存的情况,而在storage.go当中,我们已经设置bytesInUse的返回结果为 0,我们先设置其返回结果为980000000,之后执行测试函数(这一点很关键,在《Go 语言圣经》的原文中没有提及,导致测试函数一开始的执行就是失败的)。

可以看到,测试可以成功执行通过。说明我们可以顺利地在内存达到阈值的情况下,在CheckQuota当中调用notifyUser函数来对用户进行通知。

此处有一个技巧,那就是在测试函数的开头,使用一个saved来保存测试正式开始之前的notifyUser函数,使用defer关键字在测试结束时恢复这个函数,这样就不会影响其他测试函数对notifyUser这个业务函数进行测试了。这样做是并发安全的,因为go test不会并发地执行测试文件中的测试函数。

外部测试包

考虑net/urlnet/http两个包,前者提供了 URL 解析功能,后者提供了 web 服务和 HTTP 客户端功能。上层的net/http依赖下层的net/url

如果我们想要在net/url包中测试一演示不同 URL 和 HTTP 客户端的交互行为,就会在测试文件当中导入net/http,进而产生循环引用。我们已经提到过,Go 当中不允许循环引用的存在。

此时,我们就需要引入「外部测试包」,以避免因测试而产生的循环导入。我们可以在net/url这个包所在的目录net新建一个名为net/url_test的包,专门用于外部测试,包名的_test告知go test工具它应该建立一个额外的包来运行测试。外部测试包的导入路径是net/url_test,但因为它是一个专门用于测试的包,所以它不应该被其他包所导入。

由于外部测试包是一个独立的包,所以它能够导入那些「依赖待测代码本身」的其他辅助包,包内的测试代码无法做到这一点。在设计层面,外部测试包是其他所有包的上层:

可以使用go list工具来查看包目录下哪些 Go 源文件是产品代码,哪些是包内测试,还有哪些是包外测试。

有时候,外部测试包需要以白盒测试的方式对包内未导出的逻辑进行测试,一个《Go 语言圣经》当中介绍的技巧是:我们可以在包内测试文件中导出一个内部的实现来供外部测试包使用,因为这些代码仅在测试的时候用到,因此一般放在export_test.go文件当中。

例如,fmt 包的fmt.Scanf需要unicode.IsSpace函数提供的功能。为了避免太多的依赖,fmt 包并没有导入包含巨大表格数据的 unicode 包。相反,fmt 包当中有一个名为isSpace的内部简单实现。

为了确保fmt.isSpaceunicode.IsSpace的行为一致,fmt 包谨慎地包含了一个测试。一个外部测试包内的白盒测试当然无法访问包内的未导出变量,因此 fmt 专门设置了一个IsSpace函数,它是开发者为测试开的后门,专门用于导出isSpace。导出的行为被放在了export_test.go文件当中:

package fmt

var IsSpace = isSpace	// 在 export_test.go 当中导出内部的未导出变量, 为包外测试开后门

测试覆盖率

就性质而言,测试不可能是完整的。对待测程序执行的测试程度称为“测试覆盖率”。测试覆盖率不能量化,但有启发式的方法能帮助我们编写有效的测试代码。

启发式方法中,语句的覆盖率是最简单和最广泛使用的。语句的覆盖率指的是在测试中至少被执行一簇的代码占总代码数的比例。

下例是一个表格驱动的测试,用于测试表达式求值程序(《Go 语言圣经》第七章——7.9 示例:表达式求值):

func TestCoverage(t *testing.T) {
    var tests = []struct {
        input string
        env   Env
        want  string // expected error from Parse/Check or result from Eval
    }{
        {"x % 2", nil, "unexpected '%'"},
        {"!true", nil, "unexpected '!'"},
        {"log(10)", nil, `unknown function "log"`},
        {"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 tool cover,来显示测试覆盖率工具的使用方法。

$ go tool cover
Usage of 'go tool cover':
Given a coverage profile produced by 'go test':
    go test -coverprofile=c.out

Open a web browser displaying annotated source code:
    go tool cover -html=c.out
...

现在,在go test加入-coverprofile标志参数重新运行测试:

$ go test -run=Coverage -coverprofile=c.out gopl.io/ch7/eval
ok      gopl.io/ch7/eval         0.032s      coverage: 68.5% of statements

这个标志会在测试代码中插入生成 hook 函数来统计覆盖率的数据。

如果使用了-covermode=count标志,那么测试代码会在每个代码块插入一个计数器,用于统计每一个代码块的执行次数,依次我们可以衡量哪些代码是被频繁执行的代码。

我们可以将测试的日志在 HTML 打印出来,使用:

go tool cover -html=c.out

100%的测试覆盖率听起来很完美,但是在实践中通常不可行,也不是推荐的做法。测试时覆盖只能说明代码被执行过而已,并不代表代码永远不出现 BUG。

基准测试

固定测试可以测量一个程序在固定工作负载下的性能。Go 当中,基准测试函数与普通测试函数的写法类似,但是以 Benchmark 为前缀名,并且带有一个类型为*testing.B的参数。*testing.B参数除了提供和*testing.T类似的方法,还有额外一些和性能测量相关的方法。它还提供了一个整数N,用于指定操作执行的循环次数。

下例为IsPalindrome的基准测试:

import "testing"

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

使用go test -bench=.来运行基准测试。需要注意的是,和普通测试不同,基准测试在默认情况下不会运行。我们需要通过-bench来指定要运行的基准测试函数,该参数是一个正则表达式,用于匹配要执行的基准测试的名字,默认值为空,"."代表运行所有基准测试函数。

我运行的基准测试的结果是:

goos: darwin
goarch: arm64
pkg: test/word
cpu: Apple M4
BenchmarkIsPalindrome
BenchmarkIsPalindrome-10    	 9804885	       112.6 ns/op
PASS

其中BenchmarkIsPalindrome-10当中的10对应的是运行时 GOMAXPROCES 的值,这对于一些与并发相关的基准测试而言是重要的信息。

报告显示IsPalindrome函数花费0.1126微秒,是执行9804885次的平均时间。循环在基准测试函数内部实现,而不是放在基准测试框架内实现,这样可以让每个基准测试函数有机会在循环启动前初始化代码。

基于基准测试和普通测试,我们可以轻松地测试新的有关程序性能改进的想法。

剖析

对于很多程序员来说,判断哪部分是关键的性能瓶颈,是很容易犯经验上的错误的,因此一般应该借助测量工具来证明。

当我们想仔细观察程序的运行速度时,最好的方法是性能剖析。剖析技术是基于程序执行期间的一些自动抽样,然后在收尾时进行推断;最后产生的统计结果就称为剖析数据。

Go 支持多种类型的剖析性能分析,每一种关注不同的方面,它们都涉及到每个采样记录的感兴趣的一系列事件消息,每个事件都包含函数调用时的堆栈信息。内建的go test工具对集中分析方式都提供了支持。

CPU 剖析数据标识了最耗 CPU 时间的函数。每个 CPU 上运行的线程每隔几毫秒都会遇到 OS 的中断时间,每次中断都会记录一个剖析数据然后恢复正常的运行。

堆剖析标识了最耗内存的语句。剖析库会记录调用内部内存分配的操作,平均每 512KB 的内存申请会触发一个剖析数据。

阻塞剖析记录阻塞 goroutine 最久的操作,例如系统调用、管道发送和接收,还有获取锁等。每当 goroutine 被这些操作阻塞时,剖析库都会记录相应的事件。

只需要开启下面其中一个表示参数,就可以生成各种剖析文件(CPU 剖析、堆剖析、阻塞剖析)。当同时使用多个标志参数时,需要小心,因为分析操作之间可能会互相影响。

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

对于一些非测试程序,也很容易进行剖析。在具体实现上,剖析针对段时间运行的小程序和长时间运行的服务有很大不同。剖析对于长期运行的程序尤其有用,因此可以通过调用 Go 的 runtime API 来启用运行时剖析

一旦我们收集到了用于分析的采样数据,我们就可以使用pprof来分析这些数据。这是 Go 工具箱自带的工具,但并不是一个日常工具,它对应go tool pprof命令。该命令有许多特性和选项,但最基本的是两个参数:生成这个概要文件的可执行程序和对应的剖析数据。

为了提高分析效率,减少空间,分析日志本身不包含函数的名字,它只包含函数对应的地址。也就是说,pprof 需要对应的可执行程序来解读剖析数据。

下例演示了如何收集并展示一个 CPU 分析文件。我们选择net/http包的一个基准测试为例。通常,最好对业务关键代码专门设计基准测试。由于简单的基准测试没法代表业务场景,因此我们使用-run=NONE参数来禁止简单的测试。

在命令行当中输入以下语句(注意,和原本《Go 语言圣经》当中的语句不一样,原文的语句在我的设备上执行,无法得到结果):

$ go test -bench=ClientServerParallelTLS64 -cpuprofile=cpu.log -benchtime=5s net/http
PASS
ok      net/http        16.864s

上述这个语句段会让go test命令对 Go 的标准库net/http做基准测试,并生成 CPU 性能分析数据。以下是每个参数的含义:

  • -bench=ClientServerParallelTLS64:制定了要运行的基准测试函数;
  • -cpuprofile=cpu.log:生成 CPU 性能分析文件,分析的数据会写入cpu.log文件当中,后续可以使用go tool pprof cpu.log对该文件进行分析;
  • -benchtime=5s:控制每个基准测试的运行时间。默认情况下,go test会自动决定基准测试的运行时长(比如一秒)。-benchtime=5s强制每个基准测试至少运行五秒,使得测试结果更加稳定(尤其是在高并发场景当中)。需要注意的是,也可以指定基准测试的运行次数:-benchtime=100x表示运行 100 次;
  • net/http:制定了要测试的包。
  • 这条语句隐含了-run=NONE,也就是会跳过普通测试,只运行基准测试。还隐含了-count=1,默认只运行一次,可通过-count=N重复运行,取平均值使得结果更准确。

再使用go tool pprofcpu.log进行分析:

$ go tool pprof -text -nodecount=10 ./http.test cpu.log                              
File: http.test
Type: cpu
Time: 2025-06-23 15:49:06 CST
Duration: 16.84s, Total samples = 4.38s (26.00%)
Showing nodes accounting for 3.61s, 82.42% of 4.38s total
Dropped 288 nodes (cum <= 0.02s)
Showing top 10 nodes out of 219
      flat  flat%   sum%        cum   cum%
     1.76s 40.18% 40.18%      1.76s 40.18%  syscall.syscall
     0.42s  9.59% 49.77%      0.42s  9.59%  runtime.kevent
     0.35s  7.99% 57.76%      0.35s  7.99%  runtime.pthread_cond_wait
     0.25s  5.71% 63.47%      0.25s  5.71%  runtime.pthread_cond_signal
     0.25s  5.71% 69.18%      0.25s  5.71%  runtime.pthread_kill
     0.18s  4.11% 73.29%      0.18s  4.11%  runtime.madvise
     0.15s  3.42% 76.71%      0.15s  3.42%  addMulVVWx
     0.13s  2.97% 79.68%      0.13s  2.97%  runtime.usleep
     0.08s  1.83% 81.51%      0.23s  5.25%  runtime.scanobject
     0.04s  0.91% 82.42%      0.04s  0.91%  crypto/internal/fips140/bigmod.(*Nat).assign

参数-text用于指定输出格式,在这里每行是一个函数,根据 CPU 的时间长短来排序。-nodecount=10限制了只输出前 10 行的结果。对于严重的性能问题,这个文本格式基本可以帮助查明原因。

对于一些更微妙的问题,可以尝试使用pprof的图形显示功能,这需要安装 GraphViz 工具。

示例函数

第三种被go test特别对待的函数是示例函数,它以Example为函数名开头。示例函数没有函数参数和返回值。下例是IsPalindrome的示例函数:

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

示例函数有三个用处。最主要的一个是作为文档:一个包的例子可以更简洁直观的方式来演示函数的用法,比文字描述更直接易懂,特别是作为一个提醒或快速参考时。一个示例函数也可以方便展示属于同一个接口的几种类型或函数之间的关系,所有的文档都必须关联到一个地方,就像一个类型或函数声明都统一到包一样。同时,示例函数和注释并不一样,示例函数是真实的Go代码,需要接受编译器的编译时检查,这样可以保证源代码更新时,示例代码不会脱节

根据示例函数的后缀名部分,godoc 这个web文档服务器会将示例函数关联到某个具体函数或包本身,因此ExampleIsPalindrome示例函数将是IsPalindrome函数文档的一部分,Example 示例函数将是包文档的一部分。

示例函数的第二个用处是,在go test执行测试的时候也会运行示例函数测试。如果示例函数内含有类似上面例子中的// Output:格式的注释,那么测试工具会执行这个示例函数,然后检查示例函数的标准输出与注释是否匹配。

示例函数的第三个作用是,可以当做一个真实函数运行的模拟。http://golang.org是由 godoc 提供的文档服务,它使用 Go Playground 让用户可以在浏览器编辑和运行每一个示例函数。


网站公告

今日签到

点亮在社区的每一天
去签到