萬字長文——在 Go 中如何編寫測試代碼

在程序開發過程中,測試是非常重要的一環,甚至有一種開發模式叫 TDD(測試驅動開發),先編寫測試,再編寫功能代碼,通過測試來推動整個開發的進行,可見測試在開發中的重要程度。

爲此,Go 語言提供了 testing 框架來方便我們編寫測試,本文將向大家介紹在 Go 中如何編寫測試代碼。

測試分類

在 Go 中,編寫的測試用例可以分爲四類:

這四類測試用例都有各自的適用場景,其中單元測試最爲常用,你一定要掌握。

以上這些測試用例都可以使用 go test 命令來執行。

測試規範

編寫測試代碼並不需要我們學習新的 Go 語法,但有些測試規範還是需要遵守的。

Go 在提供 testing 測試框架時,就規定了很多測試規範,用來約束我們編寫測試的方式,這有助於項目的工程化。

測試文件命名規範

首先,測試文件命名必須以 _test.go 結尾,否則將被測試框架忽略。比如我們的 Go 代碼文件名爲 hello.go,則測試文件可以命名爲 hello_test.go

只有以 _test.go 結尾的測試文件,才能使用 go test 命令執行。

在構建 Go 程序時,go build 命令會忽略以 _test.go 結尾的測試文件。

測試包命名規範

測試用例除了根據使用場景可以分爲四類,還可以根據代碼和測試用例是否在一個包中,分爲白盒測試和黑盒測試。

根據二者各自特點,在開發時我們應該多編寫白盒測試,這樣才能提升代碼測試覆蓋率。

測試用例命名規範

在 Go 中我們使用測試函數來編寫測試用例,根據單元測試、基準測試、示例測試、模糊測試四種不同類型的測試分類,測試函數必須以 TestBenchmarkExampleFuzz 其中一種開頭。

測試函數簽名示例如下:

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/wantactual/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) 函數,並將得到的返回結果 got1 做相等性比較,如果不相等,則說明測試沒有通過,使用 t.Errorf 打印錯誤信息。

參數 *testing.T 是一個結構體指針,提供瞭如下幾個方法用於錯誤報告:

在測試函數所在目錄下使用 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_ParallelTestAbs 這兩個測試函數。

可以發現,兩個函數都不是一次性執行完成的,日誌中 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,如下是我們針對 DogCat 兩種不同的 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)
 }
}

因爲黑盒測試的函數 TestAbsAbs 不在同一個包中,所以需要先使用 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 是測試函數名,8GOMAXPROCS 的值,即參與執行的 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:的示例測試函數只會被編譯,但不會執行。

此外,示例測試還有一個非常有用功能,它能被 godocpkgsite 工具所識別,將示例函數的代碼提取後作爲被測試函數文檔的一部分。

注意:舊版本的 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 即可進入文檔服務首頁:

doc

點擊模塊名 github.com/jianghushinian/blog-go-example/test/getting-started 即可找到 Abs 函數位置,在 Abs 函數下方,標題 ExampleExample (Unordered) 下就是通過示例測試生成的示例文檔:

example

注意:這裏需要額外提及的一點是,我們查看文檔的本地包模塊名稱(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 支持的參數類型有限,僅支持如下幾種類型:

此外,編寫模糊測試時,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 文件,使用瀏覽器打開內容如下:

coverage

在頁面頂部左側,可以切換查看不同的測試文件和對應測試覆蓋率。

灰色代碼表示未被跟蹤 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 上,歡迎點擊查看。

希望此文能對你有所幫助。

參考

聯繫我

微信:jianghushinian

郵箱:jianghushinian007@outlook.com

博客地址:https://jianghushinian.cn

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Flaco1lI8P9Kv3gsq9s-Nw