# 第十一章 測試
Maurice Wilkes,第一個存儲程序計算機EDSAC的設計者,1949年他在實驗室爬樓梯時有一個頓悟。在《計算機先驅回憶錄》(Memoirs of a Computer Pioneer)里,他回憶到:“忽然間有一種醍醐灌頂的感覺,我整個后半生的美好時光都將在尋找程序BUG中度過了”。肯定從那之后的大部分正常的碼農都會同情Wilkes過份悲觀的想法,雖然也許不是沒有人困惑于他對軟件開發的難度的天真看法。
現在的程序已經遠比Wilkes時代的更大也更復雜,也有許多技術可以讓軟件的復雜性可得到控制。其中有兩種技術在實踐中證明是比較有效的。第一種是代碼在被正式部署前需要進行代碼評審。第二種則是測試,也就是本章的討論主題。
我們說測試的時候一般是指自動化測試,也就是寫一些小的程序用來檢測被測試代碼(產品代碼)的行為和預期的一樣,這些通常都是精心設計的執行某些特定的功能或者是通過隨機性的輸入要驗證邊界的處理。
軟件測試是一個巨大的領域。測試的任務可能已經占據了一些程序員的部分時間和另一些程序員的全部時間。和軟件測試技術相關的圖書或博客文章有成千上萬之多。對于每一種主流的編程語言,都會有一打的用于測試的軟件包,同時也有大量的測試相關的理論,而且每種都吸引了大量技術先驅和追隨者。這些都足以說服那些想要編寫有效測試的程序員重新學習一套全新的技能。
Go語言的測試技術是相對低級的。它依賴一個go test測試命令和一組按照約定方式編寫的測試函數,測試命令可以運行這些測試函數。編寫相對輕量級的純測試代碼是有效的,而且它很容易延伸到基準測試和示例文檔。
在實踐中,編寫測試代碼和編寫程序本身并沒有多大區別。我們編寫的每一個函數也是針對每個具體的任務。我們必須小心處理邊界條件,思考合適的數據結構,推斷合適的輸入應該產生什么樣的結果輸出。編程測試代碼和編寫普通的Go代碼過程是類似的;它并不需要學習新的符號、規則和工具。
### 11.1. go test
go test命令是一個按照一定的約定和組織的測試代碼的驅動程序。在包目錄內,所有以_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. 測試函數
每個測試函數必須導入testing包。測試函數有如下的簽名:
~~~
func TestName(t *testing.T) {
// ...
}
~~~
測試函數的名字必須以Test開頭,可選的后綴名必須以大寫字母開頭:
~~~
func TestSin(t *testing.T) { /* ... */ }
func TestCos(t *testing.T) { /* ... */ }
func TestLog(t *testing.T) { /* ... */ }
~~~
其中t參數用于報告測試失敗和附加的日志信息。讓我們定義一個實例包gopl.io/ch11/word1,其中只有一個函數IsPalindrome用于檢查一個字符串是否從前向后和從后向前讀都是一樣的。(下面這個實現對于一個字符串是否是回文字符串前后重復測試了兩次;我們稍后會再討論這個問題。)
*gopl.io/ch11/word1*
~~~
// 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 word
import "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`命令一樣)。我們可以用下面的命令構建和運行測試。
~~~
$ cd $GOPATH/src/gopl.io/ch11/word1
$ go test
ok gopl.io/ch11/word1 0.008s
~~~
結果還比較滿意,我們運行了這個程序, 不過沒有提前退出是因為還沒有遇到BUG報告。不過一個法國名為“Noelle Eve Elleon”的用戶會抱怨IsPalindrome函數不能識別“été”。另外一個來自美國中部用戶的抱怨則是不能識別“A man, a plan, a canal: Panama.”。執行特殊和小的BUG報告為我們提供了新的更自然的測試用例。
~~~
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)
}
}
~~~
為了避免兩次輸入較長的字符串,我們使用了提供了有類似Printf格式化功能的 Errorf函數來匯報錯誤結果。
當添加了這兩個測試用例之后,`go test`返回了測試失敗的信息。
~~~
$ go test
--- FAIL: TestFrenchPalindrome (0.00s)
word_test.go:28: IsPalindrome("été") = false
--- FAIL: TestCanalPalindrome (0.00s)
word_test.go:35: IsPalindrome("A man, a plan, a canal: Panama") = false
FAIL
FAIL gopl.io/ch11/word1 0.014s
~~~
先編寫測試用例并觀察到測試用例觸發了和用戶報告的錯誤相同的描述是一個好的測試習慣。只有這樣,我們才能定位我們要真正解決的問題。
先寫測試用例的另外的好處是,運行測試通常會比手工描述報告的處理更快,這讓我們可以進行快速地迭代。如果測試集有很多運行緩慢的測試,我們可以通過只選擇運行某些特定的測試來加快測試速度。
參數`-v`可用于打印每個測試函數的名字和運行時間:
~~~
$ go test -v
=== RUN TestPalindrome
--- PASS: TestPalindrome (0.00s)
=== RUN TestNonPalindrome
--- PASS: TestNonPalindrome (0.00s)
=== RUN TestFrenchPalindrome
--- FAIL: TestFrenchPalindrome (0.00s)
word_test.go:28: IsPalindrome("été") = false
=== RUN TestCanalPalindrome
--- FAIL: TestCanalPalindrome (0.00s)
word_test.go:35: IsPalindrome("A man, a plan, a canal: Panama") = false
FAIL
exit status 1
FAIL gopl.io/ch11/word1 0.017s
~~~
參數`-run`對應一個正則表達式,只有測試函數名被它正確匹配的測試函數才會被`go test`測試命令運行:
~~~
$ go test -v -run="French|Canal"
=== RUN TestFrenchPalindrome
--- FAIL: TestFrenchPalindrome (0.00s)
word_test.go:28: IsPalindrome("été") = false
=== RUN TestCanalPalindrome
--- FAIL: TestCanalPalindrome (0.00s)
word_test.go:35: IsPalindrome("A man, a plan, a canal: Panama") = false
FAIL
exit status 1
FAIL gopl.io/ch11/word1 0.014s
~~~
當然,一旦我們已經修復了失敗的測試用例,在我們提交代碼更新之前,我們應該以不帶參數的`go test`命令運行全部的測試用例,以確保修復失敗測試的同時沒有引入新的問題。
我們現在的任務就是修復這些錯誤。簡要分析后發現第一個BUG的原因是我們采用了 byte而不是rune序列,所以像“été”中的é等非ASCII字符不能正確處理。第二個BUG是因為沒有忽略空格和字母的大小寫導致的。
針對上述兩個BUG,我們仔細重寫了函數:
*gopl.io/ch11/word2*
~~~
// Package word provides utilities for word games.
package word
import "unicode"
// 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 []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
}
~~~
同時我們也將之前的所有測試數據合并到了一個測試中的表格中。
~~~
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 gopl.io/ch11/word2
ok gopl.io/ch11/word2 0.015s
~~~
這種表格驅動的測試在Go語言中很常見的。我們很容易向表格添加新的測試數據,并且后面的測試邏輯也沒有冗余,這樣我們可以有更多的精力地完善錯誤信息。
失敗測試的輸出并不包括調用t.Errorf時刻的堆棧調用信息。和其他編程語言或測試框架的assert斷言不同,t.Errorf調用也沒有引起panic異常或停止測試的執行。即使表格中前面的數據導致了測試的失敗,表格后面的測試數據依然會運行測試,因此在一個測試中我們可能了解多個失敗的信息。
如果我們真的需要停止測試,或許是因為初始化失敗或可能是早先的錯誤導致了后續錯誤等原因,我們可以使用t.Fatal或t.Fatalf停止當前測試函數。它們必須在和測試函數同一個goroutine內調用。
測試失敗的信息一般的形式是“f(x) = y, want z”,其中f(x)解釋了失敗的操作和對應的輸出,y是實際的運行結果,z是期望的正確的結果。就像前面檢查回文字符串的例子,實際的函數用于f(x)部分。如果顯示x是表格驅動型測試中比較重要的部分,因為同一個斷言可能對應不同的表格項執行多次。要避免無用和冗余的信息。在測試類似IsPalindrome返回布爾類型的函數時,可以忽略并沒有額外信息的z部分。如果x、y或z是y的長度,輸出一個相關部分的簡明總結即可。測試的作者應該要努力幫助程序員診斷測試失敗的原因。
**練習 11.1:** 為4.3節中的charcount程序編寫測試。
**練習 11.2:** 為(§6.5)的IntSet編寫一組測試,用于檢查每個操作后的行為和基于內置map的集合等價,后面練習11.7將會用到。
### 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 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)
}
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的p參數將告訴我們真實的數據,但是對于函數將接受更復雜的輸入,不需要保存所有的輸入,只要日志中簡單地記錄隨機數種子即可(像上面的方式)。有了這些隨機數初始化種子,我們可以很容易修改測試代碼以重現失敗的隨機測試。
通過使用當前時間作為隨機種子,在整個過程中的每次運行測試命令時都將探索新的隨機數據。如果你使用的是定期運行的自動化測試集成系統,隨機測試將特別有價值。
**練習 11.3:** TestRandomPalindromes測試函數只測試了回文字符串。編寫新的隨機測試生成器,用于測試隨機生成的非回文字符串。
**練習 11.4:** 修改randomPalindrome函數,以探索IsPalindrome是否對標點和空格做了正確處理。
### 11.2.2. 測試一個命令
對于測試包`go test`是一個的有用的工具,但是稍加努力我們也可以用它來測試可執行程序。如果一個包的名字是 main,那么在構建時會生成一個可執行程序,不過main包可以作為一個包被測試器代碼導入。
讓我們為2.3.2節的echo程序編寫一個測試。我們先將程序拆分為兩個函數:echo函數完成真正的工作,main函數用于處理命令行輸入參數和echo可能返回的錯誤。
*gopl.io/ch11/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函數對全局變量的依賴。我們還增加了一個全局名為out的變量來替代直接使用os.Stdout,這樣測試代碼可以根據需要將out修改為不同的對象以便于檢查。下面就是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包,也有對應的main入口函數,但是在測試的時候main包只是TestEcho測試函數導入的一個普通包,里面main函數并沒有被導出,而是被忽略的。
通過將測試放到表格中,我們很容易添加新的測試用例。讓我通過增加下面的測試用例來看看失敗的情況是怎么樣的:
~~~
{true, ",", []string{"a", "b", "c"}, "a b c\n"}, // NOTE: wrong expectation!
~~~
`go test`輸出如下:
~~~
$ go test gopl.io/ch11/echo
--- FAIL: TestEcho (0.00s)
echo_test.go:31: echo(true, ",", ["a" "b" "c"]) = "a,b,c", want "a b c\n"
FAIL
FAIL gopl.io/ch11/echo 0.006s
~~~
錯誤信息描述了嘗試的操作(使用Go類似語法),實際的結果和期望的結果。通過這樣的錯誤信息,你可以在檢視代碼之前就很容易定位錯誤的原因。
要注意的是在測試代碼中并沒有調用log.Fatal或os.Exit,因為調用這類函數會導致程序提前退出;調用這些函數的特權應該放在main函數中。如果真的有意外的事情導致函數發生panic異常,測試驅動應該嘗試用recover捕獲異常,然后將當前測試當作失敗處理。如果是可預期的錯誤,例如非法的用戶輸入、找不到文件或配置文件不當等應該通過返回一個非空的error的方式處理。幸運的是(上面的意外只是一個插曲),我們的echo示例是比較簡單的也沒有需要返回非空error的情況。
### 11.2.3. 白盒測試
一種測試分類的方法是基于測試者是否需要了解被測試對象的內部工作原理。黑盒測試只需要測試包公開的文檔和API行為,內部實現對測試代碼是透明的。相反,白盒測試有訪問包內部函數和數據結構的權限,因此可以做到一下普通客戶端無法實現的測試。例如,一個白盒測試可以在每個操作之后檢測不變量的數據類型。(白盒測試只是一個傳統的名稱,其實稱為clear box測試會更準確。)
黑盒和白盒這兩種測試方法是互補的。黑盒測試一般更健壯,隨著軟件實現的完善測試代碼很少需要更新。它們可以幫助測試者了解真是客戶的需求,也可以幫助發現API設計的一些不足之處。相反,白盒測試則可以對內部一些棘手的實現提供更多的測試覆蓋。
我們已經看到兩種測試的例子。TestIsPalindrome測試僅僅使用導出的IsPalindrome函數,因此這是一個黑盒測試。TestEcho測試則調用了內部的echo函數,并且更新了內部的out包級變量,這兩個都是未導出的,因此這是白盒測試。
當我們準備TestEcho測試的時候,我們修改了echo函數使用包級的out變量作為輸出對象,因此測試代碼可以用另一個實現代替標準輸出,這樣可以方便對比echo輸出的數據。使用類似的技術,我們可以將產品代碼的其他部分也替換為一個容易測試的偽對象。使用偽對象的好處是我們可以方便配置,容易預測,更可靠,也更容易觀察。同時也可以避免一些不良的副作用,例如更新生產數據庫或信用卡消費行為。
下面的代碼演示了為用戶提供網絡存儲的web服務中的配額檢測邏輯。當用戶使用了超過90%的存儲配額之后將發送提醒郵件。
*gopl.io/ch11/storage1*
~~~
package storage
import (
"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 // 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函數中。
*gopl.io/ch11/storage2*
~~~
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 // 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
}
// ...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依然使用的是測試函數的偽發送郵件函數(當更新全局對象的時候總會有這種風險)。 我們必須修改測試代碼恢復notifyUsers原先的狀態以便后續其他的測試沒有影響,要確保所有的執行路徑后都能恢復,包括測試失敗或panic異常的情形。在這種情況下,我們建議使用defer語句來延后執行處理恢復的代碼。
~~~
func TestCheckQuotaNotifiesUser(t *testing.T) {
// Save and restore original notifyUser.
saved := notifyUser
defer func() { notifyUser = saved }()
// Install the test's fake notifyUser.
var notifiedUser, notifiedMsg string
notifyUser = func(user, msg string) {
notifiedUser, notifiedMsg = user, msg
}
// ...rest of test...
}
~~~
這種處理模式可以用來暫時保存和恢復所有的全局變量,包括命令行標志參數、調試選項和優化參數;安裝和移除導致生產代碼產生一些調試信息的鉤子函數;還有有些誘導生產代碼進入某些重要狀態的改變,比如超時、錯誤,甚至是一些刻意制造的并發行為等因素。
以這種方式使用全局變量是安全的,因為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包所在的目錄聲明一個獨立的url_test測試擴展包。其中測試擴展包名的`_test`后綴告訴go test工具它應該建立一個額外的包來運行測試。我們將這個擴展測試包的導入路徑視作是net/url_test會更容易理解,但實際上它并不能被其他任何包導入。
因為測試擴展包是一個獨立的包,所以可以導入測試代碼依賴的其他的輔助包;包內的測試代碼可能無法做到。在設計層面,測試擴展包是在所以它依賴的包的上層,正如圖11.2所示。

通過回避循環導入依賴,擴展測試包可以更靈活的編寫測試,特別是集成測試(需要測試多個組件之間的交互),可以像普通應用程序那樣自由地導入其他包。
我們可以用go list命令查看包對應目錄中哪些Go源文件是產品代碼,哪些是包內測試,還哪些測試擴展包。我們以fmt包作為一個例子:GoFiles表示產品代碼對應的Go源文件列表;也就是go build命令要編譯的部分。
{% raw %}
~~~
$ go list -f={{.GoFiles}} fmt
[doc.go format.go print.go scan.go]
~~~
{% endraw %}
TestGoFiles表示的是fmt包內部測試測試代碼,以_test.go為后綴文件名,不過只在測試時被構建:
{% raw %}
~~~
$ go list -f={{.TestGoFiles}} fmt
[export_test.go]
~~~
{% endraw %}
包的測試代碼通常都在這些文件中,不過fmt包并非如此;稍后我們再解釋export_test.go文件的作用。
XTestGoFiles表示的是屬于測試擴展包的測試代碼,也就是fmt_test包,因此它們必須先導入fmt包。同樣,這些文件也只是在測試時被構建運行:
{% raw %}
~~~
$ go list -f={{.XTestGoFiles}} fmt
[fmt_test.go scan_test.go stringer_test.go]
~~~
{% endraw %}
有時候測試擴展包也需要訪問被測試包內部的代碼,例如在一個為了避免循環導入而被獨立到外部測試擴展包的白盒測試。在這種情況下,我們可以通過一些技巧解決:我們在包內的一個_test.go文件中導出一個內部的實現給測試擴展包。因為這些代碼只有在測試時才需要,因此一般會放在export_test.go文件中。
例如,fmt包的fmt.Scanf函數需要unicode.IsSpace函數提供的功能。但是為了避免太多的依賴,fmt包并沒有導入包含巨大表格數據的unicode包;相反fmt包有一個叫isSpace內部的簡易實現。
為了確保fmt.isSpace和unicode.IsSpace函數的行為一致,fmt包謹慎地包含了一個測試。是一個在測試擴展包內的白盒測試,是無法直接訪問到isSpace內部函數的,因此fmt通過一個秘密出口導出了isSpace函數。export_test.go文件就是專門用于測試擴展包的秘密出口。
~~~
package fmt
var IsSpace = isSpace
~~~
這個測試文件并沒有定義測試代碼;它只是通過fmt.IsSpace簡單導出了內部的isSpace函數,提供給測試擴展包使用。這個技巧可以廣泛用于位于測試擴展包的白盒測試。
### 11.2.5. 編寫有效的測試
許多Go語言新人會驚異于它的極簡的測試框架。很多其它語言的測試框架都提供了識別測試函數的機制(通常使用反射或元數據),通過設置一些“setup”和“teardown”的鉤子函數來執行測試用例運行的初始化和之后的清理操作,同時測試工具箱還提供了很多類似assert斷言,值比較函數,格式化輸出錯誤信息和停止一個識別的測試等輔助函數(通常使用異常機制)。雖然這些機制可以使得測試非常簡潔,但是測試輸出的日志卻會像火星文一般難以理解。此外,雖然測試最終也會輸出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語句來擴展測試用例,我們可以用像IsPalindrome的表驅動測試那樣來準備更多的s和sep測試用例。
前面的例子并不需要額外的輔助函數,如果有可以使測試代碼更簡單的方法我們也樂意接受。(我們將在13.3節看到一個類似reflect.DeepEqual輔助函數。)開始一個好的測試的關鍵是通過實現你真正想要的具體行為,然后才是考慮然后簡化測試代碼。最好的接口是直接從庫的抽象接口開始,針對公共接口編寫一些測試函數。
**練習11.5:** 用表格驅動的技術擴展TestSplit測試,并打印期望的輸出結果。
### 11.2.6. 避免的不穩定的測試
如果一個應用程序對于新出現的但有效的輸入經常失敗說明程序不夠穩健;同樣如果一個測試僅僅因為聲音變化就會導致失敗也是不合邏輯的。就像一個不夠穩健的程序會挫敗它的用戶一樣,一個脆弱性測試同樣會激怒它的維護者。最脆弱的測試代碼會在程序沒有任何變化的時候產生不同的結果,時好時壞,處理它們會耗費大量的時間但是并不會得到任何好處。
當一個測試函數產生一個復雜的輸出如一個很長的字符串,或一個精心設計的數據結構或一個文件,它可以用于和預設的“golden”結果數據對比,用這種簡單方式寫測試是誘人的。但是隨著項目的發展,輸出的某些部分很可能會發生變化,盡管很可能是一個改進的實現導致的。而且不僅僅是輸出部分,函數復雜復制的輸入部分可能也跟著變化了,因此測試使用的輸入也就不在有效了。
避免脆弱測試代碼的方法是只檢測你真正關心的屬性。保持測試代碼的簡潔和內部結構的穩定。特別是對斷言部分要有所選擇。不要檢查字符串的全匹配,但是尋找相關的子字符串,因為某些子字符串在項目的發展中是比較穩定不變的。通常編寫一個重復雜的輸出中提取必要精華信息以用于斷言是值得的,雖然這可能會帶來很多前期的工作,但是它可以幫助迅速及時修復因為項目演化而導致的不合邏輯的失敗測試。
### 11.3. 測試覆蓋率
就其性質而言,測試不可能是完整的。計算機科學家Edsger Dijkstra曾說過:“測試可以顯示存在缺陷,但是并不是說沒有BUG。”再多的測試也不能證明一個程序沒有BUG。在最好的情況下,測試可以增強我們的信心:代碼在我們測試的環境是可以正常工作的。
由測試驅動觸發運行到的被測試函數的代碼數目稱為測試的覆蓋率。測試覆蓋率并不能量化——甚至連最簡單的動態程序也難以精確測量——但是可以啟發并幫助我們編寫的有效的測試代碼。
這些幫助信息中語句的覆蓋率是最簡單和最廣泛使用的。語句的覆蓋率是指在測試中至少被運行一次的代碼占總代碼數的比例。在本節中,我們使用`go test`命令中集成的測試覆蓋率工具,來度量下面代碼的測試覆蓋率,幫助我們識別測試和我們期望間的差距。
下面的代碼是一個表格驅動的測試,用于測試第七章的表達式求值程序:
*gopl.io/ch7/eval*
~~~
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 test -v -run=Coverage gopl.io/ch7/eval
=== RUN TestCoverage
--- PASS: TestCoverage (0.00s)
PASS
ok gopl.io/ch7/eval 0.011s
~~~
下面這個命令可以顯示測試覆蓋率工具的使用用法:
~~~
$ 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 tool`命令運行Go工具鏈的底層可執行程序。這些底層可執行程序放在*G**O**R**O**O**T*/*p**k**g*/*t**o**o**l*/{GOOS}_${GOARCH}目錄。因為有`go build`命令的原因,我們很少直接調用這些底層工具。
現在我們可以用`-coverprofile`標志參數重新運行測試:
~~~
$ go test -run=Coverage -coverprofile=c.out gopl.io/ch7/eval
ok gopl.io/ch7/eval 0.032s coverage: 68.5% of statements
~~~
這個標志參數通過在測試代碼中插入生成鉤子來統計覆蓋率數據。也就是說,在運行每個測試前,它會修改要測試代碼的副本,在每個詞法塊都會設置一個布爾標志變量。當被修改后的被測試代碼運行退出時,將統計日志數據寫入c.out文件,并打印一部分執行的語句的一個總結。(如果你需要的是摘要,使用`go test -cover`。)
如果使用了`-covermode=count`標志參數,那么將在每個代碼塊插入一個計數器而不是布爾標志量。在統計結果中記錄了每個塊的執行次數,這可以用于衡量哪些是被頻繁執行的熱點代碼。
為了收集數據,我們運行了測試覆蓋率工具,打印了測試日志,生成一個HTML報告,然后在瀏覽器中打開(圖11.3)。
~~~
$ go tool cover -html=c.out
~~~

綠色的代碼塊被測試覆蓋到了,紅色的則表示沒有被覆蓋到。為了清晰起見,我們將的背景紅色文本的背景設置成了陰影效果。我們可以馬上發現unary操作的Eval方法并沒有被執行到。如果我們針對這部分未被覆蓋的代碼添加下面的測試用例,然后重新運行上面的命令,那么我們將會看到那個紅色部分的代碼也變成綠色了:
~~~
{"-x * -x", eval.Env{"x": 2}, "4"}
~~~
不過兩個panic語句依然是紅色的。這是沒有問題的,因為這兩個語句并不會被執行到。
實現100%的測試覆蓋率聽起來很美,但是在具體實踐中通常是不可行的,也不是值得推薦的做法。因為那只能說明代碼被執行過而已,并不意味著代碼就是沒有BUG的;因為對于邏輯復雜的語句需要針對不同的輸入執行多次。有一些語句,例如上面的panic語句則永遠都不會被執行到。另外,還有一些隱晦的錯誤在現實中很少遇到也很難編寫對應的測試代碼。測試從本質上來說是一個比較務實的工作,編寫測試代碼和編寫應用代碼的成本對比是需要考慮的。測試覆蓋率工具可以幫助我們快速識別測試薄弱的地方,但是設計好的測試用例和編寫應用代碼一樣需要嚴密的思考。
### 11.4. 基準測試
基準測試是測量一個程序在固定工作負載下的性能。在Go語言中,基準測試函數和普通測試函數寫法類似,但是以Benchmark為前綴名,并且帶有一個`*testing.B`類型的參數;`*testing.B`參數除了提供和`*testing.T`類似的方法,還有額外一些和性能測量相關的方法。它還提供了一個整數N,用于指定操作執行的循環次數。
下面是IsPalindrome函數的基準測試,其中循環將執行N次。
~~~
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`參數是等價的效果。
~~~
$ cd $GOPATH/src/gopl.io/ch11/word2
$ go test -bench=.
PASS
BenchmarkIsPalindrome-8 1000000 1035 ns/op
ok gopl.io/ch11/word2 2.179s
~~~
結果中基準測試名的數字后綴部分,這里是8,表示運行時對應的GOMAXPROCS的值,這對于一些和并發相關的基準測試是重要的信息。
報告顯示每次調用IsPalindrome函數花費1.035微秒,是執行1,000,000次的平均時間。因為基準測試驅動器開始時并不知道每個基準測試函數運行所花的時間,它會嘗試在真正運行基準測試前先嘗試用較小的N運行測試來估算基準測試函數所需要的時間,然后推斷一個較大的時間保證穩定的測量結果。
循環在基準測試函數內實現,而不是放在基準測試框架內實現,這樣可以讓每個基準測試函數有機會在循環啟動前執行初始化代碼,這樣并不會顯著影響每次迭代的平均運行時間。如果還是擔心初始化代碼部分對測量時間帶來干擾,那么可以通過testing.B參數提供的方法來臨時關閉或重置計時器,不過這些一般很少會用到。
現在我們有了一個基準測試和普通測試,我們可以很容易測試新的讓程序運行更快的想法。也許最明顯的優化是在IsPalindrome函數中第二個循環的停止檢查,這樣可以避免每個比較都做兩次:
~~~
n := len(letters)/2
for i := 0; i < n; i++ {
if letters[i] != letters[len(letters)-1-i] {
return false
}
}
return true
~~~
不過很多情況下,一個明顯的優化并不一定就能代碼預期的效果。這個改進在基準測試中只帶來了4%的性能提升。
~~~
$ go test -bench=.
PASS
BenchmarkIsPalindrome-8 1000000 992 ns/op
ok gopl.io/ch11/word2 2.093s
~~~
另一個改進想法是在開始為每個字符預先分配一個足夠大的數組,這樣就可以避免在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次迭代的平均運行時間統計。
~~~
$ go test -bench=.
PASS
BenchmarkIsPalindrome-8 2000000 697 ns/op
ok gopl.io/ch11/word2 1.468s
~~~
如這個例子所示,快的程序往往是伴隨著較少的內存分配。`-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
~~~
用一次內存分配代替多次的內存分配節省了75%的分配調用次數和減少近一半的內存需求。
這個基準測試告訴我們所需的絕對時間依賴給定的具體操作,兩個不同的操作所需時間的差異也是和不同環境相關的。例如,如果一個函數需要1ms處理1,000個元素,那么處理10000或1百萬將需要多少時間呢?這樣的比較揭示了漸近增長函數的運行時間。另一個例子: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) }
~~~
通過函數參數來指定輸入的大小,但是參數變量對于每個具體的基準測試都是固定的。要避免直接修改b.N來控制輸入的大小。除非你將它作為一個固定大小的迭代計算輸入,否則基準測試的結果將毫無意義。
基準測試對于編寫代碼是很有幫助的,但是即使工作完成了也應當保存基準測試代碼。因為隨著項目的發展,或者是輸入的增加,或者是部署到新的操作系統或不同的處理器,我們可以再次用基準測試來幫助我們改進設計。
**練習 11.6:** 為2.6.2節的練習2.4和練習2.5的PopCount函數編寫基準測試。看看基于表格算法在不同情況下對提升性能會有多大幫助。
**練習 11.7:** 為*IntSet(§6.5)的Add、UnionWith和其他方法編寫基準測試,使用大量隨機輸入。你可以讓這些方法跑多快?選擇字的大小對于性能的影響如何?IntSet和基于內建map的實現相比有多快?
### 11.5. 剖析
測量基準對于衡量特定操作的性能是有幫助的,但是當我們視圖讓程序跑的更快的時候,我們通常并不知道從哪里開始優化。每個碼農都應該知道Donald Knuth在1974年的“Structured Programming with go to Statements”上所說的格言。雖然經常被解讀為不重視性能的意思,但是從原文我們可以看到不同的含義:
> 毫無疑問,效率會導致各種濫用。程序員需要浪費大量的時間思考或者擔心,被部分程序的速度所干擾,實際上這些嘗試提升效率的行為可能產生強烈的負面影響,特別是當調試和維護的時候。我們不應該過度糾結于細節的優化,應該說約97%的場景:過早的優化是萬惡之源。
> 我們當然不應該放棄那關鍵的3%的機會。一個好的程序員不會因為這個理由而滿足,他們會明智地觀察和識別哪些是關鍵的代碼;但是只有在關鍵代碼已經被確認的前提下才會進行優化。對于判斷哪些部分是關鍵代碼是經常容易犯經驗性錯誤的地方,因此程序員普通使用的測量工具,使得他們的直覺很不靠譜。
當我們想仔細觀察我們程序的運行速度的時候,最好的技術是如何識別關鍵代碼。自動化的剖析技術是基于程序執行期間一些抽樣數據,然后推斷后面的執行狀態;最終產生一個運行時間的統計數據文件。
Go語言支持多種類型的剖析性能分析,每一種關注不同的方面,但它們都涉及到每個采樣記錄的感興趣的一系列事件消息,每個事件都包含函數調用時函數調用堆棧的信息。內建的`go test`工具對幾種分析方式都提供了支持。
CPU分析文件標識了函數執行時所需要的CPU時間。當前運行的系統線程在每隔幾毫秒都會遇到操作系統的中斷事件,每次中斷時都會記錄一個分析文件然后恢復正常的運行。
堆分析則記錄了程序的內存使用情況。每個內存分配操作都會觸發內部平均內存分配例程,每個512KB的內存申請都會觸發一個事件。
阻塞分析則記錄了goroutine最大的阻塞操作,例如系統調用、管道發送和接收,還有獲取鎖等。分析庫會記錄每個goroutine被阻塞時的相關操作。
在測試環境下只需要一個標志參數就可以生成各種分析文件。當一次使用多個標志參數時需要當心,因為分析操作本身也可能會影像程序的運行。
~~~
$ go test -cpuprofile=cpu.out
$ go test -blockprofile=block.out
$ go test -memprofile=mem.out
~~~
對于一些非測試程序也很容易支持分析的特性,具體的實現方式和程序是短時間運行的小工具還是長時間運行的服務會有很大不同,因此Go的runtime運行時包提供了程序運行時控制分析特性的接口。
一旦我們已經收集到了用于分析的采樣數據,我們就可以使用pprof來分析這些數據。這是Go工具箱自帶的一個工具,但并不是一個日常工具,它對應`go tool pprof`命令。該命令有許多特性和選項,但是最重要的有兩個,就是生成這個概要文件的可執行程序和對于的分析日志文件。
為了提高分析效率和減少空間,分析日志本身并不包含函數的名字;它只包含函數對應的地址。也就是說pprof需要和分析日志對于的可執行程序。雖然`go test`命令通常會丟棄臨時用的測試程序,但是在啟用分析的時候會將測試程序保存為foo.test文件,其中foo部分對于測試包的名字。
下面的命令演示了如何生成一個CPU分析文件。我們選擇`net/http`包的一個基準測試為例。通常是基于一個已經確定了是關鍵代碼的部分進行基準測試。基準測試會默認包含單元測試,這里我們用-run=NONE參數禁止單元測試。
~~~
$ go test -run=NONE -bench=ClientServerParallelTLS64 \
-cpuprofile=cpu.log net/http
PASS
BenchmarkClientServerParallelTLS64-8 1000
3141325 ns/op 143010 B/op 1747 allocs/op
ok net/http 3.395s
$ go tool pprof -text -nodecount=10 ./http.test cpu.log
2570ms of 3590ms total (71.59%)
Dropped 129 nodes (cum <= 17.95ms)
Showing top 10 nodes out of 166 (cum >= 60ms)
flat flat% sum% cum cum%
1730ms 48.19% 48.19% 1750ms 48.75% crypto/elliptic.p256ReduceDegree
230ms 6.41% 54.60% 250ms 6.96% crypto/elliptic.p256Diff
120ms 3.34% 57.94% 120ms 3.34% math/big.addMulVVW
110ms 3.06% 61.00% 110ms 3.06% syscall.Syscall
90ms 2.51% 63.51% 1130ms 31.48% crypto/elliptic.p256Square
70ms 1.95% 65.46% 120ms 3.34% runtime.scanobject
60ms 1.67% 67.13% 830ms 23.12% crypto/elliptic.p256Mul
60ms 1.67% 68.80% 190ms 5.29% math/big.nat.montgomery
50ms 1.39% 70.19% 50ms 1.39% crypto/elliptic.p256ReduceCarry
50ms 1.39% 71.59% 60ms 1.67% crypto/elliptic.p256Sum
~~~
參數`-text`用于指定輸出格式,在這里每行是一個函數,根據使用CPU的時間長短來排序。其中`-nodecount=10`標志參數限制了只輸出前10行的結果。對于嚴重的性能問題,這個文本格式基本可以幫助查明原因了。
這個概要文件告訴我們,HTTPS基準測試中`crypto/elliptic.p256ReduceDegree`函數占用了將近一半的CPU資源。相比之下,如果一個概要文件中主要是runtime包的內存分配的函數,那么減少內存消耗可能是一個值得嘗試的優化策略。
對于一些更微妙的問題,你可能需要使用pprof的圖形顯示功能。這個需要安裝GraphViz工具,可以從 http://www.graphviz.org 下載。參數`-web`用于生成一個有向圖文件,包含了CPU的使用和最熱點的函數等信息。
這一節我們只是簡單看了下Go語言的分析據工具。如果想了解更多,可以閱讀Go官方博客的“Pro?ling Go Programs”一文。
### 11.6. 示例函數
第三種`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提高的技術讓用戶可以在瀏覽器中在線編輯和運行每個示例函數,就像圖11.4所示的那樣。這通常是學習函數使用或Go語言特性最快捷的方式。

本書最后的兩掌是討論reflect和unsafe包,一般的Go用戶很少直接使用它們。因此,如果你還沒有寫過任何真實的Go程序的話,現在可以忽略剩余部分而直接編碼了。
- Go語言程序設計
- Go語言圣經(中文版)
- 譯者序
- 前言
- 第一章 入門
- 第二章 程序結構
- 第三章 基礎數據類型
- 第四章 復合數據類型
- 第五章 函數
- 第六章 方法
- 第七章 接口
- 7.4. flag.Value接口
- 7.6. sort.Interface接口
- 7.7. http.Handler接口
- 第八章 Goroutines和Channels
- 第九章 基于共享變量的并發
- 9.2. sync.Mutex互斥鎖
- 第十章 包和工具
- 第十一章 測試
- 第十二章 反射
- 12.2. reflect.Type和reflect.Value
- 12.5. 通過reflect.Value修改值
- 第13章 底層編程
- 13.1. unsafe.Sizeof, Alignof 和 Offsetof
- 附錄