Go 語言 測試函數(shù)

2023-03-14 16:59 更新

原文鏈接:https://gopl-zh.github.io/ch11/ch11-02.html


11.2. 測試函數(shù)

每個測試函數(shù)必須導(dǎo)入testing包。測試函數(shù)有如下的簽名:

func TestName(t *testing.T) {
    // ...
}

測試函數(shù)的名字必須以Test開頭,可選的后綴名必須以大寫字母開頭:

func TestSin(t *testing.T) { /* ... */ }
func TestCos(t *testing.T) { /* ... */ }
func TestLog(t *testing.T) { /* ... */ }

其中t參數(shù)用于報告測試失敗和附加的日志信息。讓我們定義一個實(shí)例包gopl.io/ch11/word1,其中只有一個函數(shù)IsPalindrome用于檢查一個字符串是否從前向后和從后向前讀都是一樣的。(下面這個實(shí)現(xiàn)對于一個字符串是否是回文字符串前后重復(fù)測試了兩次;我們稍后會再討論這個問題。)

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兩個測試函數(shù)。每一個都是測試IsPalindrome是否給出正確的結(jié)果,并使用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命令如果沒有參數(shù)指定包那么將默認(rèn)采用當(dāng)前目錄對應(yīng)的包(和go build命令一樣)。我們可以用下面的命令構(gòu)建和運(yùn)行測試。

$ cd $GOPATH/src/gopl.io/ch11/word1
$ go test
ok   gopl.io/ch11/word1  0.008s

結(jié)果還比較滿意,我們運(yùn)行了這個程序, 不過沒有提前退出是因?yàn)檫€沒有遇到BUG報告。不過一個法國名為“Noelle Eve Elleon”的用戶會抱怨IsPalindrome函數(shù)不能識別“été”。另外一個來自美國中部用戶的抱怨則是不能識別“A man, a plan, a canal: Panama.”。執(zhí)行特殊和小的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函數(shù)來匯報錯誤結(jié)果。

當(dāng)添加了這兩個測試用例之后,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

先編寫測試用例并觀察到測試用例觸發(fā)了和用戶報告的錯誤相同的描述是一個好的測試習(xí)慣。只有這樣,我們才能定位我們要真正解決的問題。

先寫測試用例的另外的好處是,運(yùn)行測試通常會比手工描述報告的處理更快,這讓我們可以進(jìn)行快速地迭代。如果測試集有很多運(yùn)行緩慢的測試,我們可以通過只選擇運(yùn)行某些特定的測試來加快測試速度。

參數(shù)-v可用于打印每個測試函數(shù)的名字和運(yùn)行時間:

$ 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

參數(shù)-run對應(yīng)一個正則表達(dá)式,只有測試函數(shù)名被它正確匹配的測試函數(shù)才會被go test測試命令運(yùn)行:

$ 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

當(dāng)然,一旦我們已經(jīng)修復(fù)了失敗的測試用例,在我們提交代碼更新之前,我們應(yīng)該以不帶參數(shù)的go test命令運(yùn)行全部的測試用例,以確保修復(fù)失敗測試的同時沒有引入新的問題。

我們現(xiàn)在的任務(wù)就是修復(fù)這些錯誤。簡要分析后發(fā)現(xiàn)第一個BUG的原因是我們采用了 byte而不是rune序列,所以像“été”中的é等非ASCII字符不能正確處理。第二個BUG是因?yàn)闆]有忽略空格和字母的大小寫導(dǎo)致的。

針對上述兩個BUG,我們仔細(xì)重寫了函數(shù):

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
}

同時我們也將之前的所有測試數(shù)據(jù)合并到了一個測試中的表格中。

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)
        }
    }
}

現(xiàn)在我們的新測試都通過了:

$ go test gopl.io/ch11/word2
ok      gopl.io/ch11/word2      0.015s

這種表格驅(qū)動的測試在Go語言中很常見。我們可以很容易地向表格添加新的測試數(shù)據(jù),并且后面的測試邏輯也沒有冗余,這樣我們可以有更多的精力去完善錯誤信息。

失敗測試的輸出并不包括調(diào)用t.Errorf時刻的堆棧調(diào)用信息。和其他編程語言或測試框架的assert斷言不同,t.Errorf調(diào)用也沒有引起panic異?;蛲V箿y試的執(zhí)行。即使表格中前面的數(shù)據(jù)導(dǎo)致了測試的失敗,表格后面的測試數(shù)據(jù)依然會運(yùn)行測試,因此在一個測試中我們可能了解多個失敗的信息。

如果我們真的需要停止測試,或許是因?yàn)槌跏蓟』蚩赡苁窃缦鹊腻e誤導(dǎo)致了后續(xù)錯誤等原因,我們可以使用t.Fatal或t.Fatalf停止當(dāng)前測試函數(shù)。它們必須在和測試函數(shù)同一個goroutine內(nèi)調(diào)用。

測試失敗的信息一般的形式是“f(x) = y, want z”,其中f(x)解釋了失敗的操作和對應(yīng)的輸入,y是實(shí)際的運(yùn)行結(jié)果,z是期望的正確的結(jié)果。就像前面檢查回文字符串的例子,實(shí)際的函數(shù)用于f(x)部分。顯示x是表格驅(qū)動型測試中比較重要的部分,因?yàn)橥粋€斷言可能對應(yīng)不同的表格項(xiàng)執(zhí)行多次。要避免無用和冗余的信息。在測試類似IsPalindrome返回布爾類型的函數(shù)時,可以忽略并沒有額外信息的z部分。如果x、y或z是y的長度,輸出一個相關(guān)部分的簡明總結(jié)即可。測試的作者應(yīng)該要努力幫助程序員診斷測試失敗的原因。

練習(xí) 11.1: 為4.3節(jié)中的charcount程序編寫測試。

練習(xí) 11.2: 為(§6.5)的IntSet編寫一組測試,用于檢查每個操作后的行為和基于內(nèi)置map的集合等價,后面練習(xí)11.7將會用到。

11.2.1. 隨機(jī)測試

表格驅(qū)動的測試便于構(gòu)造基于精心挑選的測試數(shù)據(jù)的測試用例。另一種測試思路是隨機(jī)測試,也就是通過構(gòu)造更廣泛的隨機(jī)輸入來測試探索函數(shù)的行為。

那么對于一個隨機(jī)的輸入,我們?nèi)绾文苤老M妮敵鼋Y(jié)果呢?這里有兩種處理策略。第一個是編寫另一個對照函數(shù),使用簡單和清晰的算法,雖然效率較低但是行為和要測試的函數(shù)是一致的,然后針對相同的隨機(jī)輸入檢查兩者的輸出結(jié)果。第二種是生成的隨機(jī)輸入的數(shù)據(jù)遵循特定的模式,這樣我們就可以知道期望的輸出的模式。

下面的例子使用的是第二種方法:randomPalindrome函數(shù)用于隨機(jī)生成回文字符串。

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)
        }
    }
}

雖然隨機(jī)測試會有不確定因素,但是它也是至關(guān)重要的,我們可以從失敗測試的日志獲取足夠的信息。在我們的例子中,輸入IsPalindrome的p參數(shù)將告訴我們真實(shí)的數(shù)據(jù),但是對于函數(shù)將接受更復(fù)雜的輸入,不需要保存所有的輸入,只要日志中簡單地記錄隨機(jī)數(shù)種子即可(像上面的方式)。有了這些隨機(jī)數(shù)初始化種子,我們可以很容易修改測試代碼以重現(xiàn)失敗的隨機(jī)測試。

通過使用當(dāng)前時間作為隨機(jī)種子,在整個過程中的每次運(yùn)行測試命令時都將探索新的隨機(jī)數(shù)據(jù)。如果你使用的是定期運(yùn)行的自動化測試集成系統(tǒng),隨機(jī)測試將特別有價值。

練習(xí) 11.3: TestRandomPalindromes測試函數(shù)只測試了回文字符串。編寫新的隨機(jī)測試生成器,用于測試隨機(jī)生成的非回文字符串。

練習(xí) 11.4: 修改randomPalindrome函數(shù),以探索IsPalindrome是否對標(biāo)點(diǎn)和空格做了正確處理。

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

11.2.2. 測試一個命令

對于測試包go test是一個有用的工具,但是稍加努力我們也可以用它來測試可執(zhí)行程序。如果一個包的名字是 main,那么在構(gòu)建時會生成一個可執(zhí)行程序,不過main包可以作為一個包被測試器代碼導(dǎo)入。

讓我們?yōu)?.3.2節(jié)的echo程序編寫一個測試。我們先將程序拆分為兩個函數(shù):echo函數(shù)完成真正的工作,main函數(shù)用于處理命令行輸入?yún)?shù)和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
}

在測試中我們可以用各種參數(shù)和標(biāo)志調(diào)用echo函數(shù),然后檢測它的輸出是否正確,我們通過增加參數(shù)來減少echo函數(shù)對全局變量的依賴。我們還增加了一個全局名為out的變量來替代直接使用os.Stdout,這樣測試代碼可以根據(jù)需要將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)
        }
    }
}

要注意的是測試代碼和產(chǎn)品代碼在同一個包。雖然是main包,也有對應(yīng)的main入口函數(shù),但是在測試的時候main包只是TestEcho測試函數(shù)導(dǎo)入的一個普通包,里面main函數(shù)并沒有被導(dǎo)出,而是被忽略的。

通過將測試放到表格中,我們很容易添加新的測試用例。讓我通過增加下面的測試用例來看看失敗的情況是怎么樣的:

{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類似語法),實(shí)際的結(jié)果和期望的結(jié)果。通過這樣的錯誤信息,你可以在檢視代碼之前就很容易定位錯誤的原因。

要注意的是在測試代碼中并沒有調(diào)用log.Fatal或os.Exit,因?yàn)檎{(diào)用這類函數(shù)會導(dǎo)致程序提前退出;調(diào)用這些函數(shù)的特權(quán)應(yīng)該放在main函數(shù)中。如果真的有意外的事情導(dǎo)致函數(shù)發(fā)生panic異常,測試驅(qū)動應(yīng)該嘗試用recover捕獲異常,然后將當(dāng)前測試當(dāng)作失敗處理。如果是可預(yù)期的錯誤,例如非法的用戶輸入、找不到文件或配置文件不當(dāng)?shù)葢?yīng)該通過返回一個非空的error的方式處理。幸運(yùn)的是(上面的意外只是一個插曲),我們的echo示例是比較簡單的也沒有需要返回非空error的情況。

11.2.3. 白盒測試

一種測試分類的方法是基于測試者是否需要了解被測試對象的內(nèi)部工作原理。黑盒測試只需要測試包公開的文檔和API行為,內(nèi)部實(shí)現(xiàn)對測試代碼是透明的。相反,白盒測試有訪問包內(nèi)部函數(shù)和數(shù)據(jù)結(jié)構(gòu)的權(quán)限,因此可以做到一些普通客戶端無法實(shí)現(xiàn)的測試。例如,一個白盒測試可以在每個操作之后檢測不變量的數(shù)據(jù)類型。(白盒測試只是一個傳統(tǒng)的名稱,其實(shí)稱為clear box測試會更準(zhǔn)確。)

黑盒和白盒這兩種測試方法是互補(bǔ)的。黑盒測試一般更健壯,隨著軟件實(shí)現(xiàn)的完善測試代碼很少需要更新。它們可以幫助測試者了解真實(shí)客戶的需求,也可以幫助發(fā)現(xiàn)API設(shè)計的一些不足之處。相反,白盒測試則可以對內(nèi)部一些棘手的實(shí)現(xiàn)提供更多的測試覆蓋。

我們已經(jīng)看到兩種測試的例子。TestIsPalindrome測試僅僅使用導(dǎo)出的IsPalindrome函數(shù),因此這是一個黑盒測試。TestEcho測試則調(diào)用了內(nèi)部的echo函數(shù),并且更新了內(nèi)部的out包級變量,這兩個都是未導(dǎo)出的,因此這是白盒測試。

當(dāng)我們準(zhǔn)備TestEcho測試的時候,我們修改了echo函數(shù)使用包級的out變量作為輸出對象,因此測試代碼可以用另一個實(shí)現(xiàn)代替標(biāo)準(zhǔn)輸出,這樣可以方便對比echo輸出的數(shù)據(jù)。使用類似的技術(shù),我們可以將產(chǎn)品代碼的其他部分也替換為一個容易測試的偽對象。使用偽對象的好處是我們可以方便配置,容易預(yù)測,更可靠,也更容易觀察。同時也可以避免一些不良的副作用,例如更新生產(chǎn)數(shù)據(jù)庫或信用卡消費(fèi)行為。

下面的代碼演示了為用戶提供網(wǎng)絡(luò)存儲的web服務(wù)中的配額檢測邏輯。當(dāng)用戶使用了超過90%的存儲配額之后將發(fā)送提醒郵件。(譯注:一般在實(shí)現(xiàn)業(yè)務(wù)機(jī)器監(jiān)控,包括磁盤、cpu、網(wǎng)絡(luò)等的時候,需要類似的到達(dá)閾值=>觸發(fā)報警的邏輯,所以是很實(shí)用的案例。)

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)
    }
}

我們想測試這段代碼,但是我們并不希望發(fā)送真實(shí)的郵件。因此我們將郵件處理邏輯放到一個私有的notifyUser函數(shù)中。

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)
}

現(xiàn)在我們可以在測試中用偽郵件發(fā)送函數(shù)替代真實(shí)的郵件發(fā)送函數(shù)。它只是簡單記錄要通知的用戶和郵件的內(nèi)容。

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)
    }
}

這里有一個問題:當(dāng)測試函數(shù)返回后,CheckQuota將不能正常工作,因?yàn)閚otifyUsers依然使用的是測試函數(shù)的偽發(fā)送郵件函數(shù)(當(dāng)更新全局對象的時候總會有這種風(fēng)險)。 我們必須修改測試代碼恢復(fù)notifyUsers原先的狀態(tài)以便后續(xù)其他的測試沒有影響,要確保所有的執(zhí)行路徑后都能恢復(fù),包括測試失敗或panic異常的情形。在這種情況下,我們建議使用defer語句來延后執(zhí)行處理恢復(fù)的代碼。

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...
}

這種處理模式可以用來暫時保存和恢復(fù)所有的全局變量,包括命令行標(biāo)志參數(shù)、調(diào)試選項(xiàng)和優(yōu)化參數(shù);安裝和移除導(dǎo)致生產(chǎn)代碼產(chǎn)生一些調(diào)試信息的鉤子函數(shù);還有有些誘導(dǎo)生產(chǎn)代碼進(jìn)入某些重要狀態(tài)的改變,比如超時、錯誤,甚至是一些刻意制造的并發(fā)行為等因素。

以這種方式使用全局變量是安全的,因?yàn)間o test命令并不會同時并發(fā)地執(zhí)行多個測試。

11.2.4. 外部測試包

考慮下這兩個包:net/url包,提供了URL解析的功能;net/http包,提供了web服務(wù)和HTTP客戶端的功能。如我們所料,上層的net/http包依賴下層的net/url包。然后,net/url包中的一個測試是演示不同URL和HTTP客戶端的交互行為。也就是說,一個下層包的測試代碼導(dǎo)入了上層的包。


這樣的行為在net/url包的測試代碼中會導(dǎo)致包的循環(huán)依賴,正如圖11.1中向上箭頭所示,同時正如我們在10.1節(jié)所講的,Go語言規(guī)范是禁止包的循環(huán)依賴的。

不過我們可以通過外部測試包的方式解決循環(huán)依賴的問題,也就是在net/url包所在的目錄聲明一個獨(dú)立的url_test測試包。其中包名的_test后綴告訴go test工具它應(yīng)該建立一個額外的包來運(yùn)行測試。我們將這個外部測試包的導(dǎo)入路徑視作是net/url_test會更容易理解,但實(shí)際上它并不能被其他任何包導(dǎo)入。

因?yàn)橥獠繙y試包是一個獨(dú)立的包,所以能夠?qū)肽切?b>依賴待測代碼本身的其他輔助包;包內(nèi)的測試代碼就無法做到這點(diǎn)。在設(shè)計層面,外部測試包是在所有它依賴的包的上層,正如圖11.2所示。


通過避免循環(huán)的導(dǎo)入依賴,外部測試包可以更靈活地編寫測試,特別是集成測試(需要測試多個組件之間的交互),可以像普通應(yīng)用程序那樣自由地導(dǎo)入其他包。

我們可以用go list命令查看包對應(yīng)目錄中哪些Go源文件是產(chǎn)品代碼,哪些是包內(nèi)測試,還有哪些是外部測試包。我們以fmt包作為一個例子:GoFiles表示產(chǎn)品代碼對應(yīng)的Go源文件列表;也就是go build命令要編譯的部分。

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

TestGoFiles表示的是fmt包內(nèi)部測試代碼,以_test.go為后綴文件名,不過只在測試時被構(gòu)建:

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

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

XTestGoFiles表示的是屬于外部測試包的測試代碼,也就是fmt_test包,因此它們必須先導(dǎo)入fmt包。同樣,這些文件也只是在測試時被構(gòu)建運(yùn)行:

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

有時候外部測試包也需要訪問被測試包內(nèi)部的代碼,例如在一個為了避免循環(huán)導(dǎo)入而被獨(dú)立到外部測試包的白盒測試。在這種情況下,我們可以通過一些技巧解決:我們在包內(nèi)的一個_test.go文件中導(dǎo)出一個內(nèi)部的實(shí)現(xiàn)給外部測試包。因?yàn)檫@些代碼只有在測試時才需要,因此一般會放在export_test.go文件中。

例如,fmt包的fmt.Scanf函數(shù)需要unicode.IsSpace函數(shù)提供的功能。但是為了避免太多的依賴,fmt包并沒有導(dǎo)入包含巨大表格數(shù)據(jù)的unicode包;相反fmt包有一個叫isSpace內(nèi)部的簡易實(shí)現(xiàn)。

為了確保fmt.isSpace和unicode.IsSpace函數(shù)的行為保持一致,fmt包謹(jǐn)慎地包含了一個測試。一個在外部測試包內(nèi)的白盒測試,是無法直接訪問到isSpace內(nèi)部函數(shù)的,因此fmt通過一個后門導(dǎo)出了isSpace函數(shù)。export_test.go文件就是專門用于外部測試包的后門。

package fmt

var IsSpace = isSpace

這個測試文件并沒有定義測試代碼;它只是通過fmt.IsSpace簡單導(dǎo)出了內(nèi)部的isSpace函數(shù),提供給外部測試包使用。這個技巧可以廣泛用于位于外部測試包的白盒測試。

11.2.5. 編寫有效的測試

許多Go語言新人會驚異于Go語言極簡的測試框架。很多其它語言的測試框架都提供了識別測試函數(shù)的機(jī)制(通常使用反射或元數(shù)據(jù)),通過設(shè)置一些“setup”和“teardown”的鉤子函數(shù)來執(zhí)行測試用例運(yùn)行的初始化和之后的清理操作,同時測試工具箱還提供了很多類似assert斷言、值比較函數(shù)、格式化輸出錯誤信息和停止一個失敗的測試等輔助函數(shù)(通常使用異常機(jī)制)。雖然這些機(jī)制可以使得測試非常簡潔,但是測試輸出的日志卻會像火星文一般難以理解。此外,雖然測試最終也會輸出PASS或FAIL的報告,但是它們提供的信息格式卻非常不利于代碼維護(hù)者快速定位問題,因?yàn)槭⌒畔⒌木唧w含義非常隱晦,比如“assert: 0 == 1”或成頁的海量跟蹤日志。

Go語言的測試風(fēng)格則形成鮮明對比。它期望測試者自己完成大部分的工作,定義函數(shù)避免重復(fù),就像普通編程那樣。編寫測試并不是一個機(jī)械的填空過程;一個測試也有自己的接口,盡管它的維護(hù)者也是測試僅有的一個用戶。一個好的測試不應(yīng)該引發(fā)其他無關(guān)的錯誤信息,它只要清晰簡潔地描述問題的癥狀即可,有時候可能還需要一些上下文信息。在理想情況下,維護(hù)者可以在不看代碼的情況下就能根據(jù)錯誤信息定位錯誤產(chǎn)生的原因。一個好的測試不應(yīng)該在遇到一點(diǎn)小錯誤時就立刻退出測試,它應(yīng)該嘗試報告更多的相關(guān)的錯誤信息,因?yàn)槲覀兛赡軓亩鄠€失敗測試的模式中發(fā)現(xiàn)錯誤產(chǎn)生的規(guī)律。

下面的斷言函數(shù)比較兩個值,然后生成一個通用的錯誤信息,并停止程序。它很好用也確實(shí)有效,但是當(dāng)測試失敗的時候,打印的錯誤信息卻幾乎是沒有價值的。它并沒有為快速解決問題提供一個很好的入口。

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)
    // ...
}

從這個意義上說,斷言函數(shù)犯了過早抽象的錯誤:僅僅測試兩個整數(shù)是否相同,而沒能根據(jù)上下文提供更有意義的錯誤信息。我們可以根據(jù)具體的錯誤打印一個更有價值的錯誤信息,就像下面例子那樣。只有在測試中出現(xiàn)重復(fù)模式時才采用抽象。

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)
    }
    // ...
}

現(xiàn)在的測試不僅報告了調(diào)用的具體函數(shù)、它的輸入和結(jié)果的意義;并且打印的真實(shí)返回的值和期望返回的值;并且即使斷言失敗依然會繼續(xù)嘗試運(yùn)行更多的測試。一旦我們寫了這樣結(jié)構(gòu)的測試,下一步自然不是用更多的if語句來擴(kuò)展測試用例,我們可以用像IsPalindrome的表驅(qū)動測試那樣來準(zhǔn)備更多的s和sep測試用例。

前面的例子并不需要額外的輔助函數(shù),如果有可以使測試代碼更簡單的方法我們也樂意接受。(我們將在13.3節(jié)看到一個類似reflect.DeepEqual輔助函數(shù)。)一個好的測試的關(guān)鍵是首先實(shí)現(xiàn)你期望的具體行為,然后才是考慮簡化測試代碼、避免重復(fù)。如果直接從抽象、通用的測試庫著手,很難取得良好結(jié)果。

練習(xí)11.5: 用表格驅(qū)動的技術(shù)擴(kuò)展TestSplit測試,并打印期望的輸出結(jié)果。

11.2.6. 避免脆弱的測試

如果一個應(yīng)用程序?qū)τ谛鲁霈F(xiàn)的但有效的輸入經(jīng)常失敗說明程序容易出bug(不夠穩(wěn)?。?;同樣,如果一個測試僅僅對程序做了微小變化就失敗則稱為脆弱。就像一個不夠穩(wěn)健的程序會挫敗它的用戶一樣,一個脆弱的測試同樣會激怒它的維護(hù)者。最脆弱的測試代碼會在程序沒有任何變化的時候產(chǎn)生不同的結(jié)果,時好時壞,處理它們會耗費(fèi)大量的時間但是并不會得到任何好處。

當(dāng)一個測試函數(shù)會產(chǎn)生一個復(fù)雜的輸出如一個很長的字符串、一個精心設(shè)計的數(shù)據(jù)結(jié)構(gòu)或一個文件時,人們很容易想預(yù)先寫下一系列固定的用于對比的標(biāo)桿數(shù)據(jù)。但是隨著項(xiàng)目的發(fā)展,有些輸出可能會發(fā)生變化,盡管很可能是一個改進(jìn)的實(shí)現(xiàn)導(dǎo)致的。而且不僅僅是輸出部分,函數(shù)復(fù)雜的輸入部分可能也跟著變化了,因此測試使用的輸入也就不再有效了。

避免脆弱測試代碼的方法是只檢測你真正關(guān)心的屬性。保持測試代碼的簡潔和內(nèi)部結(jié)構(gòu)的穩(wěn)定。特別是對斷言部分要有所選擇。不要對字符串進(jìn)行全字匹配,而是針對那些在項(xiàng)目的發(fā)展中是比較穩(wěn)定不變的子串。很多時候值得花力氣來編寫一個從復(fù)雜輸出中提取用于斷言的必要信息的函數(shù),雖然這可能會帶來很多前期的工作,但是它可以幫助迅速及時修復(fù)因?yàn)轫?xiàng)目演化而導(dǎo)致的不合邏輯的失敗測試。



以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號