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
测试文件中包含TestPalindrome
和TestNonPalindrome
两个测试函数:
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.Fatal
或t.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/url
和net/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.isSpace
和unicode.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 pprof
对cpu.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 让用户可以在浏览器编辑和运行每一个示例函数。