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是否對標點和空格做了正確處理。

譯者注:拓展閱讀感興趣的讀者可以再瞭解一下go-fuzz

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%的存儲配額之後將發送提醒郵件。(譯註:一般在實現業務機器監控,包括磁盤、cpu、網絡等的時候,需要類似的到達閾值=>觸發報警的邏輯,所以是很實用的案例。)

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命令要編譯的部分。

$ go list -f={{.GoFiles}} fmt
[doc.go format.go print.go scan.go]

TestGoFiles表示的是fmt包內部測試代碼,以_test.go為後綴文件名,不過只在測試時被構建:

$ go list -f={{.TestGoFiles}} fmt
[export_test.go]

包的測試代碼通常都在這些文件中,不過fmt包並非如此;稍後我們再解釋export_test.go文件的作用。

XTestGoFiles表示的是屬於外部測試包的測試代碼,也就是fmt_test包,因此它們必須先導入fmt包。同樣,這些文件也只是在測試時被構建運行:

$ go list -f={{.XTestGoFiles}} fmt
[fmt_test.go scan_test.go stringer_test.go]

有時候外部測試包也需要訪問被測試包內部的代碼,例如在一個為了避免循環導入而被獨立到外部測試包的白盒測試。在這種情況下,我們可以通過一些技巧解決:我們在包內的一個_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語言新人會驚異於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. 避免脆弱的測試

如果一個應用程序對於新出現的但有效的輸入經常失敗說明程序容易出bug(不夠穩健);同樣,如果一個測試僅僅對程序做了微小變化就失敗則稱為脆弱。就像一個不夠穩健的程序會挫敗它的用戶一樣,一個脆弱的測試同樣會激怒它的維護者。最脆弱的測試代碼會在程序沒有任何變化的時候產生不同的結果,時好時壞,處理它們會耗費大量的時間但是並不會得到任何好處。

當一個測試函數會產生一個複雜的輸出如一個很長的字符串、一個精心設計的數據結構或一個文件時,人們很容易想預先寫下一系列固定的用於對比的標杆數據。但是隨著項目的發展,有些輸出可能會發生變化,儘管很可能是一個改進的實現導致的。而且不僅僅是輸出部分,函數複雜的輸入部分可能也跟著變化了,因此測試使用的輸入也就不再有效了。

避免脆弱測試代碼的方法是隻檢測你真正關心的屬性。保持測試代碼的簡潔和內部結構的穩定。特別是對斷言部分要有所選擇。不要對字符串進行全字匹配,而是針對那些在項目的發展中是比較穩定不變的子串。很多時候值得花力氣來編寫一個從複雜輸出中提取用於斷言的必要信息的函數,雖然這可能會帶來很多前期的工作,但是它可以幫助迅速及時修復因為項目演化而導致的不合邏輯的失敗測試。