您好,登錄后才能下訂單哦!
測試是自動化測試的簡稱,即編寫簡單的程序來確保程序(產品代碼)在該測試中針對特定輸入產生預期的輸出。這些測試主要分兩種:
go test 子命令是 Go 語言包的測試驅動程序。在一個包目錄中,以 _test.go 結尾的文件不是 go build 命令編譯的目標,而是 go test 編譯的目標。
在 *_test.go 的測試源碼文件中,有三種類型的函數:
功能測試函數,以 Test 開頭,用來檢測一些程序邏輯的正確性。
基準測試函數,以 Benchmark 開頭,用來測試程序的性能。
示例函數,以 Example 開頭,提供一個機器檢查過的示例文檔。
每一個測試文件必須導入 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`)
}
}
這個最初版本的回文判斷函數比較簡陋,有些明顯也是回文的情況,但是無法被現在這個版本的函數檢測出來:
針對上面兩種回文,又寫了新的測試用例:
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 命令失敗了,錯誤信息如下:
PS G:\Steed\Documents\Go\src\gopl\ch21\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/ch21/word1 0.292s
PS G:\Steed\Documents\Go\src\gopl\ch21\word1>
這里是一個比較好的實踐,先寫測試然后發現它觸發的的錯誤。通過這步,可以定位到真正要解決的問題,并在修復后確認問題已經解決。
運行 go test 還可以指定一些參數:
PS G:\Steed\Documents\Go\src\gopl\ch21\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/ch21/word1 0.250s
PS G:\Steed\Documents\Go\src\gopl\ch21\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\ch21\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/ch21/echo 0.163s
PS H:\Go\src\gopl\ch21\echo>
錯誤信息首先描述了想要進行的操作,使用了類似 Go 的語法,就像一個函數調用。然后依次是實際獲得個值和預期的結果。這樣的錯誤信息就很有幫助。
測試中的錯誤處理
還要注意,測試代碼里并沒有調用 log.Fatal 或 os.Exit,因為這兩個調用會阻止跟蹤的過程,這兩個函數的調用可以認為是 main 函數的特權。如果有時候發生了未預期的錯誤或者崩潰,即使測試用例本身失敗了,測試驅動程序也還可以繼續工作。預期的的錯誤應該通過返回一個非空的 error 值來報告,就像上面的測試代碼里做的那樣。
測試的一種分類方式是基于對所要進行測試的包的內部的了解程度:
白盒這個名字是傳統的說法,凈盒(clear box)的說法更準確。
以上兩種方法是互補的。黑盒測試通常更加健壯,程序更新后基本不需要修改。并且可以幫助測試者了解用戶的情況以及發現API設計的缺陷。反之,白盒測試可以對實現的特定之處提供更詳細的覆蓋測試。
之前的內容已經分別給出了這兩種測試方法的例子:
偽實現
在寫 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 = "notifications@example.com"
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 = "steed@example.org"
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 工具來匯總一個包目錄中哪些是產品代碼,哪些是包內測試、哪些是外部測試。這里用 fmt 包作為例子。
GoFiles
這類文件是包含產品代碼的文件列表,這些文件是 go build 命令將編譯進程序的代碼:
PS H:\Go\src\gopl\ch21> go list -f="{{.GoFiles}}" fmt
[doc.go format.go print.go scan.go]
TestGoFiles
這類文件也屬于 fmt 包,但是這些以 _test.go 結尾的文件是測試源碼文件,僅在編譯測試的時候才會使用:
PS H:\Go\src\gopl\ch21> go list -f="{{.TestGoFiles}}" fmt
[export_test.go]
這里的 export_test.go 這個文件還有特殊的意義,后面會單獨講。
XTestGoFiles
這類是包外部測試文件列表,這些同樣的測試源碼文件,僅用在測試過程中:
PS H:\Go\src\gopl\ch21> 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\ch21\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/ch21/storage2
ok gopl/ch21/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/ch21/storage2
ok gopl/ch21/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 并且擁有一個 *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\ch21\word2> go test -bench="."
goos: windows
goarch: amd64
pkg: gopl/ch21/word2
BenchmarkIsPalindrome-4 1000000 1052 ns/op
PASS
ok gopl/ch21/word2 2.253s
PS G:\Steed\Documents\Go\src\gopl\ch21\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\ch21\word2 -bench . -coverprofile=C:\Users\Steed\AppData\Local\Temp\vscode-gotvbvaq\go-code-cover
goos: windows
goarch: amd64
pkg: gopl/ch21/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/ch21/word2 4.185s
Success: Benchmarks passed.
優化前有4次內存分配,分配了120B的內存。優化有只進行了1次內存分配,分配了112B的內存。(這里關于內存的分配主要是切片擴容的機制。)
之前的性能測試是告訴我們給定操作的絕對耗時,但是在很多情況下,需要關注的問題是兩個不同操作之間的相對耗時。比如如下的場景:
性能比較函數只是普通的代碼,表現形式通常是帶有一個參數的函數,再被多個不同的 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 上面執行的線程都每隔幾毫秒會定期地被操作系統中斷,在每次中斷過程中記錄一個性能剖析事件,然后恢復正常執行。
堆性能剖析
堆性能剖析識別出負責分配最多內存的語句。性能剖析庫對協程內部內存分配調用進行采樣,平均每 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 開頭,既沒有參數,也沒有返回值。
下面是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 瀏覽器上編輯和運行每個示例函數。這可以作為了解特定函數功能或者了解語言特性最快捷的方法。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。