24 | 測試的基本規則和流程(下)
Go 語言是一門很重視程序測試的編程語言,所以在上一篇中,我與你再三強調了程序測試的重要性,同時,也介紹了關於go test命令的基本規則和主要流程的內容。今天我們繼續分享測試的基本規則和流程。
知識擴展
問題 1:怎樣解釋功能測試的測試結果?
demo53.go
package main
import (
"errors"
"flag"
"fmt"
)
var name string
func init() {
flag.StringVar(&name, "name", "everyone", "The greeting object.")
}
func main() {
flag.Parse()
greeting, err := hello(name)
if err != nil {
fmt.Printf("error: %s\n", err)
return
}
fmt.Println(greeting, introduce())
}
// hello 用於生成問候內容。
func hello(name string) (string, error) {
if name == "" {
return "", errors.New("empty name")
}
return fmt.Sprintf("Hello, %s!", name), nil
}
// introduce 用於生成介紹內容。
func introduce() string {
return "Welcome to my Golang column."
}
demo53_test.go
package main
import (
"fmt"
"testing"
)
func TestHello(t *testing.T) {
var name string
greeting, err := hello(name)
if err == nil {
t.Errorf("The error is nil, but it should not be. (name=%q)",
name)
}
if greeting != "" {
t.Errorf("Nonempty greeting, but it should not be. (name=%q)",
name)
}
name = "Robert"
greeting, err = hello(name)
if err != nil {
t.Errorf("The error is not nil, but it should be. (name=%q)",
name)
}
if greeting == "" {
t.Errorf("Empty greeting, but it should not be. (name=%q)",
name)
}
expected := fmt.Sprintf("Hello, %s!", name)
if greeting != expected {
t.Errorf("The actual greeting %q is not the expected. (name=%q)",
greeting, name)
}
t.Logf("The expected greeting is %q.\n", expected)
}
func TestIntroduce(t *testing.T) {
intro := introduce()
expected := "Welcome to my Golang column."
if intro != expected {
t.Errorf("The actual introduce %q is not the expected.",
intro)
}
t.Logf("The expected introduce is %q.\n", expected)
}
func TestFail(t *testing.T) {
//t.Fail()
t.FailNow() // 此調用會讓當前的測試立即失敗。
t.Log("Failed.")
}
我們先來看下面的測試命令和結果:
$ go test puzzlers/article20/q2
ok puzzlers/article20/q2 0.008s
以$符號開頭表明此行展現的是我輸入的命令。在這里,我輸入了go test puzzlers/article20/q2,這表示我想對導入路徑為puzzlers/article20/q2的代碼包進行測試。代碼下面一行就是此次測試的簡要結果。
這個簡要結果有三塊內容。最左邊的ok表示此次測試成功,也就是說沒有發現測試結果不如預期的情況。
當然了,這里全由我們編寫的測試代碼決定,我們總是認定測試代碼本身沒有 Bug,並且忠誠地落實了我們的測試意圖。在測試結果的中間,顯示的是被測代碼包的導入路徑。
而在最右邊,展現的是此次對該代碼包的測試所耗費的時間,這里顯示的0.008s,即 8 毫秒。不過,當我們緊接着第二次運行這個命令的時候,輸出的測試結果會略有不同,如下所示:
$ go test puzzlers/article20/q2
ok puzzlers/article20/q2 (cached)
可以看到,結果最右邊的不再是測試耗時,而是(cached)。這表明,由於測試代碼與被測代碼都沒有任何變動,所以go test命令直接把之前緩存測試成功的結果打印出來了。
go 命令通常會緩存程序構建的結果,以便在將來的構建中重用。我們可以通過運行go env GOCACHE命令來查看緩存目錄的路徑。緩存的數據總是能夠正確地反映出當時的各種源碼文件、構建環境、編譯器選項等等的真實情況。
一旦有任何變動,緩存數據就會失效,go 命令就會再次真正地執行操作。所以我們並不用擔心打印出的緩存數據不是實時的結果。go 命令會定期地刪除最近未使用的緩存數據,但是,如果你想手動刪除所有的緩存數據,運行一下go clean -cache命令就好了。
對於測試成功的結果,go 命令也是會緩存的。運行go clean -testcache將會刪除所有的測試結果緩存。不過,這樣做肯定不會刪除任何構建結果緩存。
此外,設置環境變量GODEBUG的值也可以稍稍地改變 go 命令的緩存行為。比如,設置值為gocacheverify=1將會導致 go 命令繞過任何的緩存數據,而真正地執行操作並重新生成所有結果,然后再去檢查新的結果與現有的緩存數據是否一致。
總之,我們並不用在意緩存數據的存在,因為它們肯定不會妨礙go test命令打印正確的測試結果。
你可能會問,如果測試失敗,命令打印的結果將會是怎樣的?如果功能測試函數的那個唯一參數被命名為t,那么當我們在其中調用t.Fail方法時,雖然當前的測試函數會繼續執行下去,但是結果會顯示該測試失敗。如下所示:
$ go test puzzlers/article20/q2
--- FAIL: TestFail (0.00s)
demo53_test.go:49: Failed.
FAIL
FAIL puzzlers/article20/q2 0.007s
我們運行的命令與之前是相同的,但是我新增了一個功能測試函數TestFail,並在其中調用了t.Fail方法。測試結果顯示,對被測代碼包的測試,由於TestFail函數的測試失敗而宣告失敗。
func TestFail(t *testing.T) {
t.Fail()
t.Log("Failed.")
}
注意,對於失敗測試的結果,go test命令並不會進行緩存,所以,這種情況下的每次測試都會產生全新的結果。另外,如果測試失敗了,那么go test命令將會導致:失敗的測試函數中的常規測試日志一並被打印出來。
在這里的測試結果中,之所以顯示了“demo53_test.go:49: Failed.”這一行,是因為我在TestFail函數中的調用表達式t.Fail()的下邊編寫了代碼t.Log("Failed.")。
t.Log方法以及t.Logf方法的作用,就是打印常規的測試日志,只不過當測試成功的時候,go test命令就不會打印這類日志了。如果你想在測試結果中看到所有的常規測試日志,那么可以在運行go test命令的時候加入標記-v。
若我們想讓某個測試函數在執行的過程中立即失敗,則可以在該函數中調用t.FailNow方法。
我在下面把TestFail函數中的t.Fail()改為t.FailNow()。
func TestFail(t *testing.T) {
//t.Fail()
t.FailNow() // 此調用會讓當前的測試立即失敗。
t.Log("Failed.")
}
與t.Fail()不同,在t.FailNow()執行之后,當前函數會立即終止執行。換句話說,該行代碼之后的所有代碼都會失去執行機會。
在這樣修改之后,我再次運行上面的命令,得到的結果如下:
--- FAIL: TestFail (0.00s)
FAIL
FAIL puzzlers/article20/q2 0.008s
顯然,之前顯示在結果中的常規測試日志並沒有出現在這里。
順便說一下,如果你想在測試失敗的同時打印失敗測試日志,那么可以直接調用t.Error方法或者t.Errorf方法。
前者相當於t.Log方法和t.Fail方法的連續調用,而后者也與之類似,只不過它相當於先調用了t.Logf方法。
除此之外,還有t.Fatal方法和t.Fatalf方法,它們的作用是在打印失敗錯誤日志之后立即終止當前測試函數的執行並宣告測試失敗。更具體地說,這相當於它們在最后都調用了t.FailNow方法。
好了,到此為止,你是不是已經會解讀功能測試的測試結果了呢?
問題 2:怎樣解釋性能測試的測試結果?
性能測試與功能測試的結果格式有很多相似的地方。我們在這里僅關注前者的特殊之處。請看下面的打印結果。
$ go test -bench=. -run=^$ puzzlers/article20/q3
goos: darwin
goarch: amd64
pkg: puzzlers/article20/q3
BenchmarkGetPrimes-8 500000 2314 ns/op
PASS
ok puzzlers/article20/q3 1.192s
我在運行go test命令的時候加了兩個標記。第一個標記及其值為-bench=.,只有有了這個標記,命令才會進行性能測試。該標記的值.表明需要執行任意名稱的性能測試函數,當然了,函數名稱還是要符合 Go 程序測試的基本規則的。
第二個標記及其值是-run=$,這個標記用於表明需要執行哪些功能測試函數,這同樣也是以函數名稱為依據的。該標記的值$意味着:只執行名稱為空的功能測試函數,換句話說,不執行任何功能測試函數。
你可能已經看出來了,這兩個標記的值都是正則表達式。實際上,它們只能以正則表達式為值。此外,如果運行go test命令的時候不加-run標記,那么就會使它執行被測代碼包中的所有功能測試函數。
再來看測試結果,重點說一下倒數第三行的內容。BenchmarkGetPrimes-8被稱為單個性能測試的名稱,它表示命令執行了性能測試函數BenchmarkGetPrimes,並且當時所用的最大 P 數量為8。
最大 P 數量相當於可以同時運行 goroutine 的邏輯 CPU 的最大個數。這里的邏輯 CPU,也可以被稱為 CPU 核心,但它並不等同於計算機中真正的 CPU 核心,只是 Go 語言運行時系統內部的一個概念,代表着它同時運行 goroutine 的能力。
順便說一句,一台計算機的 CPU 核心的個數,意味着它能在同一時刻執行多少條程序指令,代表着它並行處理程序指令的能力。
我們可以通過調用 runtime.GOMAXPROCS函數改變最大 P 數量,也可以在運行go test命令時,加入標記-cpu來設置一個最大 P 數量的列表,以供命令在多次測試時使用。
至於怎樣使用這個標記,以及go test命令執行的測試流程,會因此做出怎樣的改變,我們在下一篇文章中再討論。
在性能測試名稱右邊的是,go test命令最后一次執行性能測試函數(即BenchmarkGetPrimes函數)的時候,被測函數(即GetPrimes函數)被執行的實際次數。這是什么意思呢?
go test命令在執行性能測試函數的時候會給它一個正整數,若該測試函數的唯一參數的名稱為b,則該正整數就由b.N代表。我們應該在測試函數中配合着編寫代碼,比如:
for i := 0; i < b.N; i++ {
GetPrimes(1000)
}
我在一個會迭代b.N次的循環中調用了GetPrimes函數,並給予它參數值1000。go test命令會先嘗試把b.N設置為1,然后執行測試函數。
如果測試函數的執行時間沒有超過上限,此上限默認為 1 秒,那么命令就會改大b.N的值,然后再次執行測試函數,如此往復,直到這個時間大於或等於上限為止。
當某次執行的時間大於或等於上限時,我們就說這是命令此次對該測試函數的最后一次執行。這時的b.N的值就會被包含在測試結果中,也就是上述測試結果中的500000。
我們可以簡稱該值為執行次數,但要注意,它指的是被測函數的執行次數,而不是性能測試函數的執行次數。
最后再看這個執行次數的右邊,2314 ns/op表明單次執行GetPrimes函數的平均耗時為2314納秒。這其實就是通過將最后一次執行測試函數時的執行時間,除以(被測函數的)執行次數而得出的。
(性能測試結果的基本解讀)
以上這些,就是對默認情況下的性能測試結果的基本解讀。你看明白了嗎?
demo54.go
package q3
import (
"math"
)
// GetPrimes 用於獲取小於或等於參數max的所有質數。
// 本函數使用的是愛拉托遜斯篩選法(Sieve Of Eratosthenes)。
func GetPrimes(max int) []int {
if max <= 1 {
return []int{}
}
marks := make([]bool, max)
var count int
squareRoot := int(math.Sqrt(float64(max)))
for i := 2; i <= squareRoot; i++ {
if marks[i] == false {
for j := i * i; j < max; j += i {
if marks[j] == false {
marks[j] = true
count++
}
}
}
}
primes := make([]int, 0, max-count)
for i := 2; i < max; i++ {
if marks[i] == false {
primes = append(primes, i)
}
}
return primes
}
demo54_test.go
package q3
import "testing"
func BenchmarkGetPrimes(b *testing.B) {
for i := 0; i < b.N; i++ {
GetPrimes(1000)
}
}
總結
注意,對於功能測試和性能測試,命令執行測試流程的方式會有些不同。另外一個重要的問題是,我們在與go test命令交互時,怎樣解讀它提供給我們的信息。只有解讀正確,你才能知道測試的成功與否,失敗的具體原因以及嚴重程度等等。
除此之外,對於性能測試,你還需要關注命令輸出的計算資源使用提示,以及各種性能度量。
這兩篇的文章中,我們一起學習了不少東西,但是其實還不夠。我們只是探討了go test命令以及testing包的基本使用方式。
在下一篇,我們還會討論更高級的內容。這將涉及go test命令的各種標記、testing包的更多 API,以及更復雜的測試結果。
思考題
在編寫示例測試函數的時候,我們怎樣指定預期的打印內容?
筆記源碼
https://github.com/MingsonZheng/go-core-demo
本作品采用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。
歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含鏈接: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改后的作品務必以相同的許可發布。