1949年,EDSAC(第一台存储程序计算机)的开发者莫里斯·威尔克斯在他的实验室楼梯上攀登时突然领悟到一件令人震惊的事情。在《一位计算机先驱的回忆录》中,他回忆道:“我突然完全意识到,我余生中的很大一部分时间将花在查找我自己程序中的错误上。” 毫无疑问,自那以后,每个存储程序计算机的程序员都能理解威尔克斯的感受。
当然,如今的程序比威尔克斯时代要庞大和复杂得多,大量的工作已经花在了使这种复杂性可控的技术上。有两种技术尤其突出有效。首先是在部署之前对程序进行常规的同行评审。第二,也是本章讨论的主题,是测试。测试,隐含地指的是自动化测试,是编写小型程序的实践,用于检查被测试的代码(生产代码)在特定输入下的行为是否符合预期,这些输入通常要么经过精心选择以测试特定功能,要么随机选择以确保广泛覆盖。
软件测试领域是庞大的。测试任务占据了所有程序员的部分时间,而有些程序员则全身心地投入其中。关于测试的文献包括成千上万本印刷书籍和数百万字的博客文章。在每种主流编程语言中,都有数十种旨在进行测试构建的软件包,其中一些具有大量理论,这个领域似乎吸引了不少拥有追随者的先知。这几乎足以使程序员相信,要编写有效的测试,他们必须掌握一整套新的技能。
与此相比,Go的测试方法似乎相当低科技。它依赖于一个命令go test,以及一组编写测试函数的约定,go test可以运行这些函数。这种相对轻量级的机制对于纯测试是有效的,并且自然地扩展到了用于基准测试和文档中编写的系统示例。
在实践中,编写测试代码与编写原始程序本身并没有太大的区别。我们编写简短的函数,专注于任务的某一部分。我们必须小心处理边界条件,考虑数据结构,并思考计算应该从适当输入产生什么结果。但这与编写普通的Go代码的过程相同;它不需要新的符号、约定和工具。
11.1 go test工具
go test子命令是针对按照特定约定组织的Go包的测试驱动程序。在一个包目录中,文件名以_test.go结尾的文件通常不是由go build构建的包的一部分,但当由go test构建时,则是其中的一部分。
在*_test.go文件中,有三种类型的函数被特别对待:测试函数、基准测试函数和示例函数。测试函数是以Test开头的函数,用于测试一些程序逻辑的正确行为;go test调用测试函数并报告结果,结果可以是PASS或FAIL。基准测试函数以Benchmark开头的名称,用于测量某个操作的性能;go test报告操作的平均执行时间。而示例函数以Example开头,提供机器可检查的文档。我们将在第11.2节详细介绍测试,第11.4节介绍基准测试,第11.6节介绍示例。
go test工具扫描*_test.go文件以查找这些特殊函数,生成一个临时的main包以适当的方式调用它们,构建并运行它,报告结果,然后清理。
11.2 Test函数
每个测试文件都必须导入testing包。测试函数具有以下签名:
func TestName(t *testing.T) {// ...
}
测试函数的名称必须以Test开头;可选的后缀Name必须以大写字母开头。
func TestSin(t *testing.T) { /* ... */ }
func TestCos(t *testing.T) { /* ... */ }
func TestLog(t *testing.T) { /* ... */ }
参数t提供了报告测试失败和记录额外信息的方法。让我们定义一个示例包gopl.io/ch11/word1,其中包含一个名为IsPalindrome的函数,该函数报告一个字符串是否正向和反向读取相同(如果字符串是回文的,这个实现会将每个字节测试两次;我们稍后会回到这一点)。
// Package word provides utilities for word games.
package word// IsPalindrome reports whether s reads the same forward and backward.
// (Our first attempt.)
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。每个函数都检查IsPalindrome对单个输入给出正确答案,并使用t.Error报告:
package wordimport "testing"func TestPalindrome(t *testing.T) {if !IsPalindrome("detartrated") {// ``中的内容被视为原始字符串,即其中转义字符和特殊字符都会被字面化,不进行任何处理t.Error(`IsPalindrome("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)命令将操作当前目录中的包。我们可以使用以下命令构建并运行测试:
测试通过了,我们就开始发布程序,但刚一启动派对,错误报告就开始涌现了。一位名叫诺埃尔·伊夫·埃利昂的法国用户抱怨说IsPalindrome没有识别出"été"。另一位来自中美洲的用户则感到失望,因为它认为"A man, a plan, a canal: Panama"不是一个回文串。这些具体而小的错误报告自然而然地促使我们创建新的测试案例。
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)}
}
为了避免重复编写长输入字符串,我们使 Errorf,它提供了类似Printf的格式化功能。
当这两个新的测试被添加后,go test命令失败,并提供了详细的错误信息。
首先编写测试并观察它是否触发了用户报告的相同失败是一个良好的实践。只有这样,我们才能确信无论我们想出了什么修复方法,都能解决正确的问题。
作为额外的好处,运行go test比手动按照错误报告中描述的步骤更快,这样我们就可以更快地迭代。如果测试套件包含许多耗时的测试,我们可以选择性地运行其中的哪些测试,这样进展可能会更快。
使用-v标志会打印出包中每个测试的名称和执行时间。
而-run标志,其参数是一个正则表达式,会使go test只运行那些函数名称与该模式匹配的测试:
当然,一旦我们让选定的测试通过了,我们应该以没有任何标志的方式调用go test,以便在提交更改之前最后一次运行整个测试套件。
现在我们的任务是修复这些错误。快速调查发现了第一个错误的原因是IsPalindrome使用了字节序列而不是rune序列,因此像"été"中的非ASCII字符会导致混淆。第二个错误是由于没有忽略空格、标点和字母大小写而产生的。
有了教训后,我们更加谨慎地重写这个函数:
// IsPalindrome reports whether s reads the same forward and backward.
// Letter case is ignored, as are non-letters.
func IsPalindrome(s string) bool {var letters []runefor _, r := range s {// 返回r是否是字符,包括中文字符、法文字符、英文字符等所有文字的字符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
}
我们还编写了一个更全面的测试用例集,将所有先前的测试用例以及一些新的测试用例结合到一个表格中。
func TestIsPalindrome(t *testing.T) {var tests = []struct {input stringwant 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-palindrone{"你好", false},{"你好你", true},}for _, test := range tests {if got := IsPalindrome(test.input); got != test.want {t.Errorf("IsPalindrome(%q) = %v", test.input, got)}}
}
我们的新测试通过了:
这种表驱动测试的风格在Go中非常常见。根据需要添加新的表条目非常直接,而且由于断言逻辑不会重复,我们可以投入更多精力来生成良好的错误消息。
目前,失败测试的输出不包括调用t.Errorf时的完整堆栈跟踪。t.Errorf也不会引发panic或停止测试的执行,这与许多其他语言的测试框架中的断言失败不同。测试相互独立。如果表中的前面条目导致测试失败,后续的表条目仍然会被检查,因此我们可以在单次运行中了解到多个失败。
当我们真正需要停止一个测试函数时,也许是因为一些初始化代码失败了,或者为了防止已报告的失败导致其他失败的混乱级联,我们使用t.Fatal或t.Fatalf。这些必须从与Test函数相同的goroutine中调用,而不是从测试期间创建的另一个goroutine。
测试失败消息通常为"f(x) = y,want z"的形式,其中f(x)解释了尝试的操作及其输入,y是实际结果,z是期望结果。方便的情况下,例如我们的回文示例,f(x)部分是实际的Go语法。在表驱动测试中,显示x尤为重要,因为给定的断言会多次执行,而且输入值不同。避免使用模板化和冗余的信息。当测试布尔函数(例如 IsPalindrome)时,省略want z部分,因为它不添加任何信息。如果x、y或z很长,则只需打印相关部分的简明摘要。
11.2.1 随机测试
表驱动测试对于检查函数在精心选择的输入上是否工作得很好非常方便,以便对逻辑中的有趣情况进行测试。另一种方法是随机化测试,通过随机构造输入来探索更广泛范围的输入。
给定一个随机输入,我们如何知道函数的输出会是什么呢?有两种策略。第一种是编写一个替代实现,该实现使用效率较低但更简单和更清晰的算法,并检查两个实现是否给出相同的结果。第二种方法是根据一种模式创建输入值,以便我们知道应该期望什么输出。
下面的示例使用了第二种方法: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 24runes := make([]rune, n)for i := 0; i < (n+1)/2; i++ {r := rune(rng.Intn(0x1000)) // random rune up to '\u0999'runes[i] = rrunes[n-1-i] = r}return string(runes)
}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)}}
}
由于随机化测试是非确定性的,因此关键在于失败测试的日志记录足够的信息以重现失败。在我们的示例中,对IsPalindrome的输入告诉了我们所有需要知道的信息,但对于接受更复杂输入的函数来说,记录伪随机数生成器的种子(如上所述)可能比转储整个输入数据结构更简单。有了种子值,我们可以轻松地修改测试以确定性地重播失败。
通过使用当前时间作为随机性的源,每次运行测试时,测试都会探索新颖的输入,在其整个生命周期内。如果您的项目使用自动化系统定期运行所有测试,这一点尤为重要。
11.2.2 测试命令
go test工具对于测试库包非常有用,但稍加努力,我们也可以用它来测试命令。一个名为main的包通常会生成一个可执行程序,但它也可以作为一个库被导入。
让我们为第2.3.2节的echo程序编写一个测试。我们将程序分成了两个函数:echo执行实际工作,而main解析并读取标志值,并报告echo返回的任何错误。
// Echo prints its command-line arguments.
package mainimport ("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 testingfunc 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添加了参数,以减少其对全局变量的依赖。话虽如此,我们也引入了另一个全局变量,out,即结果将写入的io.Writer。通过让echo通过这个变量写入,而不是直接写入到os.Stdout,测试可以替换一个不同的Writer实现,用于记录后续检查所写的内容。以下是测试,保存在文件echo_test.go中。
package mainimport ("bytes""fmt""testing"
)func TestEcho(t *testing.T) {var tests = []struct {newline boolsep stringargs []stringwant 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 outputif 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并定义了一个main函数,在测试期间,该包充当一个库,向测试驱动程序公开TestEcho函数;其main函数会被忽略。
通过将测试组织成表格,我们可以轻松地添加新的测试用例。让我们通过向表格中添加以下行来查看测试失败时会发生什么:
go test会打印以下内容:
错误消息按照尝试的操作(使用类似Go的语法)、实际行为和预期行为的顺序描述。有了这样的信息性错误消息,甚至在找到测试源代码之前,你可能已经对根本原因有了一个相当好的想法。
被测试的代码不应调用log.Fatal或os.Exit,因为这些函数会立即停止进程;调用这些函数应该被视为main函数的专属权利。如果发生完全意外的情况,函数发生panic,测试驱动程序将会recover,尽管测试当然会被视为失败。由于用户输入错误、缺少文件或不正确的配置等原因导致的预期错误应该通过返回非空错误值来报告。幸运的是,我们的echo示例非常简单,它永远不会返回非空错误。
11.2.3 白盒测试
对测试进行分类的一种方法是根据测试所需的对被测试包内部工作的了解程度。黑盒测试不假设任何关于包的内部工作的信息,除了其API所暴露的内容和其文档所指定的内容;包的内部是不透明的。相比之下,白盒测试具有特权访问包的内部函数和数据结构,并且可以观察和更改普通客户端无法进行的操作。例如,白盒测试可以检查包的数据类型在每个操作后是否保持不变(白盒的名称是传统的,但清晰盒子(clear box)更准确。)
这两种方法是互补的。黑盒测试通常更健壮,随着软件的演变需要的更新更少。它们还有助于测试作者更关注包的用户,并可以揭示API设计中的缺陷。相比之下,白盒测试可以更详细地覆盖实现中的复杂部分。
我们已经看到了这两种测试的示例。TestIsPalindrome只调用了导出函数IsPalindrome,因此是一个黑盒测试。TestEcho调用了echo函数并更新了全局变量out,这两者都是未导出的,因此它是一个白盒测试。在开发TestEcho时,我们修改了echo函数,使其在写出输出时使用了包级别的变量out,以便测试可以用替代实现替换标准输出,并记录数据以供以后检查。使用相同的技术,我们可以用易于测试的“假”实现替换生产代码的其他部分。假实现的优点是它们可能更简单易配置、更可预测、更可靠、更易观察。它们还可以避免不良副作用,例如更新生产数据库或收取信用卡费用。
以下代码展示了一个网络存储服务中提供的配额检查逻辑。当用户超出其配额的90%时,系统会发送警告邮件。
package storageimport ("fmt""log""net/smtp"
)func bytesInUse(username string) int64 { return 0 /* ... */ }// Email sender configuration.
// NOTE: never put passwords 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 // 1GBpercent := 100 * used / quotaif 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中。
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 // 1GBpercent := 100 * used / quotaif percent < 90 {return // OK}msg := fmt.Sprintf(template, used, percent)notifyUser(username, msg)
}
现在我们可以编写一个测试,它使用一个简单的虚拟通知机制,而不是发送真实的电子邮件。这个测试会记录已通知的用户和消息的内容。
package storageimport ("strings""testing"
)func TestCheckQuotaNotifiesUser(t *testing.T) {var notifiedUser, notifiedMsg stringnotifyUser = 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不再像应该的那样工作,因为它仍然使用测试的虚拟notifyUsers实现(当更新全局变量时,总是存在这种风险)。我们必须修改测试以恢复先前的值,以便后续测试观察不到影响,并且我们必须在所有执行路径上执行此操作,包括测试失败和panic。这自然而然地暗示了defer。
func TestCheckQuotaNotifiesUser(t *testing.T) {// Save and restore original notifyUser.saved := notifyUserdefer func() { notifyUser = saved }()// Install the test's fake notifyUser.var notifiedUser, notifiedMsg stringnotifyUser = func(user, msg string) {notifiedUser, notifiedMsg = user, msg}// ...rest of test...
}
这种模式可以用于临时保存和恢复各种全局变量,包括命令行标志、调试选项和性能参数;安装和移除导致生产代码调用某些测试代码的hook;以及诱导生产代码进入少见但重要的状态,比如超时、错误,甚至特定的并发活动交错。
以这种方式使用全局变量是安全的,因为go test通常不会同时运行多个测试。
11.2.4 外部测试包
考虑到net/url包提供了一个URL解析器,而net/http包则提供了一个Web服务器和HTTP客户端库。正如我们可能期望的那样,高层的net/http依赖于低层的net/url。然而,net/url中的一个测试是一个示例,演示了URL与HTTP客户端库之间的交互。换句话说,一个低层级的包的测试导入了一个高层级的包。
在net/url包中声明这个测试函数会在包导入图中创建一个循环,如图11.1中的向上箭头所示,但正如我们在第10.1节中解释的那样,Go规范禁止导入循环。
我们通过在外部测试包中声明测试函数来解决这个问题,也就是在net/url目录中的一个文件中,其包声明为package url_test。额外的后缀_test是向go test发出的信号,告诉它应该构建一个额外的包,其中只包含这些文件,并运行其测试。你可以认为这个外部测试包的导入路径是net/url_test,但它不能以此或任何其他名称导入。
因为外部测试包位于单独的包中,它们可以导入也依赖于被测试包的帮助包;而内部包测试则不能这样做。就设计层面而言,外部测试包在逻辑上比其所依赖的两个包都更高,如图11.2所示。
通过避免导入循环,外部测试包允许测试,特别是集成测试(测试多个组件的交互),自由地导入其他包,就像应用程序一样。
我们可以使用go list工具来总结包目录中哪些Go源文件是生产代码、包内测试和外部测试。我们将使用fmt包作为示例。GoFiles是包含生产代码的文件列表;这些文件是go build将包含在你的应用程序中的文件:
TestGoFiles是fmt包的文件列表,这些文件的名称都以_test.go结尾,它们仅在构建测试时包含:
软件包的测试通常会放在这些文件中,不过有些例外情况,比如fmt包没有测试文件。稍后我们会解释export_test.go文件的作用。
XTestGoFiles输出的是外部测试包fmt_test的文件列表,所以这些文件必须导入fmt包才能使用它。再次强调,它们仅在测试期间被包含进来。:
有时外部测试包可能需要对被测试的包的内部进行特权访问,例如,如果白盒测试必须存在于一个单独的包中以避免导入循环。在这种情况下,我们使用一个技巧:我们在一个内部的_test.go文件中添加声明来暴露必要的内部内容给外部测试使用。这个文件为测试提供了对包的一个“后门”。如果源文件仅用于此目的,并且不包含测试本身,通常称为export_test.go。
例如,fmt包的实现需要unicode.IsSpace功能作为fmt.Scanf的一部分。为了避免创建不必要的依赖,fmt不导入unicode包及其庞大的数据表;相反,它包含了一个更简单的实现,称为isSpace。
为了确保fmt.isSpace和unicode.IsSpace的行为不会分歧,fmt谨慎地包含了一个测试。这是一个外部测试,因此它无法直接访问isSpace,所以fmt通过声明一个导出的变量来持有内部的isSpace函数来为其打开一个后门。这是fmt包的export_test.go文件的全部内容:
package fmtvar IsSpace = isSpace
这个测试文件并没有定义任何测试;它只是声明了导出的符号fmt.IsSpace,供外部测试使用。这个技巧也可以在外部测试需要使用白盒测试技术的情况下使用。
11.2.5 编写有效测试
许多初学者对Go的测试框架的极简主义感到惊讶。其他语言的测试框架提供了识别测试函数的机制(通常使用反射或元数据),以及在测试运行之前和之后执行“设置”和“清理”操作的hook,以及用于断言常见的断言、比较值、格式化错误消息和在测试失败时终止的实用函数库(通常使用异常)。
尽管这些机制可以使测试非常简洁,但由此产生的测试经常看起来像是用其他语言编写的。此外,尽管它们可能正确地报告PASS或FAIL,但它们的方式可能对不幸的维护者不友好,带有晦涩的失败消息,如“assert: 0 == 1”或一页页的堆栈跟踪。
Go对测试的态度截然不同。它期望测试作者自己完成大部分工作,定义函数以避免重复,就像他们为普通程序所做的一样。测试的过程并不是机械地填写表格;测试也有用户界面,尽管它的唯一用户也是它的维护者。一个好的测试在失败时不会爆炸,而是打印出问题的症状的清晰而简洁的描述,以及可能与上下文相关的其他相关事实。理想情况下,维护者不应该需要阅读源代码来辨别测试失败。一个好的测试在一个失败后不会放弃,而应该尝试在单次运行中报告多个错误,因为失败的模式本身可能是有启示性的。
下面的断言函数比较两个值,构造一个通用的错误消息,并停止程序。它易于使用并且是正确的,但当它失败时,错误消息几乎是无用的。它没有解决提供良好用户界面的难题。
import ("fmt""strings""testing"
)// A poor assertion function.
func assertEqual(x, y int) {if x != y {panic(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) returned %d words, want %d",s, sep, got, want)}// ...
}
现在,测试报告了调用的函数、其输入、以及结果的含义;它明确标识了实际值和期望值;即使此断言失败,测试也会继续执行。一旦我们编写了这样的测试,通常自然的下一步不是定义一个函数来替换整个if语句,而是在一个循环中执行测试,其中s、sep和want变化,就像IsPalindrome的表驱动测试一样。
前面的例子不需要任何实用函数,但当它们有助于简化代码时,当然不应该阻止我们引入函数(我们将在第13.3节中看到一个这样的实用函数,reflect.DeepEqual)。一个好的测试的关键是从实现你想要的具体行为开始,然后再使用函数简化代码并消除重复。最好的结果很少是通过从抽象的、通用的测试函数库开始得到的。
11.2.6 避免脆弱的测试
一个经常在遇到新的但有效的输入时失败的应用被称为"buggy";一个当程序发生合理更改时错误地失败的测试被称为"brittle"。正如一个有bug的程序会让用户感到沮丧一样,一个脆弱的测试也会让维护者感到恼火。最脆弱的测试,几乎对于生产代码的任何更改都会失败,无论是好是坏,有时这样的测试被称为change detector或现状测试,而处理它们所花费的时间往往会迅速消耗它们曾经提供的任何好处。
当被测试的函数生成复杂的输出,比如一个长字符串、一个复杂的数据结构或一个文件时,很容易去检查输出是否完全等于在编写测试时期望的某个"黄金"值。但随着程序的演化,输出的一部分很可能会改变,可能是好的方向,但无论如何都会发生变化。而且不仅仅是输出;具有复杂输入的函数通常会失败,因为在测试中使用的输入不再有效。避免脆弱测试的最简单方法是只检查你关心的属性。首选测试程序的简单和更稳定的接口,而不是其内部函数。在你的断言中要有选择性。例如,不要检查完全相等的字符串匹配,而是寻找随着程序演化而保持不变的相关子字符串。通常值得编写一个实质性的函数来将复杂的输出精炼为其本质,以便断言会更可靠。尽管这可能看起来需要大量的前期努力,但它可以很快在本应该花费在修复错误测试上的时间中得到回报。
11.3 覆盖率
由于其性质,测试永远不会完成。0正如有影响力的计算机科学家Edsger Dijkstra所说,“测试旨在发现bug,而不是证明它们不存在。” 任何数量的测试都无法证明一个软件包没有bug。最多,它们增加了我们对软件包在各种重要场景中良好工作的信心。
测试套件对被测试软件包的覆盖程度被称为测试的覆盖率。覆盖率无法直接量化——除了最微不足道的程序之外的所有程序的动态都超出了精确的测量范围——但有一些启发式方法可以帮助我们将测试努力引导到更有可能有用的地方。
语句覆盖是这些启发式方法中最简单且最常用的。测试套件的语句覆盖率是在测试期间至少执行一次的源代码语句的分数。在本节中,我们将使用Go的覆盖工具,它已集成到go test中,来测量语句覆盖率,并帮助识别测试中明显的空白。
下面的代码是我们在第7章中构建的表达式求值器的表驱动测试示例:
func TestConverage(t *testing.T) {var tests = []struct {input stringenv Envwant 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}// 以科学计数法输出,并保留6位有效数字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工具命令运行Go工具链中的一个可执行程序。这些程序位于目录$GOROOT/pkg/tool/${GOOS}_${GOARCH}
中。多亏了go build,我们很少需要直接调用它们。
现在我们使用-coverprofile标志运行测试(go test的-run标志指定了一个正则表达式,只运行匹配该正则表达式的测试函数):
该标志启用了覆盖率数据的收集,通过对生产代码进行插桩。换句话说,它修改源代码的副本,以便在执行每个语句块之前设置一个布尔变量,每个语句块都有一个变量。在修改后的程序即将退出之前,它将每个变量的值写入指定的日志文件 c.out,并打印执行的语句块的比例摘要(如果你只需要摘要,使用go test -cover)。
如果使用-covermode=count标志运行go test,则对于每个语句块的插桩会增加一个计数器,而不是设置一个布尔值。每个语句块的执行计数的结果日志可以使我们量化地比较“热门”的语句块(更频繁执行的)和“冷门”的语句块。
在收集了数据之后,我们运行覆盖工具,它会处理日志,生成一个HTML报告,并在新的浏览器窗口中打开它。
如果语句被覆盖,则以绿色显示,如果未被覆盖,则以红色显示,并且为了清晰起见,将未覆盖的文本的背景进行了阴影处理。通过观察覆盖率报告,我们立即发现我们的输入中没有一个能触发一元运算符Eval方法。如果我们将这个新的测试用例添加到表格中,并重新运行之前的两个命令,那么一元表达式的代码将变为绿色。
然而,这两个panic语句仍然是红色的。这并不奇怪,因为这些语句被认为是无法到达的。
实现100%的语句覆盖率听起来像是一个崇高的目标,但在实践中通常是不可行的,也不太可能是一个很好的努力方向。仅仅因为一条语句被执行了,并不意味着它是没有bug的;包含复杂表达式的语句必须以不同的输入多次执行,以覆盖有趣的情况。有些语句,比如上面的panic语句,永远不可能被执行到。而另一些,比如处理奇特错误的语句,在实践中很难测试到,但很少被执行到。测试基本上是一种实用主义的努力,是在编写测试的成本和由测试可以预防的故障成本之间的权衡。覆盖率工具可以帮助识别最薄弱的地方,但设计好的测试用例需要与一般编程一样的严谨思考。
11.4 Benchmark函数
基准测试是在固定工作负载上测量程序性能的实践。在Go中,一个基准测试函数看起来像一个测试函数,但是以Benchmark前缀开头,并带有一个testing.B参数,它提供了大部分与testing.T相同的方法,另外还有一些与性能测量相关的额外方法。它还暴露了一个整数字段N,用于指定要执行的操作的次数。
以下是对IsPalindrome进行基准测试的示例,它在循环中调用了N 次IsPalindrome函数。
import "testing"func BenchmarkIsPalindrome(b *testing.B) {for i := 0; i < b.N; i++ {IsPalindrome("A man, a plan, a canal: Panama")}
}
我们使用下面的命令来运行它。与测试不同,默认情况下不运行任何基准测试。-bench标志的参数选择要运行哪些基准测试。它是一个正则表达式,匹配基准测试函数的名称,其默认值与所有基准测试函数名称都不匹配。'.'模式导致它匹配单词包中的所有基准测试,但由于只有一个,-bench=IsPalindrome也将是等效的。
基准测试名称的数字后缀,这里是8,表示了GOMAXPROCS的值,对于并发基准测试很重要。
报告告诉我们,每次调用IsPalindrome大约花费1.035微秒,这是运行100万次的平均值。由于基准测试运行器最初不知道操作需要多长时间,它会使用N的小值进行一些初始测量,然后推断出一个足够大的值以稳定地测量时间。
基准函数中实现循环,而不是在测试驱动代码中实现,是为了让基准函数有机会在循环外执行任何必要的一次性设置代码,而不会增加每次迭代的测量时间。如果这些设置代码仍然干扰结果,testing.B参数提供了方法来停止、恢复和重置计时器,但这些很少需要使用。
现在我们有了基准测试和测试,就可以轻松尝试一些提高程序运行速度的想法。也许最明显的优化是让IsPalindrome的第二个循环在中点处停止检查,以避免每次比较都重复进行。
n := len(letters) / 2for i := 0; i < n; i++ {if letters[i] != letters[len(letters)-1-i] {return false}}return true
然而,就像经常发生的情况一样,一个明显的优化并不总是能带来预期的好处。在一个实验中,这个优化仅仅带来了4%的改进。
另一个想法是预先分配一个足够大的数组来存储字母,而不是通过连续的append调用来扩展它。像这样将letters声明为正确大小的数组:
letters := make([]rune, 0, len(s))for _, r := range s {if unicode.IsLetter(r) {letters = append(letters, unicode.ToLower(r))}}
采用这种方式声明,几乎带来了35%的改进,而基准测试现在报告的是2,000,000次迭代上的平均值。
正如这个例子所示,通常情况下,最快的程序往往是使用最少内存分配的程序。使用-benchmem命令行标志将会在报告中包含内存分配的统计信息。在这里,我们比较了优化之前的分配数量:
和优化后的分配数量:
将内存分配合并为单个调用可消除75%的内存分配,并将分配的内存量减半。
像这样的基准测试告诉我们特定操作所需的绝对时间,但在许多情况下,我们关注的性能问题是两个不同操作的相对时间。例如,如果一个函数处理1,000个元素需要1毫秒,那么处理10,000个或一百万个元素需要多长时间?这样的比较可以揭示函数运行时间的渐近增长。另一个例子:I/O缓冲区的最佳大小是多少?针对一系列大小的应用吞吐量的基准测试可以帮助我们选择提供满意性能的最小缓冲区。第三个例子:对于给定的任务,哪种算法表现最佳?对同一输入数据评估两种不同算法的基准测试通常可以展示它们在重要或代表性工作负载上的优势和劣势。
相对性的(而非绝对性的)基准测试只是普通的代码。它们通常采用一个参数化函数的形式,从几个具有不同值的基准函数中调用,就像这样:
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指定了输入的大小,在不同的基准测试中会有所变化,但在每个基准测试中保持不变。不要试图使用参数b.N作为输入大小。除非你将其解释为固定大小输入的迭代次数,否则你的基准测试结果将毫无意义。
比较基准测试所揭示的模式在程序设计过程中尤其有用,但当程序运行良好时,我们不会将这些基准测试丢弃。随着程序的发展、输入的增长,或者在具有不同特性的新操作系统或处理器上部署,我们可以重复使用这些基准测试来重新审视设计决策。
11.5 性能剖析
基准测试对于衡量特定操作的性能非常有用,但当我们试图提升一个慢速程序的速度时,我们经常不知道从哪里开始。每个程序员都知道唐纳德·克努斯(Donald Knuth)在1974年的《Structured Programming with goto Statements》中提出的有关过早优化的格言。尽管经常被误解为意味着性能不重要,但在其原始语境中,我们可以辨别出一个不同的含义:
毫无疑问,效率的追求往往导致滥用。程序员们花费了大量的时间考虑或担心程序中非关键部分的速度,而这些对效率的尝试实际上在调试和维护时产生了强烈的负面影响。我们应该忘记关于小的效率问题,大约97%的时间都不要过分追求小的效率:过早的优化是万恶之源。
然而,我们不应该放弃在那关键的3%的机会。一个好的程序员不会被这样的理由所蒙蔽,他会明智地仔细审视关键代码;但只有在确定了那些关键代码之后才会如此。在程序的哪些部分真正关键这样的先入为主的判断通常是错误的,因为使用测量工具的程序员的普遍经验是,他们的直觉猜测经常失败。
当我们希望仔细查看程序的速度时,最好的技术是使用性能分析。性能分析是一种基于采样一定数量的性能事件并在执行过程中进行推断的自动化方法,然后在后处理步骤中从中推断出结果的统计摘要,该摘要被称为性能分析(profile)。
Go支持许多种类的性能分析,每一种都关注性能的不同方面,但它们都涉及记录一系列感兴趣的事件,每个事件都有一个伴随的堆栈跟踪——即在事件发生时处于活动状态的函数调用堆栈。go test工具内置了对多种性能分析的支持。
CPU profile用于识别执行耗费最多CPU时间的函数。每个CPU上当前运行的线程会被操作系统周期性地中断,每次中断都会记录一个CPU profile事件,然后正常执行继续。
Heap profile用于识别分配最多内存的语句。性能分析库会对内部内存分配例程进行采样,以便平均每分配512KB的内存就记录一个性能分析事件。
Blocking profile用于识别导致goroutine阻塞最长时间的操作,比如系统调用、通道的发送和接收,以及锁的获取。性能分析库会在每次goroutine被这些操作之一阻塞时记录一个事件。
为正在测试的代码收集性能分析信息就像启用下面的标志之一一样简单。但是,当同时使用多个标志时要小心:收集某种类型的性能分析信息的机制可能会影响其他类型性能分析结果的准确性。
要将性能分析支持添加到非测试程序中也很容易,尽管在短暂的命令行工具和长时间运行的服务器应用之间如何实现会有所不同。性能分析在长时间运行的应用中尤其有用,因此可以通过Go的runtime API在程序员的控制下启用。
一旦我们收集了性能分析信息,就需要使用pprof工具对其进行分析。这是Go发行版的标准组成部分,但由于它不是日常工具,所以使用go tool pprof间接访问。它有数十种特性和选项,但基本用法只需要两个参数,即生成性能分析信息的可执行文件和性能分析日志。
为了使性能分析高效并节省空间,日志不包含函数名称;相反,函数是通过它们的地址来标识的。这意味着pprof需要可执行文件才能理解日志。虽然go test通常在测试完成后丢弃测试可执行文件,但当启用性能分析时,它会将可执行文件保存为foo.test,其中foo是被测试的包的名称。
下面的命令显示了如何收集并显示一个简单的CPU性能分析。我们选择了net/http包中的一个基准测试。通常最好对具有代表性的工作负载进行性能分析,而不是对测试用例进行性能分析。测试用例几乎从来都不是代表性的,这就是为什么我们使用过滤器-run=NONE来禁用它们。
使用-text标志指定输出格式,这里是一个文本表格,每个函数占据一行,按照消耗CPU周期最多的“热点”函数排序。-nodecount=10标志将结果限制为10行。对于严重的性能问题,这种文本格式可能足以找出原因。
这个性能分析告诉我们,椭圆曲线密码学对于这个特定的HTTPS基准测试的性能很重要。相比之下,如果一个性能分析中主要由运行时包中的内存分配函数所主导,减少内存消耗可能是值得优化的。
对于更微妙的问题,你可能最好使用pprof的图形显示。这些需要GraphViz,可以从www.graphviz.org下载。然后,-web标志会呈现程序函数的有向图,由它们的CPU性能分析编号进行注释,并以颜色标注最热的函数。
在这里,我们只是浅尝辄止了Go的性能分析工具。要了解更多信息,请阅读Go博客上的“Profiling Go Programs”文章。
11.6 Example函数
go test专门处理的第三种函数类型是示例函数,其名称以Example开头。它既没有参数也没有返回值。下面是一个IsPalindrome的示例函数:
func ExampleIsPalindrome() {fmt.Println(IsPalindrome("A man, a plan, a canal: Panama"))fmt.Println(IsPalindrome("palindrome"))// Output:// true// false
}
示例函数有三个目的。主要目的是文档化:一个好的示例可以比其文档描述更简洁或更直观地传达库函数的行为,特别是当用作提醒或快速参考时。示例还可以演示一个API中多个类型和函数之间的交互,而散文文档必须始终附加到比如类型或函数声明或整个包的地方。与注释中的示例不同,示例函数是真正的Go代码,受编译时检查的约束,因此随着代码的演变,它们不会变得陈旧。
根据Example函数的后缀,基于Web的文档服务器godoc将示例函数与它们所示范的函数或包相关联,因此ExampleIsPalindrome将显示为IsPalindrome函数的文档,并且称为Example的示例函数将与整个包相关联。
第二个目的是示例是由go test运行的可执行测试。如果示例函数包含像上面那样的最后一个// Output:
注释,测试驱动程序将执行该函数,并检查其输出的文本是否与注释中的文本匹配。
示例的第三个目的是实际操作实验。golang.org上的godoc服务器使用Go Playground让用户在Web浏览器中编辑和运行每个示例函数,如图11.4所示。这通常是快速了解特定函数或语言特性的方法。
书的最后两章讨论了reflect和unsafe包,这些包很少有Go程序员会经常使用,甚至更少的人需要使用。如果你还没有编写过任何实质性的Go程序,现在是一个很好的时机。