萬字長文——在 Go 中如何編寫測試代碼
在程序開發過程中,測試是非常重要的一環,甚至有一種開發模式叫 TDD(測試驅動開發),先編寫測試,再編寫功能代碼,通過測試來推動整個開發的進行,可見測試在開發中的重要程度。
爲此,Go 語言提供了 testing
框架來方便我們編寫測試,本文將向大家介紹在 Go 中如何編寫測試代碼。
測試分類
在 Go 中,編寫的測試用例可以分爲四類:
-
單元測試:測試函數名稱以
Test
開頭,如TestXxx
、Test_Xxx
,用來對程序的最小單元進行測試,如函數、方法等。 -
基準測試:也叫性能測試,測試函數名稱以
Benchmark
開頭,用來測量程序的性能指標。 -
示例測試:測試函數名稱以
Example
開頭,可以用來測試程序的標準輸出內容。 -
模糊測試:也叫隨機測試,測試函數名稱以
Fuzz
開頭,是一種基於隨機輸入的自動化測試技術,適合用來測試處理用戶輸入的代碼,在 Go 1.18 中被引入。
這四類測試用例都有各自的適用場景,其中單元測試最爲常用,你一定要掌握。
以上這些測試用例都可以使用 go test
命令來執行。
測試規範
編寫測試代碼並不需要我們學習新的 Go 語法,但有些測試規範還是需要遵守的。
Go 在提供 testing
測試框架時,就規定了很多測試規範,用來約束我們編寫測試的方式,這有助於項目的工程化。
測試文件命名規範
首先,測試文件命名必須以 _test.go
結尾,否則將被測試框架忽略。比如我們的 Go 代碼文件名爲 hello.go
,則測試文件可以命名爲 hello_test.go
。
只有以 _test.go
結尾的測試文件,才能使用 go test
命令執行。
在構建 Go 程序時,go build
命令會忽略以 _test.go
結尾的測試文件。
測試包命名規範
測試用例除了根據使用場景可以分爲四類,還可以根據代碼和測試用例是否在一個包中,分爲白盒測試和黑盒測試。
-
白盒測試:將測試代碼和被測代碼放在同一個包中,也就是二者包名相同,這些測試用例屬於白盒測試。比如 Go 代碼文件名爲
hello.go
,包名爲hello
,測試文件可以命名爲hello_test.go
,並且必須與hello.go
放在同一個目錄下,包名也必須爲hello
。白盒測試的測試用例可以使用和測試當前包中所有標識符(變量、函數等),包括未導出的標識符。 -
黑盒測試:將測試代碼和被測代碼放在不同的包中,即包名不同,這些測試用例屬於黑盒測試。比如 Go 代碼文件名爲
hello.go
,包名爲hello
,測試文件同樣可以命名爲hello_test.go
,與hello.go
放在同一個目錄下,但包名不能再叫hello
,應該命名爲hello_test
,hello_test.go
文件也可以放在專門的test
目錄下,此時可以隨意命名包名。黑盒測試的測試用例僅能夠使用和測試被測代碼包中可導出的標識符,因爲二者已經不再屬於同一個包,這遵循 Go 語法規範。
根據二者各自特點,在開發時我們應該多編寫白盒測試,這樣才能提升代碼測試覆蓋率。
測試用例命名規範
在 Go 中我們使用測試函數來編寫測試用例,根據單元測試、基準測試、示例測試、模糊測試四種不同類型的測試分類,測試函數必須以 Test
、Benchmark
、Example
、Fuzz
其中一種開頭。
測試函數簽名示例如下:
func TestXxx(*testing.T)
func BenchmarkXxx(*testing.B)
func ExampleXxx()
func FuzzXxx(*testing.F)
測試函數不能有返回值,其中單元測試、基準測試和模糊測試都接收一個參數,由 testing
框架提供,示例測試則不需要傳遞參數。
其中 Xxx
一般是被測試的函數名稱,首字母必須大寫。如果是以 Test_Xxx
方式命名測試函數,則 Xxx
首字母大小寫均可。
測試變量命名規範
對於測試變量的命名,testing
框架沒有有強制約束,但社區中也形成了一些規範。
比如,函數簽名中的參數變量定義如下:
func TestXxx(t *testing.T)
func BenchmarkXxx(b *testing.B)
func FuzzXxx(f *testing.F)
單元測試、基準測試和模糊測試參數變量即爲參數類型 *testing.<T>
的小寫形式。
在編寫測試代碼時,有一個最常見的場景,就是比較被測函數的實際輸出和測試函數中的預期輸出是否相等,通常可以使用 got/want
或 actual/expected
來命名變量:
if got != want {
t.Errorf("Xxx(x) = %s; want %s", got, want)
}
或:
if actual != expected {
t.Errorf("Xxx(x) = %s; expected %s", actual, expected)
}
讀者可以根據喜好和團隊中的開發規範選擇其中一種變量命名。
此外,在單元測試中我們還會經常編寫一種叫表格測試的測試用例,寫法如下:
func TestXxx(t *testing.T) {
tests := []struct {
name string
arg float64
want float64
}{
...
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Xxx(tt.arg); got != tt.want {
t.Errorf("Xxx(%f) = %v, want %v", tt.arg, got, tt.want)
}
})
}
}
其中 tests
代表多個測試用例,循環時以 tt
作爲循環變量(tt
可以避免與單元測試函數的參數變量 t
命名衝突)。
表格測試還有另一個版本:
func TestXxx(t *testing.T) {
cases := []struct {
name string
arg float64
want float64
}{
...
}
for _, cc := range cases {
t.Run(cc.name, func(t *testing.T) {
if got := Xxx(cc.arg); got != cc.want {
t.Errorf("Xxx(%f) = %v, want %v", cc.arg, got, cc.want)
}
})
}
}
現在 cases
代表多個測試用例,循環時以 cc
作爲循環變量(cc
可以避免與常見的 context
縮寫 c
命名衝突)。
編寫測試代碼的常見規範我們就先講解到這裏,更多規範將在下文講解對應示例時再進行詳細說明。
單元測試
單元測試是我們最常編寫的測試用例,所以先來學習下如何編寫單元測試。
首先,我們準備一個 Abs
函數作爲被測試的代碼,存放於 abs.go
文件中,其包名爲 abs
,代碼如下:
package abs
import "math"
func Abs(x float64) float64 {
return math.Abs(x)
}
白盒測試
現在爲 Abs
編寫一個白盒測試函數,在存放 abs.go
文件的同一目錄下,新建 abs_test.go
文件,包名同樣定義爲 abs
,編寫測試代碼如下:
package abs
import "testing"
func TestAbs(t *testing.T) {
got := Abs(-1)
if got != 1 {
t.Errorf("Abs(-1) = %f; want 1", got)
}
}
單元測試函數 TestAbs
代碼非常簡單,先調用了 Abs(-1)
函數,並將得到的返回結果 got
與 1
做相等性比較,如果不相等,則說明測試沒有通過,使用 t.Errorf
打印錯誤信息。
參數 *testing.T
是一個結構體指針,提供瞭如下幾個方法用於錯誤報告:
-
t.Log/t.Logf
:打印正常日誌信息,類似fmt.Print
。 -
t.Error/t.Errorf
:打印測試失敗時的錯誤信息,不影響當前測試函數內後續代碼的繼續執行。 -
t.Fatal/t.Fatalf
:打印測試失敗時的錯誤信息,並終止當前測試函數執行。
在測試函數所在目錄下使用 go test
命令執行測試代碼:
$ go test
PASS
ok github.com/jianghushinian/blog-go-example/test/getting-started/abs 0.139s
go test
會自動查找當前目錄下所有以 _test.go
結尾來命名的測試文件,並執行其內部編寫的全部測試函數。
輸出 PASS
表示測試通過,github.com/jianghushinian/blog-go-example/test/getting-started/abs
是程序的 module
名稱。
go test
命令還支持使用 -v
標誌輸出更多信息:
$ go test -v
=== RUN TestAbs
--- PASS: TestAbs (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/getting-started/abs 0.437s
如果我們不小心將單元測試的函數名錯誤的寫成 Testabs
,即 abs
沒有大寫開頭:
func Testabs(t *testing.T) {
got := Abs(-1)
if got != 1 {
t.Errorf("Abs(-1) = %f; want 1", got)
}
}
則測試函數不會被 go test
命令執行。
通常情況下,我們不會對一個函數只做一種輸入參數的測試,爲了提高測試覆蓋率,我們可能還需要多測試幾種參數的用例,比如測試下 Abs(2)
、Abs(3)
等是否正確。
這時,可以像如下這樣編寫測試函數:
func TestAbs(t *testing.T) {
got := Abs(-1)
if got != 1 {
t.Errorf("Abs(-1) = %f; want 1", got)
}
got = Abs(2)
if got != 2 {
t.Errorf("Abs(2) = %f; want 2", got)
}
}
但這樣的代碼顯然過於 “平鋪直敘”,不夠優雅。
在這種更加複雜的情況下,我們可以使用「表格測試」,代碼如下:
func TestAbs_TableDriven(t *testing.T) {
tests := []struct {
name string
x float64
want float64
}{
{
name: "positive",
x: 2,
want: 2,
},
{
name: "negative",
x: -3,
want: 3,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := Abs(tt.x); got != tt.want {
t.Errorf("Abs(%f) = %v, want %v", tt.x, got, tt.want)
}
})
}
}
爲了便於與之前編寫的測試函數 TestAbs
區分,我爲當前測試函數命名爲 TestAbs_TableDriven
,代表這是一個表格驅動的測試。
在測試函數內部,首先定義了一個匿名結構體切片,用來保存多個測試用例。
name
是一個字符串,可以是任何句子,用來標記當前測試用例所測試的場景,這樣代碼維護者通過 name
字段就能夠知道當前用例所測試的場景,作用相當於代碼註釋。
x
作爲 Abs
函數的入參,其類型等同於 Abs
函數的參數,如果被測試函數有多個參數,這裏也可以使用一個結構體來保存。
want
記錄當前測試用例的期望值。
在 for
循環中,我們可以使用 *testing.T
提供的 t.Run
方法執行測試用例,這和直接編寫的 TestXxx
測試函數沒什麼本質區別。
現在使用 go test
命令執行測試代碼:
go test -v
=== RUN TestAbs
--- PASS: TestAbs (0.00s)
=== RUN TestAbs_TableDriven
=== RUN TestAbs_TableDriven/positive
=== RUN TestAbs_TableDriven/negative
--- PASS: TestAbs_TableDriven (0.00s)
--- PASS: TestAbs_TableDriven/positive (0.00s)
--- PASS: TestAbs_TableDriven/negative (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/getting-started/abs 0.145s
可以發現,表格測試的輸出信息更加豐富,能夠分別打印出表格中的每一個測試用例,並且使用縮進來展示層級關係。
現在我們故意將其中的一個測試用例改錯:
{
name: "negative",
x: -3,
want: 33,
}
再次使用 go test
命令執行測試代碼看下如何輸出:
go test -v
=== RUN TestAbs
--- PASS: TestAbs (0.00s)
=== RUN TestAbs_TableDriven
=== RUN TestAbs_TableDriven/positive
=== RUN TestAbs_TableDriven/negative
abs_test.go:36: Abs(-3.000000) = 3, want 33
--- FAIL: TestAbs_TableDriven (0.00s)
--- PASS: TestAbs_TableDriven/positive (0.00s)
--- FAIL: TestAbs_TableDriven/negative (0.00s)
FAIL
exit status 1
FAIL github.com/jianghushinian/blog-go-example/test/getting-started/abs 0.515s
根據打印結果,我們很容易能夠發現是 TestAbs_TableDriven
測試函數中 negative
這個測試用例執行失敗了。
有些場景下,我們可能想要跳過某些測試用例,可以使用 (*testing.T).Skip
方法來實現:
func TestAbs_Skip(t *testing.T) {
// CI 環境跳過當前測試
if os.Getenv("CI") != "" {
t.Skip("it's too slow, skip when running in CI")
}
t.Log(t.Skipped())
got := Abs(-2)
if got != 2 {
t.Errorf("Abs(-2) = %f; want 2", got)
}
}
假如 TestAbs_Skip
是一個非常耗時的測試用例,我們就可以使用 t.Skip
在 CI 環境下跳過此測試。
t.Skipped()
返回當前測試用例是否被跳過。
使用 go test
命令執行測試:
$ CI=1 go test -v -run="TestAbs_Skip"
=== RUN TestAbs_Skip
abs_test.go:46: it's too slow, skip when running in CI
--- SKIP: TestAbs_Skip (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/getting-started/abs 0.103s
這次我們使用 -run
參數來指定想要執行的測試用例,-run
參數的值支持正則。
並且指定了環境變量 CI=1
。
從打印結果來看,TestAbs_Skip
測試用例的確被跳過了,所以 t.Log(t.Skipped())
沒有被執行到。
默認情況下,測試用例是從上到下按照順序執行的,不過,我們可以使用 (*testint.T).Parallel
來標記一個測試函數支持併發執行:
func TestAbs_Parallel(t *testing.T) {
t.Log("Parallel before")
// 標記當前測試支持並行
t.Parallel()
t.Log("Parallel after")
got := Abs(2)
if got != 2 {
t.Errorf("Abs(2) = %f; want 2", got)
}
}
只有一個測試函數支持併發執行意義不大,我們可以將 TestAbs
測試函數也修改爲支持併發執行:
func TestAbs(t *testing.T) {
t.Parallel()
got := Abs(-1)
if got != 1 {
t.Errorf("Abs(-1) = %f; want 1", got)
}
}
現在,使用 go test
命令來測試下併發執行測試用例:
$ go test -v -run=".*Parallel.*|^TestAbs$"
=== RUN TestAbs
=== PAUSE TestAbs
=== RUN TestAbs_Parallel
abs_test.go:59: Parallel before
=== PAUSE TestAbs_Parallel
=== CONT TestAbs
--- PASS: TestAbs (0.00s)
=== CONT TestAbs_Parallel
abs_test.go:62: Parallel after
--- PASS: TestAbs_Parallel (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/getting-started/abs 0.200s
這裏我們只執行了 TestAbs_Parallel
、TestAbs
這兩個測試函數。
可以發現,兩個函數都不是一次性執行完成的,日誌中 PAUSE
表示暫停當前函數的執行,CONT
表示恢復當前函數執行。
有時候,我們測試的並不是一個函數,而是一個方法,比如我們想要測試 Animal
結構體的 shout
方法:
package animal
type Animal struct {
Name string
}
func (a Animal) shout() string {
if a.Name == "dog" {
return "旺!"
}
if a.Name == "cat" {
return "喵~"
}
return "吼~"
}
那麼,測試函數可以命名爲 TestAnimal_shout
,如下是我們針對 Dog
和 Cat
兩種不同的 Animal
對象編寫的測試代碼:
package animal
import (
"testing"
)
func TestAnimalDog_shout(t *testing.T) {
dog := Animal{Name: "dog"}
got := dog.shout()
want := "旺!"
if got != want {
t.Errorf("got %s; want %s", got, want)
}
}
func TestAnimalCat_shout(t *testing.T) {
cat := Animal{Name: "cat"}
got := cat.shout()
want := "喵~"
if got != want {
t.Errorf("got %s; want %s", got, want)
}
}
黑盒測試
講完了白盒測試,我們再來演示下如何編寫黑盒測試。
要爲 Abs
編寫黑盒測試非常簡單,我們只需要將 TestAbs
移動到新的包中即可。
package abs_test
import (
"testing"
"github.com/jianghushinian/blog-go-example/test/getting-started/abs"
)
func TestAbs(t *testing.T) {
got := abs.Abs(-1)
if got != 1 {
t.Errorf("Abs(-1) = %f; want 1", got)
}
}
因爲黑盒測試的函數 TestAbs
與 Abs
不在同一個包中,所以需要先使用 import
導入 abs
包,之後才能使用 abs.Abs
函數。
至此,常見的單元測試場景我們就介紹完了。
接下來,我們一起來看如何編寫基準測試。
基準測試
基準測試也叫性能測試,顧名思義,是爲了度量程序的性能。
爲 Abs
編寫的基準測試代碼如下:
func BenchmarkAbs(b *testing.B) {
for i := 0; i < b.N; i++ {
Abs(-1)
}
}
基準測試同樣放在 abs_test.go
文件中,以 Benchmark
開頭,參數不再是 *testing.T
,而是 *testing.B
,在測試函數中,我們循環了 b.N
次調用 Abs(-1)
,b.N
的值是一個動態值,我們無需操心,testing
框架會爲其分配合理的值,以使測試函數運行足夠多的次數,可以準確的計時。
默認情況下,go test
命令並不會運行基準測試,需要指定 -bench
參數:
$ go test -bench="."
goos: darwin
goarch: arm64
pkg: github.com/jianghushinian/blog-go-example/test/getting-started/abs
BenchmarkAbs-8 1000000000 0.5096 ns/op
PASS
ok github.com/jianghushinian/blog-go-example/test/getting-started/abs 0.674s
-bench
參數同樣接收一個正則,.
匹配所有基準測試。
我們需要重點關注的是這行結果:
BenchmarkAbs-8 1000000000 0.5096 ns/op
BenchmarkAbs-8
中,BenchmarkAbs
是測試函數名,8
是 GOMAXPROCS
的值,即參與執行的 CPU 核心數。
1000000000
表示測試執行了這麼多次。
0.5096 ns/op
表示每次循環平均消耗的納秒數。
如果還想查看基準測試的內存統計情況,則可以指定 -benchmem
參數:
$ go test -bench="BenchmarkAbs$" -benchmem
goos: darwin
goarch: arm64
pkg: github.com/jianghushinian/blog-go-example/test/getting-started/abs
BenchmarkAbs-8 1000000000 0.5097 ns/op 0 B/op 0 allocs/op
PASS
ok github.com/jianghushinian/blog-go-example/test/getting-started/abs 0.681s
現在,BenchmarkAbs-8
這行得到了更多輸出:
BenchmarkAbs-8 1000000000 0.5097 ns/op 0 B/op 0 allocs/op
0 B/op
表示每次執行測試代碼分配了多少字節內存。
0 allocs/op
表示每次執行測試代碼分配了多少次內存。
此外,在執行 go test
命令時,我們可以使用 -benchtime=Ns
參數指定基準測試函數執行時間爲 N
秒:
$ go test -bench="BenchmarkAbs$" -benchtime=0.1s
goos: darwin
goarch: arm64
pkg: github.com/jianghushinian/blog-go-example/test/getting-started/abs
BenchmarkAbs-8 210435709 0.5096 ns/op
PASS
ok github.com/jianghushinian/blog-go-example/test/getting-started/abs 0.600s
-benchtime
參數值爲 time.Duration
類型支持的時間格式。
-benchtime
參數還有一個特殊語法 -benchtime=Nx
參數,可以指定基準測試函數執行次數爲 N
次:
$ go test -bench="BenchmarkAbs$" -benchtime=10x
goos: darwin
goarch: arm64
pkg: github.com/jianghushinian/blog-go-example/test/getting-started/abs
BenchmarkAbs-8 10 20.90 ns/op
PASS
ok github.com/jianghushinian/blog-go-example/test/getting-started/abs 0.391s
有時候,我們在編寫基準測試時,被測函數可能需要一些準備數據,而這些準備數據的時間不應該算做被測試函數的耗時。
此時,可以使用 (*testing.B).ResetTimer
重置計時:
func BenchmarkAbsResetTimer(b *testing.B) {
time.Sleep(100 * time.Millisecond) // 模擬耗時的準備工作
b.ResetTimer()
for i := 0; i < b.N; i++ {
Abs(-1)
}
}
這樣,在調用 b.ResetTimer()
之前的耗時操作將不被記入測試結果的耗時中。
還有一種方法,也可以跳過準備工作的計時,即先使用 (*testing.B).StopTimer
停止計時,耗時的準備工作完成後再使用 (*testing.B).StartTimer
恢復計時:
func BenchmarkAbsStopTimerStartTimer(b *testing.B) {
b.StopTimer()
time.Sleep(100 * time.Millisecond) // 模擬耗時的準備工作
b.StartTimer()
for i := 0; i < b.N; i++ {
Abs(-1)
}
}
默認情況下,基準測試 for
循環中的代碼是串行執行的,如果想要並行執行,可以將被測試代碼的調用放在 (*testing.B).RunParallel
中:
func BenchmarkAbsParallel(b *testing.B) {
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Abs(-1)
}
})
}
我們還可以使用 (*testing.B).SetParallelism
控制併發協程數:
func BenchmarkAbsParallel(b *testing.B) {
b.SetParallelism(2) // 設置併發 Goroutines 數量爲 2 * GOMAXPROCS
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
Abs(-1)
}
})
}
在使用 go test
命令執行基準測試時,可以指定 -cpu
參數來設置 GOMAXPROCS
。
要想了解 go test
支持的更多參數,可以使用 go help testflag
命令進行查看。
示例測試
示例測試以 Example
開頭,無參數和返回值,通常存放在 example_test.go
文件中。
約定一個包、函數 F、類型 T、方法 M 的示例測試命名如下:
func Example() { ... } // 整個包的示例測試
func ExampleF() { ... } // 函數 F 的示例測試
func ExampleT() { ... } // 類型 T 的示例測試
func ExampleT_M() { ... } // 類型 T 的 M 方法的示例測試
一個包、函數、類型、方法如果存在多個示例測試,可以通過在名稱後面附加一個不同的後綴來命名示例測試函數,後綴必須以小寫字母開頭,如下:
func Example_suffix() { ... }
func ExampleF_suffix() { ... }
func ExampleT_suffix() { ... }
func ExampleT_M_suffix() { ... }
以下是一個爲 Abs
函數編寫的示例測試:
func ExampleAbs() {
fmt.Println(Abs(-1))
fmt.Println(Abs(2))
// Output:
// 1
// 2
}
示例測試函數末尾需要使用 // Output:
註釋,來標記被測試函數的標準輸出內容。
這裏分別使用 fmt.Println(Abs(-1))
、fmt.Println(Abs(2))
調用了兩次 Abs
函數,所以會得到兩個輸出。
示例測試會攔截測試過程中的標準輸出,並與 // Output:
註釋之後的內容做對比,如果相等,則測試通過。
go test
默認情況下,不會執行示例測試,可以通過 -run
指定示例測試函數:
$ go test -v -run "ExampleAbs$"
=== RUN ExampleAbs
--- PASS: ExampleAbs (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/getting-started/abs 2.050s
我們還可以使用 // Unordered Output:
註釋,來標記被測試函數的標準輸出內容。
這將忽略被測試函數的輸出順序:
func ExampleAbs_unordered() {
fmt.Println(Abs(2))
fmt.Println(Abs(-1))
// Unordered Output:
// 1
// 2
}
以上這個示例測試函數中,無論是先調用 Abs(2)
還是先調用 Abs(-1)
,測試函數都能通過。
沒有輸出註釋 // Output:
或 // Unordered Output:
的示例測試函數只會被編譯,但不會執行。
此外,示例測試還有一個非常有用功能,它能被 godoc
或 pkgsite
工具所識別,將示例函數的代碼提取後作爲被測試函數文檔的一部分。
注意:舊版本的 Go 自帶了
godoc
工具,能夠在本地啓動一個 Web 服務器,對本地安裝的 Go 包提供文檔服務,不過現在官方已經不維護godoc
了,所以不再推薦使用。Go 1.15 以後雖然集成了go doc
工具,但是無法啓動 Web 服務,比較適合命令行中查看 Go 包的文檔。現在,Go 官方比較推薦使用的工具是pkgsite
,能夠啓動 Web 服務,並且它與 Go 在線文檔站點長得一樣。
這裏以 pkgsite
工具爲例展示下示例測試函數生成的文檔效果。
首先,安裝 pkgsite
:
$ go install golang.org/x/pkgsite/cmd/pkgsite@latest
然後,在 abs.go
目錄下執行 pkgsite
即可啓動文檔服務:
$ pkgsite
現在訪問 http://localhost:8080
即可進入文檔服務首頁:
點擊模塊名 github.com/jianghushinian/blog-go-example/test/getting-started
即可找到 Abs
函數位置,在 Abs
函數下方,標題 Example
、Example (Unordered)
下就是通過示例測試生成的示例文檔:
注意:這裏需要額外提及的一點是,我們查看文檔的本地包模塊名稱(
module
)應該帶.
,也就是一般使用域名作爲包名的一部分,否則啓動pkgsite
後將會報錯,無法查看本地包的文檔,具體原因可以查看這個 issue。
模糊測試
模糊測試在 Go 1.18 中被引入,模糊測試(fuzz testing
)又叫隨機測試,是一種基於隨機輸入的自動化測試技術。
模糊測試比較適合用於發現處理用戶輸入的代碼中存在的問題。
關於模糊測試的編寫方式,有一張圖廣泛流傳:
fuzzing
模糊測試同樣需要放在 _test.go
文件中,並且以 Fuzz
開頭,參數爲 *testing.F
。
上圖中,f.Add(5, "hello")
是在爲模糊測試提供初始的種子語料,其實就是被測試函數接收的合法參數,後續的模糊測試過程中,會根據這個種子語料,生成更多的模糊測試參數。這有點類似我們生成隨機數時需要傳遞一個隨機種子。雖然調用 f.Add
方法不是必須的,但提供合法的種子語料有利於更早發現被測試函數的問題。
f.Fuzz
是模糊測試的主體邏輯,它接收一個函數,函數的第一個參數爲 *testing.T
,之後是被測函數接收的參數,稱爲 Fuzzing arguments
。
Fuzzing arguments
參數是 testing
框架隨機生成的,所以叫隨機測試,這些隨機生成的參數將依次傳遞給 Foo
函數。
調用 Foo
函數和判斷測試結果是否正確的代碼,就跟我們編寫的普通單元測試一樣了。
可以發現,模糊測試相較於單元測試,多了一個自動生成測試參數的過程。
不過,Fuzzing arguments
支持的參數類型有限,僅支持如下幾種類型:
-
string, []byte
-
int, int8, int16, int32/rune, int64
-
uint, uint8/byte, uint16, uint32, uint64
-
float32, float64
-
bool
此外,編寫模糊測試時,Fuzz target
不要依賴全局狀態,因爲模糊測試會並行執行。
爲了演示如何編寫模糊測試,我編寫了一個 Hello
函數:
package hello
import "errors"
var (
ErrEmptyName = errors.New("empty name")
ErrTooLongName = errors.New("too long name")
)
func Hello(name string) (string, error) {
if name == "" {
return "", ErrEmptyName
}
if len(name) > 10 {
return "", ErrTooLongName
}
return "Hello " + name, nil
}
將 Hello
函數放在 hello.go
文件中。
在 Hello
函數內部,對 name
參數進行了校驗,不能爲空,且長度不能超過 10。
爲 Hello
函數編寫模糊測試代碼如下:
func FuzzHello(f *testing.F) {
f.Add("Foo")
f.Fuzz(func(t *testing.T, name string) {
_, err := Hello(name)
if err != nil {
if errors.Is(err, ErrEmptyName) || errors.Is(err, ErrTooLongName) {
return
}
t.Errorf("unexpected error: %s, name: %s", err, name)
}
})
}
模糊測試代碼放在 hello_fuzz_test.go
中,包名同樣爲 hello
。
在 f.Fuzz
中調用了 Hello
函數,並判斷返回的 err
是否符合預期,如果不符合預期,則表示測試失敗。
go test
命令默認情況下同樣不會執行模糊測試,我們需要指定 -fuzz
參數:
$ go test -fuzz="FuzzHello"
fuzz: elapsed: 0s, gathering baseline coverage: 0/1 completed
fuzz: elapsed: 0s, gathering baseline coverage: 1/1 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 521335 (173703/sec), new interesting: 2 (total: 3)
fuzz: elapsed: 6s, execs: 947014 (141945/sec), new interesting: 2 (total: 3)
fuzz: elapsed: 9s, execs: 1391822 (148228/sec), new interesting: 2 (total: 3)
fuzz: elapsed: 12s, execs: 1838008 (148764/sec), new interesting: 2 (total: 3)
fuzz: elapsed: 15s, execs: 2266978 (143002/sec), new interesting: 2 (total: 3)
^Cfuzz: elapsed: 15s, execs: 2308214 (139431/sec), new interesting: 2 (total: 3)
PASS
ok github.com/jianghushinian/blog-go-example/test/getting-started/hello 17.131s
以上測試執行過程中,我使用 ^C
終止了測試。模糊測試默認情況下會一直執行下去,直至遇到 crash 終止。
通過以上示例,我們可以發現,模糊測試之所以強大,就是因爲其會一直執行,不斷生成測試參數,以覆蓋更多的情況和邊界條件。
也正因爲如此,模糊測試通常不建議在 CI 中執行。
不過,我們可以使用 -fuzztime
限制模糊測試執行的時間:
go test -fuzz="FuzzHello" -fuzztime 10s
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 8 workers
fuzz: elapsed: 3s, execs: 470505 (156828/sec), new interesting: 0 (total: 3)
fuzz: elapsed: 6s, execs: 948821 (159392/sec), new interesting: 0 (total: 3)
fuzz: elapsed: 9s, execs: 1423720 (158326/sec), new interesting: 0 (total: 3)
fuzz: elapsed: 10s, execs: 1573524 (139348/sec), new interesting: 0 (total: 3)
PASS
ok github.com/jianghushinian/blog-go-example/test/getting-started/hello 11.820s
這次,我沒有按 ^C
鍵終止測試,而是 10 秒過後,模糊測試自動終止。
現在,我們修改下 Hello
函數,使其返回一個未知的錯誤:
func Hello(name string) (string, error) {
if name == "" {
return "", ErrEmptyName
}
if len(name) > 10 {
return "", ErrTooLongName
}
if name == "Bob" {
return "", errors.New("not allowed")
}
return "Hello " + name, nil
}
當 name
值爲 Bob
時,Hello
函數將返回一個未知錯誤,模擬 BUG 場景。
再次使用 go test
命令執行模糊測試:
$ go test -fuzz="FuzzHello"
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 8 workers
fuzz: minimizing 30-byte failing input file
fuzz: elapsed: 1s, minimizing
--- FAIL: FuzzHello (1.17s)
--- FAIL: FuzzHello (0.00s)
hello_fuzz_test.go:18: unexpected error: not allowed, name: Bob
Failing input written to testdata/fuzz/FuzzHello/19f92ff5a07664a0
To re-run:
go test -run=FuzzHello/19f92ff5a07664a0
FAIL
exit status 1
FAIL github.com/jianghushinian/blog-go-example/test/getting-started/hello 1.626s
可以發現,這次模糊測試失敗了。
根據測試結果,是在執行 FuzzHello/19f92ff5a07664a0
時失敗的,19f92ff5a07664a0
是模糊測試生成的文件,位於 testdata/fuzz/FuzzHello/19f92ff5a07664a0
。
使用 tree
命令查看 19f92ff5a07664a0
位置:
tree hello
hello
├── hello.go
├── hello_fuzz_test.go
└── testdata
└── fuzz
└── FuzzHello
└── 19f92ff5a07664a0
testdata
目錄及目錄下所有內容都是模糊測試自動生成的。
19f92ff5a07664a0
文件內容如下:
go test fuzz v1
string("Bob")
文件第一行 go test fuzz v1
是模糊測試要求的文件頭,用於標識這是一個種子語料文件,並且使用的編解碼器的版本爲 v1
。
第二行就是種子語料,是一個 Go 代碼片段,即 string
類型的 Bob
參數。正是這個參數,引發了錯誤。
至此,我們使用模糊測試發現了 Hello
函數中隱藏的 BUG,這在黑盒測試中尤其有效,我們無需查看 Hello
函數內部代碼,爲每個邊界條件編寫測試用例,模糊測試會自動生成大量的隨機參數,檢測程序的異常。
測試覆蓋率
go test
命令支持使用 -cover
標誌查看測試覆蓋率:
$ go test -cover ./...
? github.com/jianghushinian/blog-go-example/test/getting-started [no test files]
ok github.com/jianghushinian/blog-go-example/test/getting-started/abs 2.334s coverage: 100.0% of statements
ok github.com/jianghushinian/blog-go-example/test/getting-started/animal 1.924s coverage: 100.0% of statements
ok github.com/jianghushinian/blog-go-example/test/getting-started/hello 2.738s coverage: 60.0% of statements
ok github.com/jianghushinian/blog-go-example/test/getting-started/test/abs 3.136s coverage: [no statements]
注意:根據我的實際測試結果來看,測試覆蓋率默認僅包含單元測試、示例測試和模糊測試(模糊測試僅執行
f.Add
添加的種子參數測試),基準測試並不會被統計。要想將基準測試納入覆蓋率統計,需要增加-bench
參數。你可以增加-v
函數查看更詳細信息。
此外,go test
命令還支持使用 -coverprofile
參數生成覆蓋率 profile
文件:
$ go test -coverprofile=coverage.out ./...
? github.com/jianghushinian/blog-go-example/test/getting-started [no test files]
ok github.com/jianghushinian/blog-go-example/test/getting-started/abs 3.101s coverage: 100.0% of statements
ok github.com/jianghushinian/blog-go-example/test/getting-started/animal 3.495s coverage: 100.0% of statements
ok github.com/jianghushinian/blog-go-example/test/getting-started/hello 3.910s coverage: 60.0% of statements
ok github.com/jianghushinian/blog-go-example/test/getting-started/test/abs 4.312s coverage: [no statements]
命令執行後,將在當前目錄生成一個 coverage.out
文件,內容如下:
cat coverage.out
mode: set
github.com/jianghushinian/blog-go-example/test/getting-started/abs/abs.go:5.29,7.2 1 1
github.com/jianghushinian/blog-go-example/test/getting-started/animal/animal.go:7.32,8.21 1 1
github.com/jianghushinian/blog-go-example/test/getting-started/animal/animal.go:8.21,10.3 1 1
github.com/jianghushinian/blog-go-example/test/getting-started/animal/animal.go:11.2,11.21 1 1
github.com/jianghushinian/blog-go-example/test/getting-started/animal/animal.go:11.21,13.3 1 1
github.com/jianghushinian/blog-go-example/test/getting-started/animal/animal.go:14.2,14.17 1 1
github.com/jianghushinian/blog-go-example/test/getting-started/hello/hello.go:10.41,11.16 1 1
github.com/jianghushinian/blog-go-example/test/getting-started/hello/hello.go:11.16,13.3 1 0
github.com/jianghushinian/blog-go-example/test/getting-started/hello/hello.go:14.2,14.20 1 1
github.com/jianghushinian/blog-go-example/test/getting-started/hello/hello.go:14.20,16.3 1 0
github.com/jianghushinian/blog-go-example/test/getting-started/hello/hello.go:17.2,17.29 1 1
有了 coverage.out
文件,我們可以直接使用 go tool cover
命令查看測試覆蓋率:
$ go tool cover -func=coverage.out
github.com/jianghushinian/blog-go-example/test/getting-started/abs/abs.go:5: Abs 100.0%
github.com/jianghushinian/blog-go-example/test/getting-started/animal/animal.go:7: shout 100.0%
github.com/jianghushinian/blog-go-example/test/getting-started/hello/hello.go:10: Hello 60.0%
total: (statements) 81.8%
以上方式,實現了在命令行查看程序測試覆蓋率。
我們還可以通過 go tool cover
命令以可視化的方式查看測試覆蓋率:
$ go tool cover -html=coverage.out -o=coverage.html
執行命令後,會在當前目錄下生成 coverage.html
文件,使用瀏覽器打開內容如下:
在頁面頂部左側,可以切換查看不同的測試文件和對應測試覆蓋率。
灰色代碼表示未被跟蹤 not tracked
。
紅色部分表示未被測試的代碼 not covered
。
綠色部分表示已經被測試覆蓋的代碼 covered
。
這樣,我們就可以更加直觀的查看和分析代碼測試覆蓋率了。
總結
本文向大家介紹了 Go 中編寫各種測試代碼的方式。
Go 支持單元測試、基準測試、示例測試以及模糊測試四種測試方法。
單元測試是我們最常使用的測試方法,如果被測代碼需要編寫多個測試用例,可以使用表格測試。
基準測試能夠測量程序的性能指標,默認情況下 go test
不會執行基準測試,需要指定 -bench regexp
參數纔可以執行。
示例測試可以測試程序的標準輸出內容,並且能夠配合 pkgsite
工具,在查看本地包文檔時作爲被測函數文檔的一部分。
模糊測試是 Go 1.18 版本引入的,是一種基於隨機輸入的自動化測試技術,非常強大,適合用於發現處理用戶輸入的代碼中存在的問題。
根據測試代碼與被測代碼是否在同一個包中,測試又可以分爲白盒測試和黑盒測試,我們應該儘量編寫白盒測試。
可以使用 go test -cover
查看測試覆蓋率,我們可以將測試覆蓋率基線集成到 CI 中,來保證單元測試覆蓋率。
go test
命令支持的更多參數可以通過 go help testflag
命令查看。
由於篇幅所限,本文僅算做是 Go 單元測試的基礎入門,更多單元測試在實戰場景中的應用,我會在後續文章中進行講解,敬請期待。
本文完整代碼示例我放在了 GitHub 上,歡迎點擊查看。
希望此文能對你有所幫助。
參考
-
Go testing 文檔:https://pkg.go.dev/testing@go1.20.1
-
Go Fuzzing:https://go.dev/security/fuzz/
-
Go 1.18 新特性前瞻:原生支持 Fuzzing 測試:https://tonybai.com/2021/12/01/first-class-fuzzing-in-go-1-18/
聯繫我
微信:jianghushinian
郵箱:jianghushinian007@outlook.com
博客地址:https://jianghushinian.cn
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Flaco1lI8P9Kv3gsq9s-Nw