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 ```Go // 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 ```Go // 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® { letters = append(letters, unicode.ToLower®) } } 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將會用到。

{% include “./ch11-02-1.md” %}

{% include “./ch11-02-2.md” %}

{% include “./ch11-02-3.md” %}

{% include “./ch11-02-4.md” %}

{% include “./ch11-02-5.md” %}

{% include “./ch11-02-6.md” %}