gopl 測試

go test

測試是自動化測試的簡稱,即編寫簡單的程序來確保程序(產品代碼)在該測試中針對特定輸入產生預期的輸出。這些測試主要分兩種:

  • 通過精心設計,用來檢測某種功能
  • 隨機性的,用來擴大測試的覆蓋面

go test 子命令是 Go 語言包的測試驅動程序。在一個包目錄中,以 _test.go 結尾的文件不是 go build 命令編譯的目標,而是 go test 編譯的目標。
在 *_test.go 的測試源碼文件中,有三種類型的函數:

  • 功能測試函數
  • 基準測試函數
  • 示例函數

功能測試函數,以 Test 開頭,用來檢測一些程序邏輯的正確性。
基準測試函數,以 Benchmark 開頭,用來測試程序的性能。
示例函數,以 Example 開頭,提供一個機器檢查過的示例文檔。

Test 函數(功能測試)

每一個測試文件必須導入 testing 包。這些函數的函數簽名如下:

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

參數 t 提供了彙報測試失敗和日誌記錄的功能。

定義示例

下面先定義一個用來測試的示例,這個示例包含一個函數 IsPalindrome,用來判斷一個字符串是否是迴文:

// word 包提供了文字遊戲相關的工具函數
package word

// IsPalindrome 判斷一個字符串是否是迴文
func IsPalindrome(s string) bool {
    for i := range s {
        if s[i] != s[len(s)-1-i] {
            return false
        }
    }
    return true
}

這個函數對於一個字符串是否是迴文字符串前後重複測試了兩次,其實只要檢查完字符串一半的字符就可以結束了。這個在稍後測試性能的時候會做改進,這裏先關注功能。

測試源碼文件

在同一個目錄中,再寫一個測試文件。假設上面的示例的文件名是 word.go,那麼這個測試文件的文件名可以是 word_test.go(命名沒有強制要求,但是這樣的命名使得文件的意義一目瞭然)。文件中包含了兩個功能測試函數,這兩個函數都是檢查 IsPalindrome 函數是否針對某個輸入的參數能給出正確的結果,並且用 t.Error 來報錯:

package word

import "testing"

func TestPalindrome(t *testing.T) {
    if !IsPalindrome("civic") {
        t.Error(`IsPalindrome("civic") = false`)
    }
    if !IsPalindrome("madam") {
        t.Error(`IsPalindrome("madam") = false`)
    }
}

func TestNonPalindrome(t *testing.T) {
    if IsPalindrome("palindrome") {
        t.Error(`IsPalindrome("palindrome") = true`)
    }
}

功能擴展

這個最初版本的迴文判斷函數比較簡陋,有些明顯也是迴文的情況,但是無法被現在這個版本的函數檢測出來:

  • "上海自來水來自海上"
  • "Madam, I'm Adam"

針對上面兩種迴文,又寫了新的測試用例:

func TestChinesePalindrome(t *testing.T) {
    input := "上海自來水來自海上"
    if !IsPalindrome(input) {
        t.Errorf(`IsPalindrome(%q) = false`, input)
    }
}

func TestSentencePalindrome(t *testing.T) {
    input := "Madam, I'm Adam"
    if !IsPalindrome(input) {
        t.Errorf(`IsPalindrome(%q) = false`, input)
    }
}

這裏用了 Errorf 函數,具有格式化的功能。

運行 go test

添加了新的測試後,再運行 go test 命令失敗了,錯誤信息如下:

PS G:\Steed\Documents\Go\src\gopl\ch11\word1> go test
--- FAIL: TestChinesePalindrome (0.00s)
    word_test.go:23: IsPalindrome("上海自來水來自海上") = false
--- FAIL: TestSentencePalindrome (0.00s)
    word_test.go:30: IsPalindrome("Madam, I'm Adam") = false
FAIL
exit status 1
FAIL    gopl/ch11/word1 0.292s
PS G:\Steed\Documents\Go\src\gopl\ch11\word1>

這裏是一個比較好的實踐,先寫測試然後發現它觸發的的錯誤。通過這步,可以定位到真正要解決的問題,並在修復後確認問題已經解決。

運行 go test 還可以指定一些參數:

PS G:\Steed\Documents\Go\src\gopl\ch11\word1> go test -v -run="Chinese|Sentence"
=== RUN   TestChinesePalindrome
--- FAIL: TestChinesePalindrome (0.00s)
    word_test.go:23: IsPalindrome("上海自來水來自海上") = false
=== RUN   TestSentencePalindrome
--- FAIL: TestSentencePalindrome (0.00s)
    word_test.go:30: IsPalindrome("Madam, I'm Adam") = false
FAIL
exit status 1
FAIL    gopl/ch11/word1 0.250s
PS G:\Steed\Documents\Go\src\gopl\ch11\word1>

參數 -v 可以輸出包中每個測試用例的名稱和執行時間。默認只會輸出有問題的測試。
參數 -run 是一個正則表達式,可以使 go test 只運行那些測出函數名稱匹配的函數。

上面選擇性地只運行新的測試用例。一旦之後的修復使得測試用例通過後,還必須使用不帶開關的 go test 來運行一次完整的測試。

新的示例函數

上一版本的函數比較簡單,使用字節序列而不是字符序列,因此無法支持非 ASCII 字符的檢查。另外也沒有忽略空格、標點符號和字母大小寫。下面重寫了這個函數:

// word 包提供了文字遊戲相關的工具函數
package word

import "unicode"

// IsPalindrome 判斷一個字符串是否是迴文
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
}

新的測試用例

測試用例也重新寫。這裏是一個更加全面的測試用例,把之前的用例和新的用例結合到一個表裏:

package word

import "testing"

func TestIsPalindrome(t *testing.T) {
    var tests = []struct {
        input string
        want  bool
    }{
        {"", true},
        {"a", true},
        {"aa", true},
        {"ab", false},
        {"kayak", true},
        {"palindrome", false},
        {"desserts", false},
        {"上海自來水來自海上", true},
        {"Madam, I'm Adam", true},
    }
    for _, test := range tests {
        if got := IsPalindrome(test.input); got != test.want {
            t.Errorf(`IsPalindrome(%q) = %v`, test.input, got)
        }
    }
}

這種基於表的測試方式在 Go 裏面很常見。根據需要添加新的表項很直觀,並且由於斷言邏輯沒有重複,因此可以花點精力讓輸出的錯誤消息更好看一點。

小結-測試函數

調用 t.Errorf 輸出的失敗的測試用例信息沒有包含整個跟蹤棧信息,也不會導致程序終止執行。這樣可以在一次測試過程中發現多個失敗的情況。
如果需要在測試函數中終止,比如由於初始化代碼失敗,可以使用 t.Fatal 或 t.Fatalf 函數來終止當前測試函數,它們必須在測試函數的同一個 goroutine 內調用。

測試錯誤消息的建議
測試錯誤消息一般格式是 f(x)=y, want z,這裏 f(x) 表示需要執行的操作和它的輸入,y 是實際的輸出結果,z 是期望得到的結果。在測試一個布爾函數的時候,省略 “want z” 部分,因爲它沒有給出有用的信息。上面的測試用例輸出的錯誤消息基本也是這麼做的,

隨機測試

基於表的測試方便針對精心選擇的輸入檢測函數是否工作正常,以測試邏輯上引人關注的用例。另外一種方式是隨機測試,通過構建隨機輸入來擴展測試的覆蓋範圍。
對於隨機的輸入,要如何確認輸出是否正確,這裏有兩種策略:

  • 額外寫一個函數,這個函數使用低效但是清晰的算法,然後檢查兩種實現的輸出是否一致
  • 構建符合某種模式的輸入,這樣就可以知道期望的輸出模式

下面的例子使用了第二種模式,randomPalindrome 函數可以隨機的創建迴文字符串,使用這些迴文字符串來驗證進行測試:

import (
    "math/rand"
    "testing"
    "time"
)

// randomPalindrome 返回一個迴文字符串,它的長度和內容都是隨機生成的
func randomPalindrome(rng *rand.Rand) string {
    n := rng.Intn(25) // 隨機字符串最大長度24
    runes := make([]rune, n)
    for i := 0; i < (n+1)/2; i++ {
        r := rune(rng.Intn(0x1000)) // 隨機字符最大是 `\u0999
        runes[i] = r
        runes[n-1-i] = r
    }
    return string(runes)
}

func TestRandomPalindromes(t *testing.T) {
    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)
        }
    }
}

由於隨機測試的不確定性,在遇到測試用例失敗的情況下,一定要記錄足夠多的信息以便於重現這個問題。這裏記錄僞隨機數生成的種子會比轉存儲整個輸入數據結構要簡單得多。有了隨機數的種子,就可以簡單地修改測試代碼來準確地重現錯誤。
通過使用當前時間作爲僞隨機數的種子源,在測試的整個生命週期中,每次運行的時候都會得到新的輸入。如果你的項目使用自動化系統來週期地運行測試,這一點很重要。

測試一個命令

就是測試命令源碼文件,其實和測試包源碼文件差不多。畢竟都是一樣的代碼,不過需要額外做一些特殊的處理。
對於包的測試,go test 很有用,但是稍加修改,也能夠將它用來測試可執行程序。一個 main 包可以生成可執行程序,不過也可以當做庫來導入。

示例程序

下面的 echo 程序,可以輸出命令行參數:

// 輸出命令行參數
package main

import (
    "flag"
    "fmt"
    "strings"
)

var n = flag.Bool("n", false, "omit trailing newline")
var sep = flag.String("s", " ", "separator")

func main() {
    flag.Parse()
    fmt.Print(strings.Join(flag.Args(), *sep))
    if !*n {
        fmt.Println()
    }
}

爲了便於測試,需要對程序進行修改。把程序分成兩個函數,echo 執行邏輯,main 用來讀取和解析命令行參數以及報告 echo 函數可能返回的錯誤:

// 輸出命令行參數
package main

import (
    "flag"
    "fmt"
    "io"
    "os"
    "strings"
)

var (
    n   = flag.Bool("n", false, "omit trailing newline")
    sep = flag.String("s", " ", "separator")
)

var out io.Writer = os.Stdout // 測試過程中將會被更改

func main() {
    flag.Parse()
    if err := echo(!*n, *sep, 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.Fprintf(out, strings.Join(args, sep))
    if newline {
        fmt.Fprintln(out)
    }
    return nil
}

分離出執行邏輯
把程序的主要功能從 main 函數裏分離出來了,運行程序的時候通過 main 函數來調用 echo。而測試的時候,就可以直接對 echo 函數進行測試。
避免依賴全局變量
在接下來的測試中,將通過不同的參數和開關來調用 echo,以檢查它在不同的模式下都能正常工作。這裏的 echo 函數調用的時候,通過傳參獲取這些信息,這是爲了避免函數依賴全局變量,這樣測試的時候也可以直接傳參來調用 echo 不同的模式。
控制輸出的變量
這裏還另外引入了一個全局變量 out,該變量是 io.Writer 類型,所有的結果都將輸出到這裏。echo 函數的輸出是輸出到 out 變量而不是直接輸出到 os.Stdout。這樣正常使用的時候,就是輸出到用戶界面,而測試的時候,可以覆蓋掉這個變量輸出到其他地方。這樣是實現了記錄寫入的內容以便於檢查。

測試代碼

下面是測試代碼,在文件 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) // 捕獲的輸出
        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)
        }
    }
}

這裏依然是通過表來組織測試用例,這樣可以很容易地添加新的測試用例。下面是添加了一行到測試用例中:

{false, ":", []string{"1", "2", "3"}, "1:2:3\n"},

上面添加的這條是有錯誤的,正好可以看看測試失敗的時候的輸出:

PS H:\Go\src\gopl\ch11\echo> go test
--- FAIL: TestEcho (0.00s)
    echo_test.go:32: echo(false, ":", ["1" "2" "3"]) = "1:2:3", want "1:2:3\n"
FAIL
exit status 1
FAIL    gopl/ch11/echo  0.163s
PS H:\Go\src\gopl\ch11\echo>

錯誤信息首先描述了想要進行的操作,使用了類似 Go 的語法,就像一個函數調用。然後依次是實際獲得個值和預期的結果。這樣的錯誤信息就很有幫助。

測試中的錯誤處理
還要注意,測試代碼裏並沒有調用 log.Fatal 或 os.Exit,因爲這兩個調用會阻止跟蹤的過程,這兩個函數的調用可以認爲是 main 函數的特權。如果有時候發生了未預期的錯誤或者崩潰,即使測試用例本身失敗了,測試驅動程序也還可以繼續工作。預期的的錯誤應該通過返回一個非空的 error 值來報告,就像上面的測試代碼裏做的那樣。

白盒測試

測試的一種分類方式是基於對所要進行測試的包的內部的瞭解程度:

  • 黑盒測試,假設測試者對包的瞭解僅通過公開的API和文檔,而包的內部邏輯是不透明的
  • 白盒測試,可以訪問包的內部函數和數據結構,並且可以做一些常規用戶無法做到的觀察和改動

白盒這個名字是傳統的說法,淨盒(clear box)的說法更準確。
以上兩種方法是互補的。黑盒測試通常更加健壯,程序更新後基本不需要修改。並且可以幫助測試者瞭解用戶的情況以及發現API設計的缺陷。反之,白盒測試可以對實現的特定之處提供更詳細的覆蓋測試。
之前的內容已經分別給出了這兩種測試方法的例子:

  • TestIsPalindrome 函數僅調用導出的函數 IsPalindrome,所以它是一個黑盒測試
  • TestEcho 函數調用 echo 函數並且更新了全局變量 out,無論函數 echo 還是變量 out 都是未導出的,所以它是一個白盒測試

僞實現
在寫 TestEcho 的時候,通過修改 echo 函數,從而在輸出結果時使用了一個包級別的變量,使得測試可以使用一個額外的實現代替標準輸出來記錄要檢查的數據。通過這樣的技術,可以使用易於測試的僞實現來替換部分產品代碼。這種僞實現的優點是更易於配置、預測和觀察,並且更可靠。

示例程序

下面的代碼演示了向用戶提供存儲服務的 Web 服務中的限額邏輯。當用戶使用的額度超過 90% 的時候,系統自動發送一封告警郵件:

package storage

import (
    "fmt"
    "log"
    "net/smtp"
)

var usage = make(map[string]int64)

func bytesInUse(username string) int64 { return usage[username] }

// 郵件發送者配置
// 注意:永遠不要把密碼放到源代碼中
const sender = "[email protected]"
const password = "password"
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 中:

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.SendMail(%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
    }

    const user = "[email protected]"
    usage[user] = 980000000 // 模擬已經使用了 980M 的情況

    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 測試中使用了僞實現替換了原本的 notifyUser 的內容,這樣在之後的其他測試中,notifyUser 依然是這裏被替換上的僞實現,這可能使得其他的測試無法正常工作(對於全局變量的更新一直都是存在風險的)。這裏還必須再修改一下這個測試讓他最後可以恢復 notifyUser 原來的值,這樣之後的測試就不會收到影響。這裏必須在所有的測試執行路徑上這樣做,包括測試失敗和崩潰的情況。通常這種情況下建議使用 defer :

func TestCheckQuotaNotifiesUser(t *testing.T) {
    // 保存留待恢復的notifyUser
    saved := notifyUser
    defer func() { notifyUser = saved }()

    // 設置測試的僞通知notifyUser
    var notifiedUser, notifiedMsg string
    notifyUser = func(user, msg string) {
        notifiedUser, notifiedMsg = user, msg
    }

    // ...測試其餘的部分...
}

以這種方式來使用全局變量是安全的,因爲 go test 一般不會併發執行多個測試。
這種方式有很多用處:

  • 用來臨時保存並恢復各種全局變量,包括命令行標誌、調試參數、以及性能參數
  • 用來安裝和移除鉤子程序來讓產品代碼調用測試代碼
  • 將產品代碼設置爲少見卻很重要的狀態,比如超時、錯誤,甚至是交叉並行執行

外部測試包

先來看一下 net/url 包,這個包提供了 URL 解析的功能。還有 net/http 包,這個包提供了 Web 服務器和 HTTP 客戶端的庫。高級的 net/http 包依賴於低級的 net/url 包。然而,在 net/url 包中有一個測試是用來演示 URL 和 HTTP 庫之間進行交互的例子。也就是說,低級別包的測試導入了高級別包。這種情況下,在 net/url 包中聲明的這個測試函數會導致包的循環引用,但是 Go 規範禁止循環引用。
爲了解決測試時可能會出現的循環引用的問題,可以將這個測試函數定義在外部測試包中。

聲明外部測試包

具體做法就是,測試文件的包名不和被測試的包同名,而是使用一個新的包名。在這個例子裏,就是原本包名是 url,現在因爲要導入高級別的包會出現循環引用,所以將包名改成一個別的名稱,比如 url_test。這個額外的後綴 _test 告訴 go test 工具,它應該單獨地編譯這個包,然後進行它的測試。爲了便於理解,可以認爲這個外部測試包的導入路徑是 net/url_test,但事實上它無法通過任何路徑導入。
由於外部測試在一個單獨的包裏,因此它們可以引用一些依賴於被測試包的幫助包,這個是包內測試無法做到的。從設計層次來看,外部測試包邏輯上在它所依賴的兩個包之上。
爲了避免包循環導入,外部測試包允許測試用例,尤其是集成測試用例(用來測試多個組件的交互),自由地導入其他的包,就像一個引用程序那樣。

使用 go list 工具

可以使用 go list 工具來彙總一個包目錄中哪些是產品代碼,哪些是包內測試、哪些是外部測試。這裏用 fmt 包作爲例子。

GoFiles
這類文件是包含產品代碼的文件列表,這些文件是 go build 命令將編譯進程序的代碼:

PS H:\Go\src\gopl\ch11> go list -f="{{.GoFiles}}" fmt
[doc.go format.go print.go scan.go]

TestGoFiles
這類文件也屬於 fmt 包,但是這些以 _test.go 結尾的文件是測試源碼文件,僅在編譯測試的時候纔會使用:

PS H:\Go\src\gopl\ch11> go list -f="{{.TestGoFiles}}" fmt
[export_test.go]

這裏的 export_test.go 這個文件還有特殊的意義,後面會單獨講。

XTestGoFiles
這類是包外部測試文件列表,這些同樣的測試源碼文件,僅用在測試過程中:

PS H:\Go\src\gopl\ch11> go list -f="{{.XTestGoFiles}}" fmt
[example_test.go fmt_test.go scan_test.go stringer_test.go]

白盒測試技巧

這是一個在外部測試中使用白盒測試的技巧,包內的白盒測試沒有這個問題。
有時候,外部測試包需要對被測試包擁有特殊的訪問權限。比如這種的情況:爲了避免循環引用,需要聲明外部測試包,但是又要做白盒測試,需要調用非導出的變量和函數。
應對這種情況,需要使用一種小技巧:在包內測試文件中添加一些聲明,將包內部的功能暴露給外部測試。由於是聲明在測試文件中的,所以暴露的後門只有在測試時可用。如果一個源文件存在的唯一目的就在於此,並且也不包含任何測試,這個文件一般就命名爲 export_test.go。
下面是 fmt 包的 export_test.go 文件裏所有的代碼部分:

package fmt

var IsSpace = isSpace
var Parsenum = parsenum

fmt 包的實現需要功能 unicode.isSpace 作爲 fmt.Scanf 的一部分。爲了避免創建不合理的依賴,fmt 沒有導入 unicode 包及其巨大的數據表,而是包含了一個更加簡單的實現 isSpace。
爲了確保 fmt.isSpace 和 unicode.isSpace 的功能一致,fmt 添加了一個測試。這是一個集成測試,所以用了外部測試包。但是測試中需要訪問 isSpace,這是一個非導出的函數。所以就有了上面的代碼,定義了一個可導出的變量來引用 isSpace 函數。並且這段代碼是定義在測試文件中的,所以無法在產品代碼中訪問到這個函數。

這個技巧在任何外部測試需要使用白盒測試技術的時候都可以使用。

編寫有效測試

Go 語言的測試期望測試的編寫者自己來做大部分工作,通過定義函數來避免重複。測試的過程不是死記硬背地填表格,測試也是有用戶界面的,雖然它的用戶也是它的維護者。

好的測試

一個好的測試,不會在發生錯誤時崩潰,而是要輸出一個簡潔、清晰的現象描述來報告錯誤,以及與之上下文相關的信息。理想情況下,不需要再通過閱讀源代碼來探究失敗的原因。
一個好的測試,不應該在發現一次測試失敗後就終止,而是要在一次運行中嘗試報告多個錯誤,因爲錯誤發生的方式本身會揭露錯誤的原因。

舉例說明

下面的斷言函數比較兩個值,構建一條一般的錯誤消息,並且停止程序。這是一個錯誤的例子,輸出的錯誤消息毫無用處。它的最大的問題就是沒有提供一個好的用戶界面:

import (
    "fmt"
    "strings"
    "testing"
)

// 一個糟糕的斷言函數
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 語句,而是在一個循環中執行這個測試,就像之前基於表的測試方式那樣。
當然定義一個函數來替代整個 if 語句也是可以的做法,只是這個例子太簡單了,並不需要任何工具函數。但是爲了使得測試代碼更簡潔,也可以考慮引入工具函數,如果上面的 assertEqual 函數的實現的用戶界面更加友好的話。並且如果這種模式在其他測試代碼裏也會重複用到,那就更有必要進行抽象了。
一個好的測試的關鍵是首先實現你所期望的具體行爲,之後再使用工具函數來使代碼簡潔並且避免重複。好的結果很少是從抽象的、通用的測試函數開始的。

這裏再預告一點,比較兩個變量的值在測試中很常見,並且會需要對各種類型的值進行比較,這就需要基於反射來實現。另外還會需要比較複合類型,這通過基於地址來判斷引用的變量是否是同一個變量來實現,這是 unsafe 包的內容。在掌握了反射的內容之後,在 unsafe 包的內容裏,會實現一個深度相等的工具函數。

避免脆弱的測試

如果一個應用在遇到新的合法輸入的情況下經常崩潰,那麼這個程序是有缺陷的
如果在程序發生可靠的改動的時候測試用例奇怪地失敗了,那麼這個測試用例也是脆弱的
避免寫出脆弱測試的最簡單的方法就是僅檢查你關心的屬性。例如,不要對輸出的字符串進行完全匹配,而是尋找到在程序進化過程中不會發生改變的子串。通常情況下,這值得寫一個穩定的函數來從複雜的輸出中提取核心內容,只有這樣之後的斷言纔會可靠。這雖然需要一些額外的工作,但這是值得的,否則這些時間會被花在修復那些奇怪地失敗的測試上面。

覆蓋率

語句覆蓋率是一種最簡單的且廣泛使用的方法之一。一個測試套件的語句覆蓋率是指部分語句在一次執行中執行執行一次。可以使用 go cover 工具,這個工具被集成到了 go test 中,用來衡量語句覆蓋率並幫助識別測試之間的明顯差別。
如果使用VSCode,直接通過測試源碼文件裏的按鈕運行測試,再切換到源碼文件中就能看到測試覆蓋率的效果。下面講的是不依賴編輯器和插件的做法。

生成覆蓋率報告

通過下面的命令可以輸出覆蓋工具的使用方法:

PS G:\Steed\Documents\Go\src\gopl\ch11\storage2> 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 工具鏈裏的一個可執行文件。這些程序位於 $GOROOT/pkg/tool/${GOOS}_{GOARCH},就是 Go 安裝目錄裏的文件夾下,都是一些 exe 文件。這裏多虧了 go build 工具,我們不需要直接運行它。

-coverprofile 標記
要生成覆蓋率報告,需要帶上 -coverprofile 標記來運行測試:

PS G:\Steed\Documents\Go\src> go test -run=CheckQuotaNotifiesUser -coverprofile="c.out" gopl/ch11/storage2
ok      gopl/ch11/storage2      0.349s  coverage: 58.3% of statements
PS G:\Steed\Documents\Go\src>

這個標記通過檢測產品代碼,啓用了覆蓋數據收集。也就是說,它修改了源代碼的副本,這樣在這個語句塊執行之前,設置一個布爾變量,每個語句塊都對應一個變量。在修改程序退出之前,它將每個變量的值都寫入到指定的日誌文件,這裏是 c.out,並記錄被執行語句的彙總信息。

-cover 標記
如果不需要記錄這個日誌文件而只要查看命令行輸出的內容,可以使用 -cover 標記:

PS G:\Steed\Documents\Go\src> go test -run=CheckQuotaNotifiesUser -cover gopl/ch11/storage2
ok      gopl/ch11/storage2      0.366s  coverage: 58.3% of statements
PS G:\Steed\Documents\Go\src>

效果是一樣的,只是不生成記錄文件。

-convermode=count 標記
默認的 mode 是 set。這個標記使每個語句塊的檢測使用一個遞增計數器來替代原本的布爾值。這樣日誌中就能統計到每個塊的執行次數,由此可以識別出執行頻率較高的“熱塊”和相反的“冷塊”。
VSCode似乎不能指定這個模式,所以只能生成查看布爾值的報告,檢查代碼是否被覆蓋,看不到熱塊和冷塊的效果。

查看覆蓋率報告

在生成數據後,運行 cover 工具來處理生成的日誌,可以生成一個 HTML 報告。可以在瀏覽器裏直觀的查看:

PS G:\Steed\Documents\Go\src> go tool cover -html="c.out"

Benchmark 函數

基準測試就是在一定的工作負載之下檢測程序性能的一種方法。
基準測試函數看上去和功能測試函數差不多,前綴是 Benchmark 並且擁有一個 *testing.B 參數。*testing.B 和 *testing.T 差不多,還額外增加了一些和性能檢測相關的方法。另外它還有一個整型成員 *testing.B.N,用來指定被檢測操作的執行次數。

基準測試函數

回到之前的檢查迴文的函數,下面是 IsPalindrome 函數的基準測試,它在一個循環中調用了 IsPalindrome 共 N 次:

func BenchmarkIsPalindrome(b *testing.B) {
    for i := 0; i < b.N; i++ {
        IsPalindrome("山西懸空寺空懸西山")
    }
}

上面的基準測試函數直接加到之前的測試源碼文件中。
在基準測試函數中手動寫代碼來實現循環,而不是在測試驅動程序中自動實現是有原因的。在基準測試函數中,for循環之外,可以執行一些必要的初始化代碼並且這段時間不會加到每次迭代的時間中去。如果有代碼會干擾結果,參數 testing.B 還提供了方法來停止、恢復和重置計時器(需要用到的場景並不多)。

執行基準測試

依然是使用 go test 命令來進行測試,但是默認情況下不會運行任何基準測試。需要加上 -bench 參數並指定有運行的基準測試。它是一個匹配 Benchmark 函數名稱的正則表達式,默認值不匹配任何函數。可以使用點來匹配所有的基準測試函數:

PS G:\Steed\Documents\Go\src\gopl\ch11\word2> go test -bench="."
goos: windows
goarch: amd64
pkg: gopl/ch11/word2
BenchmarkIsPalindrome-4          1000000              1052 ns/op
PASS
ok      gopl/ch11/word2 2.253s
PS G:\Steed\Documents\Go\src\gopl\ch11\word2>

基準測試函數名稱後面的數字後綴表示 GOMAXPROCS 的值。這對於一些併發相關的基準測試是一個重要的信息。
報告顯示每次調用 IsPalindrome 的平均耗時是 1.052ms,這個是 1000000 次調用的平均值。基準測試運行器在開始的時候並不清楚測試操作的耗時,所以開始會用比較小的N值來做檢測,然後爲了檢測穩定的運行時間,會推斷出一個較大的次數來保證得到穩定的測試結果。

提升效率

現在有了基準測試,那麼就先想辦法來讓程序更快一點,然後再運行基準測試來檢查具體快了多少。
有一處是明顯可以改進的,只需要遍歷字符串前面一半的字符就可以完成字符串的檢查。避免了第二次的重複比較:

    n := len(letters)
    for i := 0; i < n; i++ {
        if letters[i] != letters[len(letters)-1-i] {
            return false
        }
    }
    return true

但是通常情況下,優化並不能總是帶來期望的好處。這個優化後的運行時間也就 1.004ms,只有4.5%的提升。

另外還有一處可以優化,爲 letters 預分配一個容量足夠大的數組,避免在 append 調用的時候多次進行擴容:

    // var letters []rune
    letters := make([]rune, 0, len(s))
    for _, r := range s {
        if unicode.IsLetter(r) {
            letters = append(letters, unicode.ToLower(r))
        }
    }

這次改進後平均運行時間縮短到了 0.839ms,提升了20%。

查看內存分配

如上面的例子所示,最快的程序通常是那些進行內存分配數量最少的程序。命令行標記 -benchmem 在報告中會包含內存分配統計數據。下面是優化前後兩個函數的基準測試報告:

Running tool: D:\Go\bin\go.exe test -benchmem -run=^$ gopl\ch11\word2 -bench . -coverprofile=C:\Users\Steed\AppData\Local\Temp\vscode-gotvbvaq\go-code-cover

goos: windows
goarch: amd64
pkg: gopl/ch11/word2
BenchmarkIsPalindrome-4      1000000          1095 ns/op         120 B/op          4 allocs/op
BenchmarkIsPalindrome2-4     2000000           871 ns/op         112 B/op          1 allocs/op
PASS
coverage: 88.2% of statements
ok      gopl/ch11/word2 4.185s
Success: Benchmarks passed.

優化前有4次內存分配,分配了120B的內存。優化有隻進行了1次內存分配,分配了112B的內存。(這裏關於內存的分配主要是切片擴容的機制。)

性能比較函數

之前的性能測試是告訴我們給定操作的絕對耗時,但是在很多情況下,需要關注的問題是兩個不同操作之間的相對耗時。比如如下的場景:

  • 如果一個函數需要1ms來處理一千個元素,那麼處理一萬個或者一百萬個元素需要多久。這樣的比較能揭示漸進增長函數的運行時間
  • I/O緩衝區要設置多大最佳。對一個應用使用一系列的大小進行基準測試,可以幫助我們選擇最小的緩衝區並帶來最佳的性能表現
  • 對於一個任務來講,哪種算法表現最佳?對兩個不同的算法使用相同的輸入,在重要的或者具有代表性的工作負載下,進行基準測試通常可以顯示出每個算法的優缺點

性能比較函數只是普通的代碼,表現形式通常是帶有一個參數的函數,再被多個不同的 Benchmark 函數傳入不同的值來調用,比如下面這樣:

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

參數 size 指定了輸入的大小,每個 Benchmark 函數傳入的值都不同但是在每個函數內部是一個常量。不要使用 b.N 來控制輸入的大小。除非是把它當做固定大小輸入的循環次數,否則基準測試的結果將毫無意義。
基準測試比較揭示的模式在程序設計階段很有用處,但是即使程序正常工作了,也不要丟掉基準測試。隨着程序的演變,或者它的輸入增長了,或者它被部署在其他的操作系統上並擁有一些新特性,這時仍然可以重用基準測試來回顧當初的設計決策。

性能剖析

當希望仔細地查看程序的速度是,發現關鍵代碼的最佳技術就是性能剖析。性能剖析是通過自動化手段在程序執行過程中基於一些性能事件的採樣來進行性能評測,然後再從這些採樣中推斷分析,得到的統計報告就稱作爲性能剖析(profile)。

獲取報告

Go 支持很多種性能剖析方式。其中,工具 go test 內置支持一些類別的性能剖析:

  • CPU 性能剖析
  • 堆性能剖析
  • 阻塞性能剖析

CPU 性能剖析
CPU 性能剖析識別出執行過程中需要 CPU 最多的函數。在每個 CPU 上面執行的線程都每隔幾毫秒會定期地被操作系統中斷,在每次中斷過程中記錄一個性能剖析事件,然後恢復正常執行。

堆性能剖析
堆性能剖析識別出負責分配最多內存的語句。性能剖析庫對協程內部內存分配調用進行採樣,平均每 512KB 的內存申請會觸發一個性能剖析事件。

阻塞性能剖析
阻塞性能剖析識別出那些阻塞協程最久的操作,例如系統調用,通道發送和接收數據,以及鎖等待等。性能分析庫在一個 goroutine 每次被上述操作之一阻塞的時候記錄一個事件。

獲取性能剖析報告很容易,只需要像下面這樣指定一個標誌參數即可。一次只獲取一種性能剖析報告,如果使用了多個標誌,一種類別的報告會把其他類別的報告覆蓋掉:

$ go test -cpuprofile=cpu.out
$ go test -blockprofile=block.out
$ go test -memprofile=mem.out

還可以對非測試程序進行性能剖析,性能剖析對於長時間運行的程序尤其有用。所以 Go 運行時的性能剖析特性可以通過 runtime API 來啓用。

分析報告

在獲取了性能剖析報告後,需要使用 pprof 工具來分析它。這是 Go 自帶的一個工具,但是因爲不經常使用,所以通過 go tool pprof 間接來使用它。它有很多特性和選項,但是基本的用法只有兩個參數:

  • 產生性能剖析結果的可執行文件
  • 性能剖析日誌

爲了使得性能剖析過程高效並且節約空間,性能剖析日誌裏沒有包含函數名稱而是使用它們的地址。這就需要可執行文件才能理解理解數據內容。通常情況下 go test 工具在測試完成之後就丟棄了用於測試而臨時產生的可執行文件,但在性能剖析啓用的時候,它保存並把可執行文件命名爲 foo.test,其中 foo 是被測試包的名字。

示例

下面的命令演示如何獲取和顯示簡單的 CPU 性能剖析。這裏選擇了 net\/http 包中的一個基準測試。通常情況下最後對我們關心的具有代表性的具體負載而構建的基準測試進行性能剖析。對測試用例進行基準測試永遠沒有代表性,這裏使用了過濾器 -run=NONE 來禁止那些測試:

F:\>go test -run=NONE -bench=ClientServerParallelTLS64 -cpuprofile=cpu.log net/http
goos: windows
goarch: amd64
pkg: net/http
BenchmarkClientServerParallelTLS64-4    2019/04/24 15:40:39 http: TLS handshake error from 127.0.0.1:55188: read tcp 127.0.0.1:55163->127.0.0.1:55188: use of closed network connection
2019/04/24 15:40:39 http: TLS handshake error from 127.0.0.1:55366: read tcp 127.0.0.1:55264->127.0.0.1:55366: use of closed network connection
2019/04/24 15:40:41 http: TLS handshake error from 127.0.0.1:57477: read tcp 127.0.0.1:57266->127.0.0.1:57477: use of closed network connection
   10000            198886 ns/op            9578 B/op        107 allocs/op
PASS
ok      net/http        3.697s

F:\>

運行完上面的測試後,會生成兩個文件,一個是測試報告,一個是用於測試而臨時產生的可執行文件。再用下面的命令打印測試報告:

F:\>go tool pprof -text -nodecount=10 ./http.test cpu.log
./http.test: open ./http.test: The system cannot find the file specified.
Fetched 1 source profiles out of 2
Type: cpu
Time: Apr 24, 2019 at 3:40pm (CST)
Duration: 2.71s, Total samples = 9820ms (362.69%)
Showing nodes accounting for 5720ms, 58.25% of 9820ms total
Dropped 370 nodes (cum <= 49.10ms)
Showing top 10 nodes out of 217
      flat  flat%   sum%        cum   cum%
    4220ms 42.97% 42.97%     4270ms 43.48%  runtime.cgocall
     210ms  2.14% 45.11%      260ms  2.65%  runtime.step
     200ms  2.04% 47.15%      490ms  4.99%  runtime.pcvalue
     190ms  1.93% 49.08%      190ms  1.93%  math/big.addMulVVW
     180ms  1.83% 50.92%      180ms  1.83%  runtime.osyield
     160ms  1.63% 52.55%      320ms  3.26%  runtime.scanobject
     160ms  1.63% 54.18%      160ms  1.63%  vendor/golang_org/x/crypto/curve25519.ladderstep
     150ms  1.53% 55.70%      150ms  1.53%  runtime.findObject
     140ms  1.43% 57.13%      140ms  1.43%  runtime.memmove
     110ms  1.12% 58.25%     1020ms 10.39%  runtime.gentraceback

F:\>

標記 -text 指定輸出的格式,這裏用的是一個文本表格,表格中每行是一個函數,這些函數是根據消耗CPU最多的規則排序的“熱函數”。
標記 -nodecount=10 限制輸出最高的10條記錄。

這裏是一份書上的性能剖析結果:

$ 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

這個性能剖析結果告訴我們,HTTPS基準測試中 crypto\/elliptic.p256ReduceDegree 函數佔用了將近一半的CPU資源,對性能佔很大比重。
相比之下,上面的性能剖析結果中,主要是runtime包的內存分配的函數,那麼減少內存消耗是一個有價值的優化。

對於更微妙的問題,最好使用 pprof 的圖形顯示功能。這需要 GraphViz 工具,可以從 http://www.graphviz.org 下載。然後使用標記 -web 生成函數的有向圖,並能標記出函數的CPU消耗數值,以及有顏色突出“熱函數”。點到爲止,未展開。

Example 函數

這是第三種也是最後一種測試函數,示例函數。名字以 Example 開頭,既沒有參數,也沒有返回值。
下面是IsPalindrome函數對應的示例函數:

func ExampleIsPalindrome() {
    fmt.Println(IsPalindrome("A man, a plan, a canal: Panama"))
    fmt.Println(IsPalindrome("palindrome"))
    // Output:
    // true
    // false
}

示例函數有三個目的:

  • 用作文檔
  • 作爲可執行測試
  • 提供一個真實的演練場

用作文檔

比起乏味的描述,舉一個好的例子是描述庫函數功能最簡潔直觀的方式。
基於 Example 函數的後綴,基於 Web 的文檔服務器 godoc 可以將示例函數(比如:ExampleIsPalindrome)和它所演示的函數或包(比如:IsPalindrome函數),關聯起來。
如果是一個名字叫 Example 的函數,那麼就會和包的文檔關聯。

作爲可執行測試

示例函數是可以通過 go test 運行的可執行測試。示例函數的最後如果有一段類型 // Output: 的註釋,就像上面的例子裏一樣。測試驅動程序將會執行這個函數並且檢查輸出到終端的內容與註釋是否匹配。

提供一個真實的演練場

http://golang.org 就是由 godoc 提供的文檔服務,它使用 Go Playground 來讓用戶在 Web 瀏覽器上編輯和運行每個示例函數。這可以作爲了解特定函數功能或者瞭解語言特性最快捷的方法。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章