基準測試
確定代碼是快或慢非常復雜。我們不用自己計算,應使用Go測試框架內置的基準測試。下面來看??第15章的GitHub代碼庫??sample_code/bench目錄下的函數:
func FileLen(f string, bufsize int) (int, error) {file, err := os.Open(f)if err != nil {return 0, err}defer file.Close()count := 0for {buf := make([]byte, bufsize)num, err := file.Read(buf)count += numif err != nil {break}}return count, nil
}
這個函數計算文件中的字數。它接收兩個參數,文件名和用于讀取文件的緩沖大小(稍后會講到第二個參數的作用)。
在測試其速度前,應當測試代碼運行是否正常。以下是簡單的測試:
func TestFileLen(t *testing.T) {result, err := FileLen("testdata/data.txt", 1)if err != nil {t.Fatal(err)}if result != 65204 {t.Error("Expected 65204, got", result)}
}
下面來看運行該函數需要多長時間。我們的目標是找出該使用多大的緩沖區讀取文件。
注:在花時間墜入優化的深淵之前,請明確程序需要進行優化。如果程序已經足夠快,滿足了響應要求,并且使用的內存量在接受范圍之內,那么將時間花在新增功能和修復bug上會更好。業務的需求決定了何為"足夠快"和"接受范圍之內"。
在 Go 中,基準測試是測試文件中以單詞??Benchmark?
??開頭的函數,它們接受一個類型為??*testing.B?
??的參數。這種類型包含了??*testing.T?
?的所有功能,以及用于基準測試的額外支持。首先看一個使用 1 字節緩沖區的基準測試:
var blackhole intfunc BenchmarkFileLen1(b *testing.B) {for i := 0; i < b.N; i++ {result, err := FileLen("testdata/data.txt", 1)if err != nil {b.Fatal(err)}blackhole = result}
}
??blackhole?
?? 包級變量是有作用的。我們將 ??FileLen?
?? 的結果寫入這個包級變量,以確保編譯器不會自負到優化掉對 ??FileLen?
? 的調用,而對基準測試產生破壞。
每個 Go 基準測試都必須有一個循環,從 0 迭代到 ??b.N?
??。測試框架會一遍又一遍地調用我們的基準測試函數,每次傳遞更大的 ??N?
? 值,直到確保時間結果準確為止。馬上會在輸出中看到這一點。
我們通過向??go test?
??傳遞??-bench?
??標記來運行基準測試。該標記接收一個正則表達式來描述要運行的基準測試名稱。使用??-bench=.?
??來運行所有基準測試。第二個標記??-benchmem?
?在基準測試輸出中包含內存分配信息。所有測試在基準測試之前運行,因此只有在測試通過時才能對代碼進行基準測試。
以下是運行基準測試我電腦上的輸出:
BenchmarkFileLen1-12 25 47201025 ns/op 65342 B/op 65208 allocs/op
運行含內存分配信息的基準測試輸出有5列。分別如下:
BenchmarkFileLen1-12基準測試的名稱,中間杠,加用于測試的GOMAXPROCS的值。25產生穩定輸出運行測試的次數。47201025 ns/op該基準測試運行單次通過的時間,單位是納秒(1秒為1,000,000,000納秒)。65342 B/op基準測試單次通過所分配的字節數。65208 allocs/op基準測試單次通過堆上分配字節的次數。其值小于等于字節的分配數。
我們已經得到1字節緩沖的結果,下面來看使用其它大小緩沖所得到的結果:
func BenchmarkFileLen(b *testing.B) {for _, v := range []int{1, 10, 100, 1000, 10000, 100000} {b.Run(fmt.Sprintf("FileLen-%d", v), func(b *testing.B) {for i := 0; i < b.N; i++ {result, err := FileLen("testdata/data.txt", v)if err != nil {b.Fatal(err)}blackhole = result}})}
}
和使用??t.Run?
??啟動表格測試類似,我們使用??b.Run?
?啟動不同輸入的基準測試。作者電腦上的結果如下:
BenchmarkFileLen/FileLen-1-12 25 47828842 ns/op 65342 B/op 65208 allocs/op
BenchmarkFileLen/FileLen-10-12 230 5136839 ns/op 104488 B/op 6525 allocs/op
BenchmarkFileLen/FileLen-100-12 2246 509619 ns/op 73384 B/op 657 allocs/op
BenchmarkFileLen/FileLen-1000-12 16491 71281 ns/op 68744 B/op 70 allocs/op
BenchmarkFileLen/FileLen-10000-12 42468 26600 ns/op 82056 B/op 11 allocs/op
BenchmarkFileLen/FileLen-100000-12 36700 30473 ns/op 213128 B/op 5 allocs/op
結果符合預期;隨著緩沖區大小的增加,分配次數減少,代碼運行速度更快,直至緩沖區大于文件的大小。當緩沖區大于文件大小時,會有額外的分配導致輸出減慢。如果我們預期文件大致是這個大小,那么10,000 字節的緩沖區效果最佳。
但是有一個改動可以進一步提高性能。現在每次從文件獲取下一組字節時都重新分配緩沖區。這是沒必要的。如果我們在循環之前進行字節切片分配,然后重新運行基準測試,會看到提升:
BenchmarkFileLen/FileLen-1-12 25 46167597 ns/op 137 B/op 4 allocs/op
BenchmarkFileLen/FileLen-10-12 261 4592019 ns/op 152 B/op 4 allocs/op
BenchmarkFileLen/FileLen-100-12 2518 478838 ns/op 248 B/op 4 allocs/op
BenchmarkFileLen/FileLen-1000-12 20059 60150 ns/op 1160 B/op 4 allocs/op
BenchmarkFileLen/FileLen-10000-12 62992 19000 ns/op 10376 B/op 4 allocs/op
BenchmarkFileLen/FileLen-100000-12 51928 21275 ns/op 106632 B/op 4 allocs/op
現在分配的次數相同且較小,每個緩沖區大小僅需四次分配。有意思的是,我們現在可以作出權衡。如果內存緊張,可以使用較小的緩沖區大小,在犧牲性能的情況下節約內存。
Go代碼性能調優
如果基準測試顯示存在性能或內存問題,下一步是確定問題的具體原因。Go 包含了分析工具,可從正在運行的程序中收集 CPU 和內存使用數據,還有用于可視化和解釋生成的數據的工具。甚至可以暴露一個 Web 服務端點,遠程從運行的 Go 服務中收集分析信息。
討論性能調優工具不在我們的范疇。線上有許多很好的資源提供相關信息。一個不錯的起點是 Julia Evans 的博文??使用 pprof 對 Go 程序做性能分析??。
Go的樁代碼(Stub)
截至目前,我們測試的函數都不依賴其他代碼的。但這并不具代表性,因為大多數代碼都存在依賴關系。我們學過Go提供了兩種方式來抽象函數調用:定義函數類型和定義接口。這些抽象不僅有助寫出模塊化的生產代碼,還有助于我們編寫單元測試。
小貼士:在代碼有抽象依賴時,編寫單元測試會更容易!
來看??第15章的GitHub代碼庫??的sample_code/solver目錄中示例代碼。我們定義了一個名為 ??Processor?
? 的類型:
type Processor struct {Solver MathSolver
}
其中字段的類型為??MathSolver?
?:
type MathSolver interface {Resolve(ctx context.Context, expression string) (float64, error)
}
稍后我們會實現并測試??MathSolver?
?。
??Processor?
??還需要一個從??io.Reader?
?中讀取表達式并返回計算值的方法:
func (p Processor) ProcessExpression(ctx context.Context, r io.Reader)(float64, error) {curExpression, err := readToNewLine(r)if err != nil {return 0, err}if len(curExpression) == 0 {return 0, errors.New("no expression to read")}answer, err := p.Solver.Resolve(ctx, curExpression)return answer, err
}
下面編寫代碼測試??ProcessExpression?
??。首先,人們需要簡單地實現??Resolve?
?方法以供測試:
type MathSolverStub struct{}func (ms MathSolverStub) Resolve(ctx context.Context, expr string)(float64, error) {switch expr {case "2 + 2 * 10":return 22, nilcase "( 2 + 2 ) * 10":return 40, nilcase "( 2 + 2 * 10":return 0, errors.New("invalid expression: ( 2 + 2 * 10")}return 0, nil
}
接下來,我們編寫使用這一stub的單元測試(生產代碼還應測試錯誤消息,但這里為保持簡潔省略該操作):
func TestProcessorProcessExpression(t *testing.T) {p := Processor{MathSolverStub{}}in := strings.NewReader(`2 + 2 * 10
( 2 + 2 ) * 10
( 2 + 2 * 10`)data := []float64{22, 40, 0}hasErr := []bool{false, false, true}for i, d := range data {result, err := p.ProcessExpression(context.Background(), in)if err != nil && !hasErr[i] {t.Error(err)}if result != d {t.Errorf("Expected result %f, got %f", d, result)}}
}
再進行測試,一切正常。
雖然大部分Go接口僅有一到兩個方法,但也有更多的。有時會發現有多個方法的接口。我們來看??第15章的GitHub代碼庫??sample_code/stub目錄中的代碼。假設有一個這樣的接口:
type Entities interface {GetUser(id string) (User, error)GetPets(userID string) ([]Pet, error)GetChildren(userID string) ([]Person, error)GetFriends(userID string) ([]Person, error)SaveUser(user User) error
}
在測試依賴于大型接口的代碼,有兩種模式。第一種是將接口內嵌到結構體中。在結構體中內嵌接口會自動在結構體中定義接口的所有方法。它不提供這些方法的具體實現,因此需要實現當前所需測試的方法。假設??Logic?
??是一個包含??Entities?
?類型字段的結構體:
type Logic struct {Entities Entities
}
假如想測試如下方法:
func (l Logic) GetPetNames(userId string) ([]string, error) {pets, err := l.Entities.GetPets(userId)if err != nil {return nil, err}out := make([]string, len(pets))for _, p := range pets {out = append(out, p.Name)}return out, nil
}
這個方法僅使用對??Entities?
??聲明的一個方法,即??GetPets?
??。不必實現??GetPets?
??上的所有方法的stub來測試??GetPets?
?,我們可以編寫一個僅實現所需測試方法的stub結構體來完成測試:
type GetPetNamesStub struct {Entities
}func (ps GetPetNamesStub) GetPets(userID string) ([]Pet, error) {switch userID {case "1":return []Pet{{Name: "Bubbles"}}, nilcase "2":return []Pet{{Name: "Stampy"}, {Name: "Snowball II"}}, nildefault:return nil, fmt.Errorf("invalid id: %s", userID)}
}
然后編寫單元測試,將stub插入??Logic?
?:
func TestLogicGetPetNames(t *testing.T) {data := []struct {name stringuserID stringpetNames []string}{{"case1", "1", []string{"Bubbles"}},{"case2", "2", []string{"Stampy", "Snowball II"}},{"case3", "3", nil},}l := Logic{GetPetNamesStub{}}for _, d := range data {t.Run(d.name, func(t *testing.T) {petNames, err := l.GetPetNames(d.userID)if err != nil {t.Error(err)}if diff := cmp.Diff(d.petNames, petNames); diff != "" {t.Error(diff)}})}
}
(順便提下,??GetPetNames?
?方法有一個bug。你發現了嗎?即便是簡單的方法有時也可能存在bug。)
警告:如在stub結構體中嵌入接口,請確保實現測試期間調用的所有方法!如調用未實現的方法,測試會panic。
如僅需為單個測試實現接口中的一個或兩個方法,這種方法效果很好。但在需要對不同輸入和輸出的測試調用相同方法時,其缺點就會暴露出來。這時,需要在同一實現中包含每個測試的各種可能結果,或者為每個測試重新實現該結構體。這很快就會難以理解和維護。更好的解決方案是創建一個將方法調用代理到函數字段的stub結構體。對于??Entities?
?上定義的每個方法,我們在stub結構體中定義一個具有匹配簽名的函數字段:
type EntitiesStub struct {getUser func(id string) (User, error)getPets func(userID string) ([]Pet, error)getChildren func(userID string) ([]Person, error)getFriends func(userID string) ([]Person, error)saveUser func(user User) error
}
然后通過定義方法來讓??EntitiesStub?
??實現??Entities?
?接口。在各方法中,我們調用相應函數字段。如:
func (es EntitiesStub) GetUser(id string) (User, error) {return es.getUser(id)
}func (es EntitiesStub) GetPets(userID string) ([]Pet, error) {return es.getPets(userID)
}
創建好這一stub,就可以通過用于表格測試的數據結構體中的除非來支持不同測試用例中不同方法的實現:
func TestLogicGetPetNames(t *testing.T) {data := []struct {name stringgetPets func(userID string) ([]Pet, error)userID stringpetNames []stringerrMsg string}{{"case1", func(userID string) ([]Pet, error) {return []Pet{{Name: "Bubbles"}}, nil}, "1", []string{"Bubbles"}, ""},{"case2", func(userID string) ([]Pet, error) {return nil, errors.New("invalid id: 3")}, "3", nil, "invalid id: 3"},}l := Logic{}for _, d := range data {t.Run(d.name, func(t *testing.T) {l.Entities = EntitiesStub{getPets: d.getPets}petNames, err := l.GetPetNames(d.userID)if diff := cmp.Diff(petNames, d.petNames); diff != "" {t.Error(diff)}var errMsg stringif err != nil {errMsg = err.Error()}if errMsg != d.errMsg {t.Errorf("Expected error `%s`, got `%s`", d.errMsg, errMsg)}})}
}
我們在??data?
??的匿名結構體中添加了一個函數類型的字段。在每個測試用例中,都指定一個返回與??GetPets?
??相同數據的函數。通過這種方式編寫測試樁,可以清楚地了解每個測試用例應該返回什么。每個測試運行時,我們都會實例化一個新的??EntitiesStub?
??,并將測試數據中的??getPets?
??賦值給??EntitiesStub?
??中的??getPets?
?函數字段。
模擬和樁測試
術語"模擬"(mock)和"樁"(stub)測試經常互換使用,但它們實際上是兩個不同的概念。Martin Fowler,一個在與軟件開發領域令人尊敬的前輩,寫過一篇有關mock測試的??博客文章??,講到了模擬和樁測試之間的區別。簡言之,樁測試對給定的輸入返回固定的值,而模擬測試則驗證一組調用是否按照預期的順序和預期的輸入發生。
在示例中,我們使用測試樁來返回給定響應的固定值。讀者可以手動編寫自己的模擬測試,或者可以使用第三方庫來生成。最流行的兩個是Google的??gomock???庫和Stretchr的??testify??庫。
httptest
為調用HTTP服務的函數編寫測試可能會很困難。過去這會成為一個集成測試,需要啟動一個作為函數調用的服務的測試實例。Go標準庫內置??net/http/httptest???包,可以更容易地生成HTTP服務的測試樁。我們回到??第15章的GitHub代碼庫??的sample_code/solver目錄,實現一個調用HTTP服務的??MathSolver?
?來評估表達式:
type RemoteSolver struct {MathServerURL stringClient *http.Client
}func (rs RemoteSolver) Resolve(ctx context.Context, expression string)(float64, error) {req, err := http.NewRequestWithContext(ctx, http.MethodGet,rs.MathServerURL+"?expression="+url.QueryEscape(expression),nil)if err != nil {return 0, err}resp, err := rs.Client.Do(req)if err != nil {return 0, err}defer resp.Body.Close()contents, err := io.ReadAll(resp.Body)if err != nil {return 0, err}if resp.StatusCode != http.StatusOK {return 0, errors.New(string(contents))}result, err := strconv.ParseFloat(string(contents), 64)if err != nil {return 0, err}return result, nil
}
現在來看如何使用??httptest?
??庫在不啟動服務端的情況下測試這段代碼。代碼位于??第15章的GitHub代碼庫??的solver/remote_solver_test.go中的??TestRemoteSolver_Resolve?
??函數中,以下是要點。首先,我們希望保障傳遞給函數的數據到達服務端。因此,在測試函數中,我們定義了一個名為??info?
??的類型來保存輸入和輸出,以及一個名為??io?
?的變量,該變量被賦予當前的輸入和輸出值:
type info struct {expression stringcode intbody string
}
var io info
接著偽裝啟動一個遠程服務端,使用它來配置??RemoteSolver?
?的實例:
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {expression := req.URL.Query().Get("expression")if expression != io.expression {rw.WriteHeader(http.StatusBadRequest)fmt.Fprintf(rw, "expected expression '%s', got '%s'",io.expression, expression)return}rw.WriteHeader(io.code)rw.Write([]byte(io.body))}))
defer server.Close()
rs := RemoteSolver{MathServerURL: server.URL,Client: server.Client(),
}
??httptest.NewServer?
??函數在隨機未使用的端口上啟動一個HTTP服務端。我們需要提供一個??http.Handler?
??實現在處理請求。因其是服務端,必須在測試完成后關閉。??http.Handler?
??實例的URL通過??server?
??實例的??URL?
??字段指定,以及有一個預配置的??http.Client?
??與測試服務器之間進行通訊。我們將它們傳遞給??RemoteSolver?
?。
函數剩下的部分與其它表格測試并無分別:
data := []struct {name stringio inforesult float64}{{"case1", info{"2 + 2 * 10", http.StatusOK, "22"}, 22},// remaining cases}for _, d := range data {t.Run(d.name, func(t *testing.T) {io = d.ioresult, err := rs.Resolve(context.Background(), d.io.expression)if result != d.result {t.Errorf("io `%f`, got `%f`", d.result, result)}var errMsg stringif err != nil {errMsg = err.Error()}if errMsg != d.errMsg {t.Errorf("io error `%s`, got `%s`", d.errMsg, errMsg)}})}
需要注意變量??io?
?由兩個不同的閉包捕獲:一個用于樁服務器,一個用于運行各條測試。我們在一個閉包中寫入、在另一個閉包中讀取。在生產代碼里這種做法不好,但在單個函數的測試代碼完全成立。
集成測試和構建標簽
雖然??httptest?
?提供了一種不依賴外部服務的測試方式,但還是應該編寫集成測試、連接其它服務的自動化測試。這些可以驗證我們對服務API的理解是正確的。挑戰是如何對自動化測試進行分組,只應在存在支撐環境時才運行集成測試。同時,集成測試一般比單元測試慢,所以不要頻繁測試。
在??Go語言工具??中,我們講到了構建標簽,由Go編譯器用于控制文件何時編譯。雖然它們主要用于讓開發者編寫針對指定操作系統、CPU或Go版本的代碼,但也可以利用其能力指定自定義標簽來控制何時編譯及運行測試。
讓我們嘗試使用我們的數學求解項目。通過??docker pull jonbodner/math-server?
??使用??Docker???下載一個服務實現,然后在本地使用??docker run -p 8080:8080 jonbodner/math-server?
?命令將服務運行在8080端口上。
注: 如果讀者沒有安裝Docker,或者希望自行構建代碼,可以在??GitHub??上找到相關代碼。
我們需要編寫一個集成測試,以確保我們的??Resolve?
??方法正確地與數學服務器進行通信。??第15章的GitHub代碼庫???中的??sample_code/solver/remote_solver_integration_test.go?
??文件中的??TestRemoteSolver_ResolveIntegration?
?函數包含了一個完整的測試。這個測試看起來和我們之前編寫的表格測試一樣。要注意的是文件的第一行,包聲明之前由一行分隔,如下所示:
//go:build integration
與我們所編寫的其它測試一同運行集成測試,使用:
$ go test -tags integration -v ./...
使用?
?-short?
??標記另一種分組測試的方法是使用?
?go test?
??命令加??-short?
?標記。如果希望跳過執行時間較長的測試,可以通過在測試函數開頭添加以下代碼來標記出慢速測試:if testing.Short() { t.Skip("skipping test in short mode.") }
在只希望運行短測試時,對?
?go test?
??傳遞??-short?
?標記。使用?
?-short?
??標記運行短測試時需要注意一些問題。如果使用該標記,測試僅分為兩個級別:短測試和所有測試。通過使用構建標簽,可以對集成測試分組,指定它們運行需要使用的服務。另一個不使用??-short?
??標記來表示集成測試的理由是邏輯上的。構建標簽表示依賴關系,而??-short?
??標記只是表示不希望運行耗時很長的測試。這是不同的概念。最后,我認為??-short?
?標記不直觀。始終應該運行短測試。更合理的做法是用一個標記來包含長時間運行的測試,而不是排除它們。
通過競態檢查器發現并發問題
雖然Go內置支持并發,還是會出現bug。很容易在不獲取鎖而誤在兩個不同的協程中引用同一變量。在計算機科學中這稱為數據競爭(data race)。為有助找到這類bug,Go中內置了一個競態檢查器。它并不保證能找到代碼中的每個數據競爭,如若找到,應對其添加適當的鎖。
我們來看??第15章的GitHub代碼庫??中的簡單示例sample_code/race/race.go?:
func getCounter() int {var counter intvar wg sync.WaitGroupwg.Add(5)for i := 0; i < 5; i++ {go func() {for i := 0; i < 1000; i++ {counter++}wg.Done()}()}wg.Wait()return counter
}
這段代碼啟動了5個協程,每個協程都對共享變量??counter?
?進行1000次更新,然后返回結果。預期結果為5000,那么我們就用sample_code/race/race_test.go中的單元測試進行驗證吧:
func TestGetCounter(t *testing.T) {counter := getCounter()if counter != 5000 {t.Error("unexpected counter:", counter)}
}
如果多次運行??go test?
?,會發現有時會通過,但大多數時候會得到這樣的錯誤消息:
unexpected counter: 3673
問題在于代碼中存在數據競爭。在簡單的程序中,原因很明顯:多個協程嘗試同時更新??counter?
??而部分更新丟失了。在更復雜的程序中,這些競爭會更難發現。我們來看競態檢查器有什么功能。對??go test?
??使用??-race?
?標記來進行啟用:
$ go test -race
==================
WARNING: DATA RACE
Read at 0x00c000128070 by goroutine 10:test_examples/race.getCounter.func1()test_examples/race/race.go:12 +0x45Previous write at 0x00c000128070 by goroutine 8:test_examples/race.getCounter.func1()test_examples/race/race.go:12 +0x5b
跟蹤信息清晰地表明??counter++?
?行是問題的根源。
警告: 有些人試圖通過在代碼中插入sleep來修復競態條件,以將多個協程訪問的變量的訪問岔開。這種做法很糟糕。這樣做可能在某些情況下消除問題,但代碼仍然是錯誤的,在一些情況下會失敗。
還可以在構建程序時使用??-race?
?標記。這會創建一個包含競態檢查器的二進制文件,并將它找到的所有競態報告到控制臺。這樣在沒有測試的代碼中可找到數據競爭。
競態檢查器這么有用,為什么不在所有測試和生產環境中始終啟用它呢?啟用??-race?
?的二進制運行速度約比正常二進制慢10倍。對于需要幾分鐘才能運行的大型測試套件,這不什么是問題,但對于運行時間僅為一秒的測試套件來說,10倍慢的速度會降低生產效率。
模糊測試
每個開發人員最終都會學到的一項最重要的教訓是所有數據都不可信。無論數據格式規范得多好,最終都將得處理與期望所不匹配的輸入。這并不僅僅因惡意所致。數據在傳輸過程、存儲甚至在內存中都可能受到損壞。處理數據的程序可能存在bug,而數據格式規范總會有一些邊界情況,不同的開發人員的解釋方式也會不同。
即使開發人員編寫了良好的單元測試,也不可能考慮到所有情況。我們已經了解到,即使具有100%的單元測試覆蓋率,也不能保證代碼沒有bug。需要用生成的數據來補充單元測試,這些數據可能會以預料外的方式破壞程序。這就用到了模糊測試。
模糊測試(Fuzzing)是一種生成隨機數據并將其提交給代碼以查看它是否正確處理意外輸入的技術。開發人員可以提供一個種子語料庫或一組已知的好數據,模糊測試器使用這些數據來生成有問題的輸入。我們來看如何使用Go測試工具中的模糊測試來發現額外的測試用例。
假設我們正在編寫一個處理數據文件的程序。示例代碼位于??GitHub??上。我們發送了一個字符串列表,但希望高效地分配內存,因此文件中的字符串數以第一行發送,其余行為文本行。以下是處理該數據的示例函數:
func ParseData(r io.Reader) ([]string, error) {s := bufio.NewScanner(r)if !s.Scan() {return nil, errors.New("empty")}countStr := s.Text()count, err := strconv.Atoi(countStr)if err != nil {return nil, err}out := make([]string, 0, count)for i := 0; i < count; i++ {hasLine := s.Scan()if !hasLine {return nil, errors.New("too few lines")}line := s.Text()out = append(out, line)}return out, nil
}
我們使用??bufio.Scanner?
??逐行從??io.Reader?
??中讀取。如果沒有供讀取的數據,返回一個錯誤。然后讀取第一行并嘗試將其轉化為命名為的整型??count?
??。如轉化失敗,返回錯誤。接著,為字符串切片分配內存并從Scanner中讀取??count?
?行。如果行數不足,返回錯誤。一切正常的話,返回所讀取的行數。
已編寫了驗證該代碼的單元測試:
func TestParseData(t *testing.T) {data := []struct {name stringin []byteout []stringerrMsg string}{{name: "simple",in: []byte("3\nhello\ngoodbye\ngreetings\n"),out: []string{"hello", "goodbye", "greetings"},errMsg: "",},{name: "empty_error",in: []byte(""),out: nil,errMsg: "empty",},{name: "zero",in: []byte("0\n"),out: []string{},errMsg: "",},{name: "number_error",in: []byte("asdf\nhello\ngoodbye\ngreetings\n"),out: nil,errMsg: `strconv.Atoi: parsing "asdf": invalid syntax`,},{name: "line_count_error",in: []byte("4\nhello\ngoodbye\ngreetings\n"),out: nil,errMsg: "too few lines",},}for _, d := range data {t.Run(d.name, func(t *testing.T) {r := bytes.NewReader(d.in)out, err := ParseData(r)var errMsg stringif err != nil {errMsg = err.Error()}if diff := cmp.Diff(out, d.out); diff != "" {t.Error(diff)}if diff := cmp.Diff(errMsg, d.errMsg); diff != "" {t.Error(diff)}})}
}
單元測試對??ParseData?
?有100%的行覆蓋率,處理了所有的錯誤分支。你可能覺得代碼已可以上生產,但我們來看模糊測試能否幫忙找到我們未考慮到的錯誤。
注:模糊測試消耗大量資源。一個模糊測試可能會分配(或嘗試分配)好幾G 的內存,并可能在本地磁盤上寫幾個 G 的內容。如果在該機器上同時運行的其它程序變慢了,請做好心理準備。
先來編寫模糊測試:
func FuzzParseData(f *testing.F) {testcases := [][]byte{[]byte("3\nhello\ngoodbye\ngreetings\n"),[]byte("0\n"),}for _, tc := range testcases {f.Add(tc)}f.Fuzz(func(t *testing.T, in []byte) {r := bytes.NewReader(in)out, err := ParseData(r)if err != nil {t.Skip("handled error")}roundTrip := ToData(out)rtr := bytes.NewReader(roundTrip)out2, err := ParseData(rtr)if diff := cmp.Diff(out, out2); diff != "" {t.Error(diff)}})
}
模糊測試與標準單元測試很像。函數名以??Fuzz?
??開頭,唯一的參數是??*testing.F?
?類型,沒有返回值。
接下來,我們配置一個種子語料庫,由一到多個樣本數據集組成。這些數據可以成功運行,也可以出錯,甚至可能會panic。重要的是,你清楚提供這些數據時程序的行為,并且模糊測試會考慮到這種行為。這些樣本數據會由模糊測試器修改生成不良輸入。我們的示例只使用了每個條目的一個數據字段(一個字節切片),但你可以使用盡可能多的字段。目前,語料庫條目中的字段僅限于以下類型:
- 任意整數類型(包括無符號類型、?
?rune?
??和??byte?
?) - 任意浮點數類型
- ?
?bool?
? - ?
?string?
? - ?
?[]byte?
?
語料庫中的每個條目都傳遞給??*testing.F?
??實例上的??Add?
?方法。在本例中,每個條目都是一個字節切片:
f.Add(tc)
如果進行模糊測試的函數需要一個??int?
??和??string?
??,對??Add?
?的調用就會是這樣:
f.Add(1, "some text")
向??Add?
?傳遞無效類型的值報運行時錯誤。
接下來,我們在??*testing.F?
??實例上調用??Fuzz?
??方法。這與編寫標準單元測試中的表格測試時調用??Run?
??有點像調。??Fuzz?
??接受一個參數,一個函數,其第一個參數的類型為??*testing.T?
??,其余參數的類型、順序和數量與傳遞給??Add?
?的值完全匹配。這也指定了在模糊測試期間由模糊測試引擎生成的數據類型。Go編譯器無法強制執行這個約束,因此如果未遵循這個約定,就會導致運行時錯誤。
最后,讓我們看一下模糊測試的主體。記住,模糊測試用于查找無法正確處理不良輸入的情況。由于輸入是隨機生成的,我們無法編寫輸出具體是什么的測試。相反,我們必須使用對所有輸入都為真的測試條件。對于??ParseData?
?來說,可以檢查兩類:
- 代碼是否會對不良輸入返回錯誤,或者是否會panic?
- 如果你將字符串切片轉換回字節切片并重新解析它,是否會得到相同的結果?
我們來看運行模糊測試時會發生什么:
$ go test -fuzz=FuzzParseData
fuzz: elapsed: 0s, gathering baseline coverage: 0/243 completed
fuzz: elapsed: 0s, gathering baseline coverage: 243/243 completed,now fuzzing with 8 workers
fuzz: minimizing 289-byte failing input file
fuzz: elapsed: 3s, minimizing
fuzz: elapsed: 6s, minimizing
fuzz: elapsed: 9s, minimizing
fuzz: elapsed: 10s, minimizing
--- FAIL: FuzzParseData (10.48s)fuzzing process hung or terminated unexpectedly while minimizing: EOFFailing input written to testdata/fuzz/FuzzParseData/fedbaf01dc50bf41b40d7449657cdc9af9868b1be0421c98b2910071de9be3dfTo re-run:go test -run=FuzzParseData/fedbaf01dc50bf41b40d7449657cdc9af9868b1be0421c98b2910071de9be3df
FAIL
exit status 1
FAIL file_parser 10.594s
如未指定??-fuzz?
?標志,模糊測試將被視作單元測試,并以種子語料庫運行。一次只能對一個模糊測試進行模糊測試。
注: 如想要完整體驗,可以刪除?
?testdata/fuzz/FuzzParseData?
?目錄的內容。這會使用模糊測試器生成新的種子語料庫條目。由于模糊測試器生成隨機輸入,樣本可能與所顯示的不同。不過,不同的條目可能會產生類似的錯誤,雖然順序可能不同。
模糊測試運行了幾秒鐘,然后失敗了。在這種情況下,??go?
??命令報告它已崩潰。我們不希望程序崩潰,因此來看一下生成的輸入。每次測試用例失敗時,模糊測試器都會將它寫入與失敗的測試相同包中的??testdata/fuzz/TESTNAME?
??子目錄中,在種子語料庫中添加一個新的條目。文件中的新種子語料庫條目現在成為一個新的單元測試,由模糊測試器自動生成。每當??go test?
??運行??FuzzParseData?
?函數時,它都會運行,并在我們修復了錯誤后充當回歸測試。
以下是文件的內容:
go test fuzz v1
[]byte("300000000000")
第一行表示模糊測試的測試數據的頭。后續行為導致錯誤的數據。
錯誤消息表明在重新運行測試時如何隔離出錯的分支:
$ go test -run=FuzzParseData/fedbaf01dc50bf41b40d7449657cdc9af9868b1be0421c98b2910071de9be3df
signal: killed
FAIL file_parser 15.046s
問題是我們在嘗試分配一個能存儲300,000,000,000字符串容量的切片。所需的RAM比我電腦的要多。我們需要將預期的文本元素限定到合適的數量。通過在??ParseData?
?中解析預期行數之后添加如下代碼將最大行數設置為1,000:
if count > 1000 {return nil, errors.New("too many")}
再測試運行模糊測試查看是否有其它錯誤:
$ go test -fuzz=FuzzParseData
fuzz: elapsed: 0s, gathering baseline coverage: 0/245 completed
fuzz: elapsed: 0s, gathering baseline coverage: 245/245 completed,now fuzzing with 8 workers
fuzz: minimizing 29-byte failing input file
fuzz: elapsed: 2s, minimizing
--- FAIL: FuzzParseData (2.20s)--- FAIL: FuzzParseData (0.00s)testing.go:1356: panic: runtime error: makeslice: cap out of rangegoroutine 23027 [running]:runtime/debug.Stack()/usr/local/go/src/runtime/debug/stack.go:24 +0x104testing.tRunner.func1()/usr/local/go/src/testing/testing.go:1356 +0x258panic({0x1003f9920, 0x10042a260})/usr/local/go/src/runtime/panic.go:884 +0x204file_parser.ParseData({0x10042a7c8, 0x14006c39bc0})file_parser/file_parser.go:24 +0x254
[...]Failing input written to testdata/fuzz/FuzzParseData/03f81b404ad91d092a482ad1ccb4a457800599ab826ec8dae47b49c01c38f7b1To re-run:go test -run=FuzzParseData/03f81b404ad91d092a482ad1ccb4a457800599ab826ec8dae47b49c01c38f7b1
FAIL
exit status 1
FAIL file_parser 2.434s
這次的測試結果中產生了panic。查看??go fuzz?
?生成的文件,可以看到:
go test fuzz v1
[]byte("-1")
導致panic的行為:
out := make([]string, 0, count)
我們在嘗試創建容量為負數的切片,產生了panic。在代碼添加一個條件發現負數的情況:
if count < 0 {return nil, errors.New("no negative numbers")}
再次運行測試,會出現另一個錯誤:
$ go test -fuzz=FuzzParseData
fuzz: elapsed: 0s, gathering baseline coverage: 0/246 completed
fuzz: elapsed: 0s, gathering baseline coverage: 246/246 completed,now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 288734 (96241/sec), new interesting: 0 (total: 246)
fuzz: elapsed: 6s, execs: 418803 (43354/sec), new interesting: 0 (total: 246)
fuzz: minimizing 34-byte failing input file
fuzz: elapsed: 7s, minimizing
--- FAIL: FuzzParseData (7.43s)--- FAIL: FuzzParseData (0.00s)file_parser_test.go:89: []string{- "\r",+ "",}Failing input written to testdata/fuzz/FuzzParseData/b605c41104bf41a21309a13e90cfc6f30ecf133a2382759f2abc34d41b45ae79To re-run:go test -run=FuzzParseData/b605c41104bf41a21309a13e90cfc6f30ecf133a2382759f2abc34d41b45ae79
FAIL
exit status 1
FAIL file_parser 7.558s
查看所創建的文件,生成的是僅包含\r(回車)字符的空行。我們沒考慮輸入中有空行,所以在讀取??Scanner?
?中文本行的循環中添加一些代碼。我們會檢測某行是否僅包含空白字符。如是,則返回錯誤:
line = strings.TrimSpace(line)if len(line) == 0 {return nil, errors.New("blank line")}
再次運行模糊測試:
$ go test -fuzz=FuzzParseData
fuzz: elapsed: 0s, gathering baseline coverage: 0/247 completed
fuzz: elapsed: 0s, gathering baseline coverage: 247/247 completed,now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 391018 (130318/sec), new interesting: 2 (total: 249)
fuzz: elapsed: 6s, execs: 556939 (55303/sec), new interesting: 2 (total: 249)
fuzz: elapsed: 9s, execs: 622126 (21734/sec), new interesting: 2 (total: 249)
[...]
fuzz: elapsed: 2m0s, execs: 2829569 (0/sec), new interesting: 16 (total: 263)
fuzz: elapsed: 2m3s, execs: 2829569 (0/sec), new interesting: 16 (total: 263)
^Cfuzz: elapsed: 2m4s, execs: 2829569 (0/sec), new interesting: 16 (total: 263)
PASS
ok file_parser 123.662s
幾分鐘后,不再有報錯,按下control+C終止測試。
模糊測試沒有找到其它問題也并不表示代碼就沒有bug了。但模糊測試讓我們可以找到原始代碼中忽略的一些錯誤。編寫模糊測試需要一些練習,因其與編寫單元測試的思維不同。一旦掌握,就會成為驗證代碼如何處理預料外用戶輸入的基本工具。
小結
本章中,我們學習了如何通過Go對測試、代碼覆蓋率、基準測試、模糊測試和數據競爭檢查的內置支持編寫測試及提升代碼質量。
本文來自正在規劃的??Go語言&云原生自我提升系列??,歡迎關注后續文章。