Skip to content

測試

對於開發者而言,良好的測試可以提前發現程序的中錯誤,避免後續因維護不及時產生 Bug 而造成的心智負擔,所以寫好測試非常有必要。Go 在測試這一方面提供了非常簡便實用的命令行工具go test,在標准庫和許多開源框架都能看到測試的身影,該工具使用起來十分方便,目前支持以下幾種測試:

  • 示例測試
  • 單元測試
  • 基准測試
  • 模糊測試

在 Go 中大部分的 API 都是由標准庫testing提供。

TIP

在命令行中執行go help testfunc命令,可看 Go 官方對於上面四種測試類型的解釋。

編寫規范

在開始編寫測試之前,首先需要注意幾點規范,這樣在後續的學習中會更加方便。

  • 測試包:測試文件最好單獨放在一個包中,這個包通常命名為test
  • 測試文件:測試文件通常以_test.go結尾,例如要測試某一個功能,就將其命名為function_test.go,如果想根據測試類型再劃分的更細一些也可以將測試類型為作為文件前綴,例如benchmark_marshaling_test.go,或者example_marshaling_test.go
  • 測試函數:每一個測試文件中都會有若干個測試函數用於不同的測試。對於不同的測試類型,測試函數的命名的風格也不同。例如示例測試是ExampleXXXX,單元測試是TestXXXX,基准測試是BenchmarkXXXX,模糊測試是FuzzXXXX,這樣一來即便不需要注釋也可以知曉這是什麼類型的測試。

TIP

當包名為testdata時,該包通常是為了存儲用於測試的輔助數據,在執行測試時,Go 會忽略名為testdata的包。

遵循上述的規范,養成良好的測試風格,可以為日後的維護省去不少的麻煩。

執行測試

執行測試主要會用到go test命令,下面拿實際的代碼舉例,現在有待測試文件/say/hello.go代碼如下

go
package say

import "fmt"

func Hello() {
  fmt.Println("hello")
}

func GoodBye() {
  fmt.Println("bye")
}

和測試文件/test/example_test.go代碼如下

go
package test

import (
  "golearn/say"
)

func ExampleHello() {
  say.Hello()
  // Output:
  // hello
}

func ExampleGoodBye() {
  say.GoodBye()
  // Output:
  // bye
}

func ExampleSay() {
  say.Hello()
  say.GoodBye()
  // Output:
  // hello
  // bye
}

執行這些測試有多種方法,比如想要執行test包下所有的測試用例,就可以直接在test目錄下執行如下命令

sh
$ go test ./
PASS
ok      golearn/test    0.422s

./表示當前目錄,Go 會將test目錄下的所有測試文件重新編譯後,然後再將所有測試用例全都執行,從結果可以看出所有的測試用例都通過了。其後的參數也可以跟多個目錄,例如下方的命令,顯然項目的主目錄並沒有測試文件可供執行。

sh
$ go test ./ ../
ok      golearn/test
?       golearn [no test files]

TIP

當執行的參數有多個包時,Go 並不會再次執行已經成功通過的測試用例,在執行時會行尾添加(cached)以表示輸出結果是上一次的緩存。當測試的標志參數位於以下集合中時,Go 就會緩存測試結果,否則就不會。

-benchtime, -cpu,-list, -parallel, -run, -short, -timeout, -failfast, -v

如果想要禁用緩存,可以加上參數 -count=1

當然也可以單獨指定某一個測試文件來執行。

sh
$ go test example_test.go
ok      command-line-arguments  0.457s

或者可以單獨指定某一個測試文件的某一個測試用例,例如

sh
$ go test -run ExampleSay
PASS
ok      golearn/test    0.038s

上面三種情況雖然都完成了測試,但是輸出結果太簡潔了,這時可以加上參數-v,來使輸出結果更加詳細一點,例如

sh
$ go test ./ -v
=== RUN   ExampleHello
--- PASS: ExampleHello (0.00s)
=== RUN   ExampleGoodBye
--- PASS: ExampleGoodBye (0.00s)
=== RUN   ExampleSay
--- PASS: ExampleSay (0.00s)
PASS
ok      golearn/test    0.040s

這下可以很清楚的看到每一個測試用例的執行順序,耗時,執行情況,以及總體的耗時。

TIP

go test命令默認運行所有的單元測試,示例測試,模糊測試,如果加上了-bench參數則會運行所有類型的測試,例如下方的命令

sh
$ go test -bench .

所以需要使用-run參數來指定,例如只運行所有的基准測試的命令如下

sh
$ go test -bench . -run ^$

常用參數

Go 測試有著非常多的標志參數,下面只會介紹常用的參數,想要了解更多細節建議使用go help testflag命令自行查閱。

參數釋義
-o file指定編譯後的二進制文件名稱
-c只編譯測試文件,但不運行
-json以 json 格式輸出測試日志
-exec xprog使用xprog運行測試,等價於go run
-bench regexp選中regexp匹配的基准測試
-fuzz regexp選中regexp匹配的模糊測試
-fuzztime t模糊測試自動結束的時間,t為時間間隔,當單位為x時,表示次數,例如200x
-fuzzminimizetime t模式測試運行的最小時間,規則同上
-count n運行測試 n 次,默認 1 次
-cover開啟測試覆蓋率分析
-covermode set,count,atomic設置覆蓋率分析的模式
-cpu為測試執行GOMAXPROCS
-failfast第一次測試失敗後,不會開始新的測試
-list regexp列出regexp匹配的測試用例
-parallel n允許調用了t.Parallel的測試用例並行運行,n值為並行的最大數量
-run regexp只運行regexp匹配的測試用例
-skip regexp跳過regexp匹配的測試用例
-timeout d如果單次測試執行時間超過了時間間隔d,就會panicd為時間間隔,例 1s,1ms,1ns 等
-shuffle off,on,N打亂測試的執行順序,N為隨機種子,默認種子為系統時間
-v輸出更詳細的測試日志
-benchmem統計基准測試的內存分配
-blockprofile block.out統計測試中協程阻塞情況並寫入文件
-blockprofilerate n控制協程阻塞統計頻率,通過命令go doc runtime.SetBlockProfileRate查看更多細節
-coverprofile cover.out統計覆蓋率測試的情況並寫入文件
-cpuprofile cpu.out統計 cpu 情況並寫入文件
-memprofile mem.out統計內存分配情況並寫入文件
-memprofilerate n控制內存分配統計的頻率,通過命令go doc runtime.MemProfileRate查看更多細節
-mutexprofile mutex.out統計鎖競爭情況並寫入文件
-mutexprofilefraction n設置統計n個協程競爭一個互斥鎖的情況
-trace trace.out將執行追蹤情況寫入文件
-outputdir directory指定上述的統計文件的輸出目錄,默認為go test的運行目錄

示例測試

示例測試並不像其他三種測試一樣是為了發現程序的問題所在,它更多的是為了展示某一個功能的使用方法,起到文檔作用。示例測試並不是一個官方定義的概念,也不是一個硬性的規范,更像是一種工程上的約定俗成,是否遵守只取決於開發者。示例測試在標准庫中出現的非常多,通常是官方所編寫的標准庫代碼示例,例如標准庫context/example_test.go中的ExampleWithDeadline測試函數,該函數中展現了DeadlineContext的基本使用方法:

go
// This example passes a context with an arbitrary deadline to tell a blocking
// function that it should abandon its work as soon as it gets to it.
func ExampleWithDeadline() {
   d := time.Now().Add(shortDuration)
   ctx, cancel := context.WithDeadline(context.Background(), d)

   // Even though ctx will be expired, it is good practice to call its
   // cancellation function in any case. Failure to do so may keep the
   // context and its parent alive longer than necessary.
   defer cancel()

   select {
   case <-time.After(1 * time.Second):
      fmt.Println("overslept")
   case <-ctx.Done():
      fmt.Println(ctx.Err())
   }

   // Output:
   // context deadline exceeded
}

表面上看該測試函數就是一個普通的函數,不過示例測試主要是由Output注釋來體現的,待測試函數只有一行輸出時,使用Output注釋來檢測輸出。首先創建一個名為hello.go的文件,寫入如下代碼

go
package say

import "fmt"

func Hello() {
  fmt.Println("hello")
}

func GoodBye() {
  fmt.Println("bye")
}

SayHello函數就是待測函數,然後創建測試文件example_test.go,寫入如下代碼

go
package test

import (
  "golearn/say"
)

func ExampleHello() {
  say.Hello()
  // Output:
  // hello
}

func ExampleGoodBye() {
  say.GoodBye()
  // Output:
  // bye
}

func ExampleSay() {
  say.Hello()
  say.GoodBye()
  // Output:
  // hello
  // bye
}

函數中Output注釋表明了檢測函數輸出是否為hello,接下來執行測試命令看看結果。

sh
$ go test -v
=== RUN   ExampleHello
--- PASS: ExampleHello (0.00s)
=== RUN   ExampleGoodBye
--- PASS: ExampleGoodBye (0.00s)
=== RUN   ExampleSay
--- PASS: ExampleSay (0.00s)
PASS
ok      golearn/test    0.448s

從結果可以看出全部測試都已經通過,關於Output有以下幾種寫法,第一種是只有一行輸出,意為檢測該函數的輸出是不是 hello

// Output:
// hello

第二種是多行輸出,即按順序檢測輸出是否匹配

// Output:
// hello
// bye

第三種是無序輸出,即不按照順序多行輸出匹配

// Unordered output:
// bye
// hello

需要注意的是,對於測試函數而言,僅當最後幾行為Output注釋才會被視為示例測試,否則就只是一個普通的函數,不會被 Go 執行。

單元測試

單元測試就是對軟件中的最小可測試單元進行測試,單元的大小定義取決於開發者,可能是一個結構體,或者是一個包,也可能是一個函數,或者是一個類型。下面依舊通過例子來演示,首先創建/tool/math.go文件,寫入如下代碼

go
package tool

type Number interface {
  ~int8 | ~int16 | ~int32 | ~int64 | ~int
}

func SumInt[T Number](a, b T) T {
  return a + b
}

func Equal[T Number](a, b T) bool {
  return a == b
}

然後創建測試文件/tool_test/unit_test.go,對於單元測試而言,命名可以為unit_test或者是想要測試的包或者功能作為文件前綴。

go
package test_test

import (
  "golearn/tool"
  "testing"
)

func TestSum(t *testing.T) {
  a, b := 10, 101
  expected := 111

  actual := tool.SumInt(a, b)
  if actual != expected {
    t.Errorf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
  }
}

func TestEqual(t *testing.T) {
  a, b := 10, 101
  expected := false

  actual := tool.Equal(a, b)
  if actual != expected {
    t.Errorf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
  }
}

對於單元測試而言,每一個測試用例的命名風格為TestXXXX,且函數的入參必須是t *testing.Ttesting.Ttesting包提供的用於方便測試的結構體,提供了許多可用的方法,例子中的t.Errorf等同於t.Logf,用於格式化輸出測試失敗的日志信息,其他常用的還有t.Fail用於將當前用例標記為測試失敗,功能類似的還有t.FailNow同樣會標記為測試失敗,但是前者失敗後還會繼續執行,後者則會直接停止執行,如下方的例子,將預期結果修改為錯誤的結果:

go
package tool_test

import (
  "golearn/tool"
  "testing"
)

func TestSum(t *testing.T) {
  a, b := 10, 101
  expected := 110

  actual := tool.SumInt(a, b)
  if actual != expected {
        // Errorf內部使用的是t.Fail()
    t.Errorf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
  }
  t.Log("test finished")
}

func TestEqual(t *testing.T) {
  a, b := 10, 101
  expected := true

  actual := tool.Equal(a, b)
  if actual != expected {
        // Fatalf內部使用的是t.FailNow()
    t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
  }
  t.Log("test finished")
}

執行上述測試輸出如下

sh
$ go test tool_test.go -v
=== RUN   TestSum
    tool_test.go:14: Sum(10,101) expected 110,actual is 111
    tool_test.go:16: test finished
--- FAIL: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:25: Sum(10,101) expected true,actual is false
--- FAIL: TestEqual (0.00s)
FAIL    command-line-arguments  0.037s

從測試日志中可以看出TestSum用例盡管失敗了還是輸出了 test finished,而TestEqual則沒有,同樣的還有t.SkipNow,會將當前用例標記為SKIP,然後停止執行,在下一輪測試中會繼續執行。

go
package tool_test

import (
   "golearn/tool"
   "testing"
)

func TestSum(t *testing.T) {
   a, b := 10, 101
   expected := 110

   actual := tool.SumInt(a, b)
   if actual != expected {
      t.Skipf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
   }
   t.Log("test finished")
}

func TestEqual(t *testing.T) {
   a, b := 10, 101
   expected := true

   actual := tool.Equal(a, b)
   if actual != expected {
      t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
   }
   t.Log("test finished")
}

在執行測試時,修改測試次數為 2

go
$ go test tool_test.go -v -count=2
=== RUN   TestSum
    tool_test.go:14: Sum(10,101) expected 110,actual is 111
--- SKIP: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:25: Sum(10,101) expected true,actual is false
--- FAIL: TestEqual (0.00s)
=== RUN   TestSum
    tool_test.go:14: Sum(10,101) expected 110,actual is 111
--- SKIP: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:25: Sum(10,101) expected true,actual is false
--- FAIL: TestEqual (0.00s)
FAIL    command-line-arguments  0.468s

上數的例子中在最後一行輸出了 test finished,用於表示測試完畢,其實可以使用t.Cleanup來注冊一個收尾函數專門做此事,該函數會在測試用例結束時執行,如下。

go
package tool_test

import (
  "golearn/tool"
  "testing"
)

func finished(t *testing.T) {
  t.Log("test finished")
}

func TestSum(t *testing.T) {
  t.Cleanup(func() {
    finished(t)
  })

  a, b := 10, 101
  expected := 111

  actual := tool.SumInt(a, b)
  if actual != expected {
    t.Skipf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
  }

}

func TestEqual(t *testing.T) {
  t.Cleanup(func() {
    finished(t)
  })

  a, b := 10, 101
  expected := false

  actual := tool.Equal(a, b)
  if actual != expected {
    t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
  }
}

執行測試後輸出如下

$ go test tool_test.go -v
=== RUN   TestSum
    tool_test.go:9: test finished
--- PASS: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:9: test finished
--- PASS: TestEqual (0.00s)
PASS
ok      command-line-arguments  0.462s

Helper

通過t.Helper()可以將當前函數標記為幫助函數,幫助函數不會單獨作為一個測試用例用於執行,在記錄日志時輸出的行號也是幫助函數的調用者的行號,這樣可以使得分析日志時定位更准確,避免的冗雜的其他信息。比如上述t.Cleanup的例子就可以修改為幫助函數,如下。

go
package tool_test

import (
   "golearn/tool"
   "testing"
)

func CleanupHelper(t *testing.T) {
   t.Helper()
   t.Log("test finished")
}

func TestSum(t *testing.T) {
   t.Cleanup(func() {
      CleanupHelper(t)
   })

   a, b := 10, 101
   expected := 111

   actual := tool.SumInt(a, b)
   if actual != expected {
      t.Skipf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
   }

}

func TestEqual(t *testing.T) {
   t.Cleanup(func() {
      CleanupHelper(t)
   })

   a, b := 10, 101
   expected := false

   t.Helper()
   actual := tool.Equal(a, b)
   if actual != expected {
      t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
   }
}

執行測試後輸出信息如下,與之前的區別在於 test finished 的行號變成了調用者的行號。

$ go test tool_test.go -v
=== RUN   TestSum
    tool_test.go:15: test finished
--- PASS: TestSum (0.00s)
=== RUN   TestEqual
    tool_test.go:30: test finished
--- PASS: TestEqual (0.00s)
PASS
ok      command-line-arguments  0.464s

TIP

上述操作都只能在主測試中進行,即直接執行的測試用例,如果是子測試中使用將會panic

子測試

在一些情況下,會需要用到在一個測試用例中測試另外測試用例,這種嵌套的測試用例一般稱為子測試,通過t.Run()方法,該方法簽名如下

go
// Run方法會開啟一個新的協程用於運行子測試,阻塞等待函數f執行完畢後才會返回
// 返回值為是否通過測試
func (t *T) Run(name string, f func(t *T)) bool

下面是一個例子

go
func TestTool(t *testing.T) {
  t.Run("tool.Sum(10,101)", TestSum)
  t.Run("tool.Equal(10,101)", TestEqual)
}

執行後結果如下

sh
$ go test -run TestTool -v
=== RUN   TestTool
=== RUN   TestTool/tool.Sum(10,101)
    tool_test.go:15: test finished
=== RUN   TestTool/tool.Equal(10,101)
    tool_test.go:30: test finished
--- PASS: TestTool (0.00s)
    --- PASS: TestTool/tool.Sum(10,101) (0.00s)
    --- PASS: TestTool/tool.Equal(10,101) (0.00s)
PASS
ok      golearn/tool_test       0.449s

通過輸出可以很清晰的看到父子的層級結構,在上述的例子中第一個子測試未執行完畢第二個子測試是不會執行的,可以使用t.Parallel()將測試用例標記為可並行運行,如此一來輸出的順序將會無法確定。

go
package tool_test

import (
  "golearn/tool"
  "testing"
)

func CleanupHelper(t *testing.T) {
  t.Helper()
  t.Log("test finished")
}

func TestSum(t *testing.T) {
  t.Parallel()
  t.Cleanup(func() {
    CleanupHelper(t)
  })

  a, b := 10, 101
  expected := 111

  actual := tool.SumInt(a, b)
  if actual != expected {
    t.Skipf("Sum(%d,%d) expected %d,actual is %d", a, b, expected, actual)
  }

}

func TestEqual(t *testing.T) {
  t.Parallel()
  t.Cleanup(func() {
    CleanupHelper(t)
  })

  a, b := 10, 101
  expected := false

  actual := tool.Equal(a, b)
  if actual != expected {
    t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
  }
}

func TestToolParallel(t *testing.T) {
  t.Log("setup")
  t.Run("tool.Sum(10,101)", TestSum)
  t.Run("tool.Equal(10,101)", TestEqual)
  t.Log("teardown")
}

執行測試後輸出如下

$ go test -run TestTool -v
=== RUN   TestToolParallel
    tool_test.go:46: setup
=== RUN   TestToolParallel/tool.Sum(10,101)
=== PAUSE TestToolParallel/tool.Sum(10,101)
=== RUN   TestToolParallel/tool.Equal(10,101)
=== PAUSE TestToolParallel/tool.Equal(10,101)
=== NAME  TestToolParallel
    tool_test.go:49: teardown
=== CONT  TestToolParallel/tool.Sum(10,101)
=== CONT  TestToolParallel/tool.Equal(10,101)
=== NAME  TestToolParallel/tool.Sum(10,101)
    tool_test.go:16: test finished
=== NAME  TestToolParallel/tool.Equal(10,101)
    tool_test.go:32: test finished
--- PASS: TestToolParallel (0.00s)
    --- PASS: TestToolParallel/tool.Sum(10,101) (0.00s)
    --- PASS: TestToolParallel/tool.Equal(10,101) (0.00s)
PASS
ok      golearn/tool_test       0.444s

從測試結果中就可以很明顯的看出有一個阻塞等待的過程,在並發執行測試用例時,像上述的例子肯定是無法正常進行的,因為後續的代碼無法保證同步運行,這時可以選擇再嵌套一層t.Run(),如下

go
func TestToolParallel(t *testing.T) {
  t.Log("setup")
  t.Run("process", func(t *testing.T) {
    t.Run("tool.Sum(10,101)", TestSum)
    t.Run("tool.Equal(10,101)", TestEqual)
  })
  t.Log("teardown")
}

再次執行,就可以看到正常的執行結果了。

$ go test -run TestTool -v
=== RUN   TestToolParallel
    tool_test.go:46: setup
=== RUN   TestToolParallel/process
=== RUN   TestToolParallel/process/tool.Sum(10,101)
=== PAUSE TestToolParallel/process/tool.Sum(10,101)
=== RUN   TestToolParallel/process/tool.Equal(10,101)
=== PAUSE TestToolParallel/process/tool.Equal(10,101)
=== CONT  TestToolParallel/process/tool.Sum(10,101)
=== CONT  TestToolParallel/process/tool.Equal(10,101)
=== NAME  TestToolParallel/process/tool.Sum(10,101)
    tool_test.go:16: test finished
=== NAME  TestToolParallel/process/tool.Equal(10,101)
    tool_test.go:32: test finished
=== NAME  TestToolParallel
    tool_test.go:51: teardown
--- PASS: TestToolParallel (0.00s)
    --- PASS: TestToolParallel/process (0.00s)
        --- PASS: TestToolParallel/process/tool.Sum(10,101) (0.00s)
        --- PASS: TestToolParallel/process/tool.Equal(10,101) (0.00s)
PASS
ok      golearn/tool_test       0.450s

表格風格

在上述的單元測試中,測試的輸入數據都是手動聲明的一個個變量,當數據量小的時候無傷大雅,但如果想要測試多組數據時,就不太可能再去聲明變量來創建測試數據,所以一般情況下都是盡量采用結構體切片的形式,結構體是臨時聲明的匿名結構體,因為這樣的編碼風格看起來就跟表格一樣,所以稱為table-driven。下面舉個例子,這是一個手動聲明多個變量來創建測試數據的例子,如果有多組數據看起來就不是很直觀,所以將其修改為表格風格

go
func TestEqual(t *testing.T) {
  t.Cleanup(func() {
    CleanupHelper(t)
  })

  a, b := 10, 101
  expected := false
  actual := tool.Equal(a, b)
  if actual != expected {
    t.Fatalf("Sum(%d,%d) expected %t,actual is %t", a, b, expected, actual)
  }
}

修改後的代碼如下

go
func TestEqual(t *testing.T) {
  t.Cleanup(func() {
    CleanupHelper(t)
  })

  // table driven style
  testData := []struct {
    a, b int
    exp  bool
  }{
    {10, 101, false},
    {5, 5, true},
    {30, 32, false},
    {100, 101, false},
    {2, 3, false},
    {4, 4, true},
  }

  for _, data := range testData {
    if actual := tool.Equal(data.a, data.b); actual != data.exp {
      t.Fatalf("Sum(%d,%d) expected %t,actual is %t", data.a, data.b, data.exp, actual)
    }
  }
}

這樣的測試數據看起來就要直觀很多。

基准測試

基准測試又稱為性能測試,通常用於測試程序的內存佔用,CPU 使用情況,執行耗時等等性能指標。對於基准測試而言,測試文件通常以bench_test.go結尾,而測試用例的函數必須為BenchmarkXXXX格式。

下面以一個字符串拼接的例子的性能比較來當作基准測試的例子。首先創建文件/tool/strConcat.go文件,眾所周知直接使用字符串進行+拼接性能是很低的,而使用strings.Builder則要好很多,在/tool/strings.go文件分別創建兩個函數進行兩種方式的字符串拼接。

go
package tool

import "strings"


func ConcatStringDirect(longString string) {
   res := ""
   for i := 0; i < 100_000.; i++ {
      res += longString
   }
}

func ConcatStringWithBuilder(longString string) {
   var res strings.Builder
   for i := 0; i < 100_000.; i++ {
      res.WriteString(longString)
   }
}

然後創建測試文件/tool_test/bench_tool_test.go ,代碼如下

go
package tool_test

import (
  "golearn/tool"
  "testing"
)

var longString = "longStringlongStringlongStringlongStringlongStringlongStringlongStringlongString"

func BenchmarkConcatDirect(b *testing.B) {
  for i := 0; i < b.N; i++ {
    tool.ConcatStringDirect(longString)
  }
}

func BenchmarkConcatBuilder(b *testing.B) {
  for i := 0; i < b.N; i++ {
    tool.ConcatStringWithBuilder(longString)
  }
}

執行測試命令,命令中開啟了詳細日志和內存分析,指定了使用的 CPU 核數列表,且每個測試用例執行兩輪,輸出如下

sh
$ go test -v -benchmem -bench . -run bench_tool_test.go -cpu=2,4,8 -count=2
goos: windows
goarch: amd64
pkg: golearn/tool_test
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
BenchmarkConcatDirect
BenchmarkConcatDirect-2                4         277771375 ns/op        4040056736 B/op    10000 allocs/op
BenchmarkConcatDirect-2                4         278500125 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-4                1        1153796000 ns/op        4040068784 B/op    10126 allocs/op
BenchmarkConcatDirect-4                1        1211017600 ns/op        4040073104 B/op    10171 allocs/op
BenchmarkConcatDirect-8                2         665460800 ns/op        4040077760 B/op    10219 allocs/op
BenchmarkConcatDirect-8                2         679774450 ns/op        4040080064 B/op    10243 allocs/op
BenchmarkConcatBuilder
BenchmarkConcatBuilder-2            3428            344530 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            3579            351858 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-4            2448            736177 ns/op         4128185 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1688            662993 ns/op         4128185 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1958            550333 ns/op         4128199 B/op         29 allocs/op
BenchmarkConcatBuilder-8            2174            552113 ns/op         4128196 B/op         29 allocs/op
PASS
ok      golearn/tool_test       21.381s

下面解釋一下基准測試的輸出結果,goos代表是運行的操作系統,goarh代表的是 CPU 架構,pkg為測試所在的包,cpu是一些關於 CPU 的信息。下面的每一個測試用例的結果由每一個基准測試的名稱分隔,第一列BenchmarkConcatDirect-2中的 2 代表了使用的 CPU 核數,第二列的 4 代表了代碼中b.N的大小,也就是基准測試中的循環次數,第三列277771375 ns/op代表了每一次循環所消耗的時間,ns 為納秒,第四列4040056736 B/op表示每一次循環所分配內存的字節大小,第五列10000 allocs/op表示每一次循環內存分配的次數。

很顯然,根據測試的結果看來,使用strings.Builder的性能要遠遠高於使用+拼接字符串,通過直觀的數據對比性能正是基准測試的目的所在。

benchstat

benchstat 是一個開源的性能測試分析工具,上述性能測試的樣本數只有兩組,一旦樣本多了起來人工分析就會十分的費時費力,該工具便是為了解決性能分析問題而生。

首先需要下載該工具

sh
$ go install golang.org/x/perf/benchstat

分兩次執行基准測試,這次將樣本數修改為 5 個,並且分別輸出到old.txtnew.txt文件以做對比,第一次執行結果

sh
$ go test -v -benchmem -bench . -run bench_tool_test.go -cpu=2,4,8 -count=5 | tee -a old.txt
goos: windows
goarch: amd64
pkg: golearn/tool_test
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
BenchmarkConcatDirect
BenchmarkConcatDirect-2                4         290535650 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         298974625 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         299637800 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         276487000 ns/op        4040056784 B/op    10001 allocs/op
BenchmarkConcatDirect-2                4         356465275 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-4                2         894723200 ns/op        4040077424 B/op    10216 allocs/op
BenchmarkConcatDirect-4                2         785830400 ns/op        4040078288 B/op    10225 allocs/op
BenchmarkConcatDirect-4                2         743634000 ns/op        4040077568 B/op    10217 allocs/op
BenchmarkConcatDirect-4                2         953802700 ns/op        4040075408 B/op    10195 allocs/op
BenchmarkConcatDirect-4                2         953028750 ns/op        4040077520 B/op    10217 allocs/op
BenchmarkConcatDirect-8                2         684023150 ns/op        4040086784 B/op    10313 allocs/op
BenchmarkConcatDirect-8                2         634380250 ns/op        4040090528 B/op    10352 allocs/op
BenchmarkConcatDirect-8                2         685030600 ns/op        4040090768 B/op    10355 allocs/op
BenchmarkConcatDirect-8                2         817909650 ns/op        4040089808 B/op    10345 allocs/op
BenchmarkConcatDirect-8                2         600078100 ns/op        4040095664 B/op    10406 allocs/op
BenchmarkConcatBuilder
BenchmarkConcatBuilder-2            2925            419651 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            2961            423899 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            2714            422275 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            2848            452255 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            2612            454452 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-4             974           1158000 ns/op         4128189 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1098           1068682 ns/op         4128192 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1042           1056570 ns/op         4128194 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1280            978213 ns/op         4128191 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1538           1162108 ns/op         4128190 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1744            700824 ns/op         4128203 B/op         29 allocs/op
BenchmarkConcatBuilder-8            2235            759537 ns/op         4128201 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1556            736455 ns/op         4128204 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1592            825794 ns/op         4128201 B/op         29 allocs/op
BenchmarkConcatBuilder-8            2263            717285 ns/op         4128203 B/op         29 allocs/op
PASS
ok      golearn/tool_test       56.742s

第二次執行結果

sh
$ go test -v -benchmem -bench . -run bench_tool_test.go -cpu=2,4,8 -count=5 | tee -a new.txt
goos: windows
goarch: amd64
pkg: golearn/tool_test
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
BenchmarkConcatDirect
BenchmarkConcatDirect-2                4         285074900 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         291517150 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         281901975 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         292320625 ns/op        4040056592 B/op     9999 allocs/op
BenchmarkConcatDirect-2                4         286723000 ns/op        4040056952 B/op    10002 allocs/op
BenchmarkConcatDirect-4                1        1188983000 ns/op        4040071856 B/op    10158 allocs/op
BenchmarkConcatDirect-4                1        1080713900 ns/op        4040070800 B/op    10147 allocs/op
BenchmarkConcatDirect-4                1        1203622300 ns/op        4040067344 B/op    10111 allocs/op
BenchmarkConcatDirect-4                1        1045291300 ns/op        4040070224 B/op    10141 allocs/op
BenchmarkConcatDirect-4                1        1123163300 ns/op        4040070032 B/op    10139 allocs/op
BenchmarkConcatDirect-8                2         790421300 ns/op        4040076656 B/op    10208 allocs/op
BenchmarkConcatDirect-8                2         659047300 ns/op        4040079488 B/op    10237 allocs/op
BenchmarkConcatDirect-8                2         712991800 ns/op        4040077184 B/op    10213 allocs/op
BenchmarkConcatDirect-8                2         706605350 ns/op        4040078000 B/op    10222 allocs/op
BenchmarkConcatDirect-8                2         656195700 ns/op        4040085248 B/op    10297 allocs/op
BenchmarkConcatBuilder
BenchmarkConcatBuilder-2            2726            386412 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            3439            335358 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            3376            338957 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            3870            326301 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-2            4285            339596 ns/op         4128176 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1663            671535 ns/op         4128187 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1507            744885 ns/op         4128191 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1353           1097800 ns/op         4128187 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1388           1006019 ns/op         4128189 B/op         29 allocs/op
BenchmarkConcatBuilder-4            1635            993764 ns/op         4128189 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1332            783599 ns/op         4128198 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1818            729821 ns/op         4128202 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1398            780614 ns/op         4128202 B/op         29 allocs/op
BenchmarkConcatBuilder-8            1526            750513 ns/op         4128204 B/op         29 allocs/op
BenchmarkConcatBuilder-8            2164            704798 ns/op         4128204 B/op         29 allocs/op
PASS
ok      golearn/tool_test       50.387s

再使用 benchstat 進行對比

sh
$ benchstat old.txt new.txt
goos: windows
goarch: amd64
pkg: golearn/tool_test
cpu: 11th Gen Intel(R) Core(TM) i7-11800H @ 2.30GHz
    old.txt               new.txt
    sec/op    sec/op      vs base
ConcatDirect-2     299.0m ± ¹    286.7m ± ¹        ~ (p=0.310 n=5)
ConcatDirect-4     894.7m ± ¹   1123.2m ± ¹  +25.53% (p=0.008 n=5)
ConcatDirect-8     684.0m ± ¹    706.6m ± ¹        ~ (p=0.548 n=5)
ConcatBuilder-2    423.9µ ± ¹    339.0µ ± ¹  -20.04% (p=0.008 n=5)
ConcatBuilder-4   1068.7µ ± ¹    993.8µ ± ¹        ~ (p=0.151 n=5)
ConcatBuilder-8    736.5µ ± ¹    750.5µ ± ¹        ~ (p=0.841 n=5)
geomean            19.84m          19.65m         -0.98%
¹ need >= 6 samples for confidence interval at level 0.95

    old.txt                new.txt
     B/op     B/op       vs base
ConcatDirect-2    3.763Gi ± ¹   3.763Gi ± ¹       ~ (p=1.000 n=5)
ConcatDirect-4    3.763Gi ± ¹   3.763Gi ± ¹  -0.00% (p=0.008 n=5)
ConcatDirect-8    3.763Gi ± ¹   3.763Gi ± ¹  -0.00% (p=0.008 n=5)
ConcatBuilder-2   3.937Mi ± ¹   3.937Mi ± ¹       ~ (p=1.000 n=5) ²
ConcatBuilder-4   3.937Mi ± ¹   3.937Mi ± ¹       ~ (p=0.079 n=5)
ConcatBuilder-8   3.937Mi ± ¹   3.937Mi ± ¹       ~ (p=0.952 n=5)
geomean           123.2Mi         123.2Mi        -0.00%
¹ need >= 6 samples for confidence interval at level 0.95
² all samples are equal

   old.txt               new.txt
  allocs/op  allocs/op    vs base
ConcatDirect-2    9.999k ± ¹   9.999k ± ¹       ~ (p=1.000 n=5)
ConcatDirect-4    10.22k ± ¹   10.14k ± ¹  -0.74% (p=0.008 n=5)
ConcatDirect-8    10.35k ± ¹   10.22k ± ¹  -1.26% (p=0.008 n=5)
ConcatBuilder-2    29.00 ± ¹    29.00 ± ¹       ~ (p=1.000 n=5) ²
ConcatBuilder-4    29.00 ± ¹    29.00 ± ¹       ~ (p=1.000 n=5) ²
ConcatBuilder-8    29.00 ± ¹    29.00 ± ¹       ~ (p=1.000 n=5) ²
geomean            543.6          541.7        -0.33%
¹ need >= 6 samples for confidence interval at level 0.95
² all samples are equal

從結果中可以看出 benchstat 將其分為了三組,分別是耗時,內存佔用和內存分配次數,其中geomean為平均值,p為樣本的顯著性水平,臨界區間通常為0.05,高於0.05就不太可信,取其中一條數據如下:

          │    sec/op     │    sec/op      vs base               │
ConcatDirect-4     894.7m ± ∞ ¹   1123.2m ± ∞ ¹  +25.53% (p=0.008 n=5)

可以看到old執行耗時為 894.7ms,new執行耗時 1123.2ms,相比之下還增加了 25.53%的耗時。

模糊測試

模糊測試是 GO1.18 推出的一個新功能,屬於是單元測試和基准測試的一種增強,區別在於前兩者的測試數據都需要開發者手動編寫,而模糊測試可以通過語料庫來生成隨機的測試數據,關於 Go 中的模糊測試可以前往Go Fuzzing來了解更多概念。模糊測試的好處在於,相比於固定的測試數據,隨機數據可以更好的測試程序的邊界條件。下面拿官方教程的例子來講解,這次需要測試的是一個反轉字符串的函數,首先創建文件/tool/strings.go,寫入如下代碼

go
package tool

func Reverse(s string) string {
  b := []byte(s)
  for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
    b[i], b[j] = b[j], b[i]
  }
  return string(b)
}

創建模糊測試文件/tool_test/fuzz_tool_test.go,寫入如下代碼

go
package tool

import (
  "golearn/tool"
  "testing"
  "unicode/utf8"
)

func FuzzReverse(f *testing.F) {
  testdata := []string{"hello world!", "nice to meet you", "good bye!"}
  for _, data := range testdata {
    f.Add(data)
  }

  f.Fuzz(func(t *testing.T, str string) {
    first := tool.Reverse(str)
    second := tool.Reverse(first)
    t.Logf("str:%q,first:%q,second:%q", str, first, second)
    if str != second {
      t.Errorf("before: %q, after: %q", str, second)
    }
    if utf8.ValidString(str) && !utf8.ValidString(first) {
      t.Errorf("Reverse produced invalid UTF-8 string %q %q", str, first)
    }
  })
}

在模糊測試中,首先需要給語料種子庫添加數據,示例中使用f.Add()來添加,有助於後續生成隨機的測試數據。然後使用f.Fuzz(fn)來進行測試,函數簽名如下:

go
func (f *F) Fuzz(ff any)

func (f *F) Add(args ...any)

fn就類似於一個單元測試函數的邏輯,函數的第一個入參必須是t *testing.T,其後跟想要生成的參數。由於傳入的字符串是不可預知的,這裡采用反轉兩次的方法來進行驗證。執行如下命令

sh
$ go test -run Fuzz -v
=== RUN   FuzzReverse
=== RUN   FuzzReverse/seed#0
    fuzz_tool_test.go:18: str:"hello world!",first:"!dlrow olleh",second:"hello world!"
=== RUN   FuzzReverse/seed#1
    fuzz_tool_test.go:18: str:"nice to meet you",first:"uoy teem ot ecin",second:"nice to meet you"
=== RUN   FuzzReverse/seed#2
    fuzz_tool_test.go:18: str:"good bye!",first:"!eyb doog",second:"good bye!"
--- PASS: FuzzReverse (0.00s)
    --- PASS: FuzzReverse/seed#0 (0.00s)
    --- PASS: FuzzReverse/seed#1 (0.00s)
    --- PASS: FuzzReverse/seed#2 (0.00s)
PASS
ok      golearn/tool_test       0.539s

當參數不帶-fuzz時,將不會生成隨機的測試數據,只會給測試函數傳入語料庫中的數據,可以從結果中看到測試全部通過了,這樣使用就等同於單元測試,但其實是有問題的,下面加上-fuzz參數再次執行。

sh
$ go test -fuzz . -fuzztime 30s -run Fuzz -v
=== RUN   FuzzReverse
fuzz: elapsed: 0s, gathering baseline coverage: 0/217 completed
fuzz: minimizing 91-byte failing input file
fuzz: elapsed: 0s, gathering baseline coverage: 15/217 completed
--- FAIL: FuzzReverse (0.13s)
    --- FAIL: FuzzReverse (0.00s)
        fuzz_tool_test.go:18: str:"𐑄",first:"\x84\x91\x90\xf0",second:"𐑄"
        fuzz_tool_test.go:23: Reverse produced invalid UTF-8 string "𐑄" "\x84\x91\x90\xf0"

    Failing input written to testdata\fuzz\FuzzReverse\d856c981b6266ba2
    To re-run:
    go test -run=FuzzReverse/d856c981b6266ba2
=== NAME
FAIL
exit status 1
FAIL    golearn/tool_test       0.697s

TIP

模糊測試中失敗的用例會輸出到當前測試文件夾下的testdata目錄下的某個語料文件中,例如上述例子中的

Failing input written to testdata\fuzz\FuzzReverse\d856c981b6266ba2
To re-run:
go test -run=FuzzReverse/d856c981b6266ba2

testdata\fuzz\FuzzReverse\d856c981b6266ba2便是輸出的語料文件路徑,文件的內容如下

go test fuzz v1
string("𐑄")

可以看到這一次並沒有通過,原因是字符串反轉後變成了非utf8格式,所以通過模糊測試就發現了這個問題所在。由於一些字符佔用並不止一個字節,如果將其以字節為單位反轉後肯定是亂碼,所以將待測試的源代碼修改為如下,將字符串轉換為[]rune,這樣就可以避免出現上述問題。

go
func Reverse(s string) string {
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}

接下來直接運行根據上次模糊測試失敗的用例

sh
$ go test -run=FuzzReverse/d856c981b6266ba2 -v
=== RUN   FuzzReverse
=== RUN   FuzzReverse/d856c981b6266ba2
    fuzz_tool_test.go:18: str:"𐑄",first:"𐑄",second:"𐑄"
--- PASS: FuzzReverse (0.00s)
    --- PASS: FuzzReverse/d856c981b6266ba2 (0.00s)
PASS
ok      golearn/tool_test       0.033s

可以看到這一次通過了測試,再次執行模糊測試看看還有沒有問題

sh
$ go test -fuzz . -fuzztime 30s -run Fuzz -v
=== RUN   FuzzReverse
fuzz: elapsed: 0s, gathering baseline coverage: 0/219 completed
fuzz: minimizing 70-byte failing input file
failure while testing seed corpus entry: FuzzReverse/d97214ce235bfcf5
fuzz: elapsed: 0s, gathering baseline coverage: 2/219 completed
--- FAIL: FuzzReverse (0.15s)
    --- FAIL: FuzzReverse (0.00s)
        fuzz_tool_test.go:18: str:"\xe4",first:"�",second:"�"
        fuzz_tool_test.go:20: before: "\xe4", after: "�"

=== NAME
FAIL
exit status 1
FAIL    golearn/tool_test       0.184s

可以發現又出錯了,這次的問題是對字符串做了兩次反轉後不相等,原字符為\xe4,期望的結果是4ex\ ,但結果是亂碼,如下

go
func main() {
  fmt.Println("\xe4")
  fmt.Println([]byte("\xe4"))
  fmt.Println([]rune("\xe4"))
  fmt.Printf("%q\n", "\xe4")
  fmt.Printf("%x\n", "\xe4")
}

它的執行結果是


[65533]
"\xe4"
e4

究其原因在於\xe4代表一個字節,但並不是一個有效的UTF-8序列(UTF-8編碼中\xe4是一個三字節字符的開始,但後面缺少兩個字節).轉換成[]rune時,Golang自動將它變成含單個Unicode字符的[]rune{"\uFFFD"},其反轉後仍是[]rune{"\uFFFD"},轉換回string時該Unicode字符又被替換為其UTF-8編碼\xef\xbf\xbd。因此一個解決辦法是如果傳入的是非 utf8 字符串,直接返回錯誤:

go
func Reverse(s string) (string, error) {
    if !utf8.ValidString(s) {
        return s, errors.New("input is not valid UTF-8")
    }
    r := []rune(s)
    for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r), nil
}

測試代碼也需要稍作修改

go
func FuzzReverse(f *testing.F) {
  testdata := []string{"hello world!", "nice to meet you", "good bye!"}
  for _, data := range testdata {
    f.Add(data)
  }

  f.Fuzz(func(t *testing.T, str string) {
    first, err := tool.Reverse(str)
    if err != nil {
      t.Skip()
    }
    second, err := tool.Reverse(first)
    if err != nil {
      t.Skip()
    }
    t.Logf("str:%q,first:%q,second:%q", str, first, second)
    if str != second {
      t.Errorf("before: %q, after: %q", str, second)
    }
    if utf8.ValidString(str) && !utf8.ValidString(first) {
      t.Errorf("Reverse produced invalid UTF-8 string %q %q", str, first)
    }
  })
}

當反轉函數返回error時,就跳過測試,再來進行模糊測試

sh
$ go test -fuzz . -fuzztime 30s -run Fuzz -v
=== RUN   FuzzReverse
fuzz: elapsed: 0s, gathering baseline coverage: 0/219 completed
fuzz: elapsed: 0s, gathering baseline coverage: 219/219 completed, now fuzzing with 16 workers
fuzz: elapsed: 3s, execs: 895571 (297796/sec), new interesting: 32 (total: 251)
fuzz: elapsed: 6s, execs: 1985543 (363120/sec), new interesting: 37 (total: 256)
fuzz: elapsed: 9s, execs: 3087837 (367225/sec), new interesting: 38 (total: 257)
fuzz: elapsed: 12s, execs: 4090817 (335167/sec), new interesting: 40 (total: 259)
fuzz: elapsed: 15s, execs: 5132580 (346408/sec), new interesting: 44 (total: 263)
fuzz: elapsed: 18s, execs: 6248486 (372185/sec), new interesting: 45 (total: 264)
fuzz: elapsed: 21s, execs: 7366827 (373305/sec), new interesting: 46 (total: 265)
fuzz: elapsed: 24s, execs: 8439803 (358059/sec), new interesting: 47 (total: 266)
fuzz: elapsed: 27s, execs: 9527671 (361408/sec), new interesting: 47 (total: 266)
fuzz: elapsed: 30s, execs: 10569473 (348056/sec), new interesting: 48 (total: 267)
fuzz: elapsed: 30s, execs: 10569473 (0/sec), new interesting: 48 (total: 267)
--- PASS: FuzzReverse (30.16s)
=== NAME
PASS
ok      golearn/tool_test       30.789s

然後這次就可以得到一個比較完整的模糊測試輸出日志,其中一些概念的解釋如下:

  • elapsed: 一個輪次完成後已經流逝的時間
  • execs: 運行的輸入總數,297796/sec 表示多少個輸入每秒
  • new interesting: 在測試中,已經添加語料庫中的」有趣「輸入的總數。(有趣的輸入指的是該輸入能夠將代碼覆蓋率擴大到現有語料庫所能覆蓋的范圍之外,隨著覆蓋范圍的不斷擴大,它的增長趨勢總體上而言會持續變緩)

TIP

如果沒有-fuzztime參數限制時間,模糊測試將會永遠的運行下去。

類型支持

Go Fuzz 中的支持的類型如下:

  • string, []byte
  • int, int8, int16, int32/rune, int64
  • uint, uint8/byte, uint16, uint32, uint64
  • float32, float64
  • bool

Golang學習網由www.golangdev.cn整理維護