Go語言核心36講(Go語言實戰與應用二)--學習筆記

MingsonZheng發表於2021-11-11

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納秒。這其實就是通過將最後一次執行測試函式時的執行時間,除以(被測函式的)執行次數而得出的。

image

(效能測試結果的基本解讀)

以上這些,就是對預設情況下的效能測試結果的基本解讀。你看明白了嗎?

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/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

相關文章