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 = "[email protected]"
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 = "[email protected]"
    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新人會驚異與它的極簡的測試框架. 很多其他語言的測試框架都提供了識别測試函數的機製(通常使用反射或元數據), 通過設置一些 ‘‘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’’結果數據對比, 用這種簡單方式寫測試是誘人的. 但是隨着項目的發展, 輸出的某些部分很可能會發生變化, 盡管很可能是一個改進的實現導致的. 而且不僅僅是輸出部分, 函數複雜複製的輸入部分可能也跟着變化了, 因此測試使用的輸入也就不在有效了.

避免脆弱測試代碼的方法是隻檢測你眞正關心的屬性. 保存測試代碼的簡潔和內部結構的穩定. 特别是對斷言部分要有所選擇. 不要檢査字符串的全匹配, 但是尋找相關的子字符串, 因爲某些子字符串在項目的發展中是比較穩定不變的. 通常編寫一個重複雜的輸出中提取必要精華信息以用於斷言是值得的, 雖然這可能會帶來很多前期的工作, 但是它可以幫助迅速及時脩複因爲項目演化而導致的不合邏輯的失敗測試.