跟我一起學Go系列:從寫測試用例開始仗劍走天涯

rickiyang發表於2021-04-19

從入門到深入 Go 我們已經走了很長的路,當你想啟動多個測試類的時候你是不是想啟動多個 main 方法,但是 Go 限制了在同一個 package 下只能有一個 main,所以這條路你是走不通的。那我們想寫單元測試的時候應該如何操作呢?彆著急,不用引入任何的第三方包,單元測試 Go 也有預設的規範寫法。

約定

在 Go SDK 中 ”testing“ 包的內容就是 Go 預設提供的單元測試支援。Go 標準庫對單元測試編寫的格式有一些硬性要求:

  • 所有測試方法必須放在位於以 _test.go 結尾的檔案中,這樣在執行 go build 構建的時候測試程式碼才會被排除。
  • 測試函式的命名必須以 Test 開頭,並且跟在 Test 後面的字尾開頭第一個字母必須大寫
  • 測試方法必須要包含 “t *testing.T” 引數。
func TestGetUser(t *testing.T)
func TestInsert(t *testing.T)

其中引數 t 用於報告測試失敗和附加的日誌資訊。 testing.T 的擁有的方法如下:

func (c *T) Error(args ...interface{})
func (c *T) Errorf(format string, args ...interface{})
func (c *T) Fail()
func (c *T) FailNow()
func (c *T) Failed() bool
func (c *T) Fatal(args ...interface{})
func (c *T) Fatalf(format string, args ...interface{})
func (c *T) Log(args ...interface{})
func (c *T) Logf(format string, args ...interface{})
func (c *T) Name() string
func (t *T) Parallel()
func (t *T) Run(name string, f func(t *T)) bool
func (c *T) Skip(args ...interface{})
func (c *T) SkipNow()
func (c *T) Skipf(format string, args ...interface{})
func (c *T) Skipped() bool

比如我們現在有一段迴文檢測的 func:

package service


// 判斷一個字串s是否時迴文字串
func IsPalindrome(s string) bool {
  for i := range s {
    if s[i] != s[len(s)-1-i] {
      return false
    }
  }
  return true
}

想在單元測試中呼叫 這個方法:

package demo

import (
  "gorm-demo/service"
  "testing"
)

func TestString(t *testing.T) {
  palindrome := service.IsPalindrome("3ee3")
  if palindrome {
    t.Logf("IsPalindrome test success, param=%s", "3e1e3")
  } else {
    t.Fatalf("IsPalindrome test fail, param=%s", "3e1e3")
  }

}

根據是否是迴文輸出對應的結果:

=== RUN   TestString
    string_test.go:11: IsPalindrome test success, param=3e1e3
--- PASS: TestString (0.00s)
PASS

除了直接執行對應的測試方法之外我們還可以通過 go test 命令列的方式來執行測試,go test 是 Go 語言自帶的測試工具,其中包含的是兩類:單元測試和效能測試,通過 go help test 可以看到 go test 的使用說明:

格式形如: go test [-c] [-i] [build flags] [packages] [flags for test binary]

引數解讀:

-c : 編譯go test成為可執行的二進位制檔案,但是不執行測試。

-i : 安裝測試包依賴的package,但是不執行測試。

關於build flags,呼叫go help build,這些是編譯執行過程中需要使用到的引數,一般設定為空

關於packages,呼叫go help packages,這些是關於包的管理,一般設定為空

關於flags for test binary,呼叫go help testflag,這些是go test過程中經常使用到的引數

-test.v : 是否輸出全部的單元測試用例(不管成功或者失敗),預設沒有加上,所以只輸出失敗的單元測試用例。

-test.run pattern: 只跑哪些單元測試用例

-test.bench patten: 只跑那些效能測試用例

-test.benchmem : 是否在效能測試的時候輸出記憶體情況

-test.benchtime t : 效能測試執行的時間,預設是1s

-test.cpuprofile cpu.out : 是否輸出cpu效能分析檔案

-test.memprofile mem.out : 是否輸出記憶體效能分析檔案

-test.blockprofile block.out : 是否輸出內部goroutine阻塞的效能分析檔案

-test.memprofilerate n : 記憶體效能分析的時候有一個分配了多少的時候才打點記錄的問題。這個引數就是設定打點的記憶體分配間隔,也就是profile中一個sample代表的記憶體大小。預設是設定為512 * 1024的。如果你將它設定為1,則每分配一個記憶體塊就會在profile中有個打點,那麼生成的profile的sample就會非常多。如果你設定為0,那就是不做打點了。

你可以通過設定memprofilerate=1和GOGC=off來關閉記憶體回收,並且對每個記憶體塊的分配進行觀察。

-test.blockprofilerate n: 基本同上,控制的是goroutine阻塞時候打點的納秒數。預設不設定就相當於-test.blockprofilerate=1,每一納秒都打點記錄一下

-test.parallel n : 效能測試的程式並行cpu數,預設等於GOMAXPROCS。

-test.timeout t : 如果測試用例執行時間超過t,則丟擲panic

-test.cpu 1,2,4 : 程式執行在哪些CPU上面,使用二進位制的1所在位代表,和nginx的nginx_worker_cpu_affinity是一個道理

-test.short : 將那些執行時間較長的測試用例執行時間縮短

測試覆蓋率

Go提供內建功能來檢查你的程式碼覆蓋率。我們可以使用 go test -cover 來檢視測試覆蓋率。

MacBook-Pro:mockDemo yy$ go test -cover
PASS
coverage: 0.0% of statements
ok      gorm-demo/test/mockDemo 0.007s

Go還提供了一個額外的-coverprofile引數,用來將覆蓋率相關的記錄資訊輸出到一個檔案。例如:

MacBook-Pro:mockDemo yy$ go test -cover -coverprofile=tt.log
PASS
coverage: 0.0% of statements
ok      gorm-demo/test/mockDemo 0.007s

生成 tt.log 檔案之後,執行 go tool cover -html=tt.log,使用 cover 工具來處理生成的記錄資訊,該命令會開啟本地的瀏覽器視窗生成一個 HTML 報告。

斷言

使用 Java 的同學看到這裡估計會問: Go 中沒有斷言嗎?還需要自己去判斷。

其實沒有斷言這種東西我們仔細想想也並不難理解,從 Go 的 error 包設計將異常作為返回值而不是使用 try-catch 的模式來說,Go 希望你在測試階段就知曉每一個可能出現的異常,而不是將異常吞掉。所以 Assert 這種吞掉錯誤的功能 Go 官方也不想提供。

當然 Go 官方不提供不代表廣大開發同胞真的不想用,這不有大哥開發了靈活又好用的斷言庫 testify ,有了它,我們上面的程式碼就可以改為這樣:

assert.True(t, service.IsPalindrome("3e45e3"))

輸出:

=== RUN   TestString
string_test.go:11: 
Error Trace:	string_test.go:11
Error:      	Should be true
Test:       	TestString
--- FAIL: TestString (0.00s)
FAIL

簡介明瞭,一眼就知道測試用例是否通過。真的是誰用誰知道。

具體 testify 還有很多實用的斷言方法:

// 判斷兩個值是否相等
func Equal(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
// 判斷兩個值不相等
func NotEqual(t TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
// 測試失敗,測試中斷
func FailNow(t TestingT, failureMessage string, msgAndArgs ...interface{}) bool
// 判斷值是否為nil,常用於 error 的判斷
func Nil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool
// 判斷值是否不為nil,常用於 error 的判斷
func NotNil(t TestingT, object interface{}, msgAndArgs ...interface{}) bool

大家有興趣可以看看 API。

Mock 功能

使用這個功能之前,先著重宣告 mock 的意思。

mock 模擬,模仿的意思。這裡這裡提供的功能是模擬某段功能,用我們的模擬邏輯去代替。

testify 也支援 Mock,不過 Go 原生的 mock 框架就挺好的。GoMock 是由 Go 官方開發維護的測試框架,實現了較為完整的基於 interface 的 Mock 功能。注意它沒在 SDK 裡面哈。

go get -u github.com/golang/mock/gomock

Gomock 還提供了 mockgen 工具用來輔助生成測試程式碼。

go get -u github.com/golang/mock/mockgen

使用的時候這兩個包都需要安裝。

安裝 mockgen 有兩種方式,你可以只在你的當前程式碼目錄執行 go get ,這樣 mockgen 命令只對當前目錄有效;或者你直接取 mockgen 的目錄下執行 go build ,編譯後會在這個目錄下生成一個可執行程式 mockgen。然後將這個可執行程式 mockgen 拖到 $GOPATH/bin/ 目錄下後面你就可以全域性使用 mockgen 。

mockgen 使用也很簡單,可以對包或者原始碼檔案生成指定介面的 Mock 程式碼,注意是對介面檔案哈。

package mockDemo

type Task interface {
	Do(string) (bool, error)
}

想對指定介面生成 mock 程式碼使用如下命令:

mockgen -source=原始檔路徑  -destination=寫入檔案的路徑(沒有這個引數輸出到終端) -package=生成檔案的包名

demo :
mockgen -source=/Users/cc/go/src/go-web-demo/test/mockDemo/task.go -destination=/Users/cc/go/src/go-web-demo/test/mockDemo/mock_task_test.go -package=mockDemo


-source:設定需要模擬(mock)的介面檔案
-destination:設定 mock 檔案輸出的地方,若不設定則列印到標準輸出中
-package:設定 mock 檔案的包名,若不設定則為 `mock_` 字首加上檔名(如本文的包名會為 mock_person)


接下來上示例,再次解釋 mock 就是要模擬,比如我們的 Do 方法要去連線資料庫查詢資料,這裡因為不方便測試連線資料庫這段程式碼,但是又不想影響整體測試流程所以用 mock 的方式去替代這段邏輯。解釋清楚了我們上程式碼。

整體測試程式碼如下:

介面:

package mockDemo

type Task interface {
	Do(string) (bool, error)
}

根據該介面生成 mock 類:

// Code generated by MockGen. DO NOT EDIT.
// Source: /Users/yangyue/go/src/go-web-demo/test/mockDemo/task.go

// Package mockDemo is a generated GoMock package.
package mockDemo

import (
	reflect "reflect"

	gomock "github.com/golang/mock/gomock"
)

// MockTask is a mock of Task interface.
type MockTask struct {
	ctrl     *gomock.Controller
	recorder *MockTaskMockRecorder
}

// MockTaskMockRecorder is the mock recorder for MockTask.
type MockTaskMockRecorder struct {
	mock *MockTask
}

// NewMockTask creates a new mock instance.
func NewMockTask(ctrl *gomock.Controller) *MockTask {
	mock := &MockTask{ctrl: ctrl}
	mock.recorder = &MockTaskMockRecorder{mock}
	return mock
}

// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockTask) EXPECT() *MockTaskMockRecorder {
	return m.recorder
}

// Do mocks base method.
func (m *MockTask) Do(arg0 string) (bool, error) {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "Do", arg0)
	ret0, _ := ret[0].(bool)
	ret1, _ := ret[1].(error)
	return ret0, ret1
}

// Do indicates an expected call of Do.
func (mr *MockTaskMockRecorder) Do(arg0 interface{}) *gomock.Call {
	mr.mock.ctrl.T.Helper()
	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Do", reflect.TypeOf((*MockTask)(nil).Do), arg0)
}

測試方法:

package mockDemo

import (
	"fmt"
	"github.com/golang/mock/gomock"
	"testing"
)

func TestMock(t *testing.T) {
	ctl := gomock.NewController(t)
	defer ctl.Finish()
	task := NewMockTask(ctl)
	gomock.InOrder(task.EXPECT().Do("banana").Return(true, nil))

	task.Do("banana")
}

gomock.NewController:返回 gomock.Controller,它代表 mock 生態系統中的頂級控制元件。定義了 mock 物件的範圍、生命週期和期待值。多 goroutine 下是執行緒安全的。

NewMockTask() 建立一個新的 MockTask 例項,因為 MockTask 實現了 Task 介面所有後面實際是呼叫 MockTask 的實現方法。

gomock.InOrder(calls ...*Call):宣告呼叫 Call 的順序,這裡可以傳入多個 Call。

task.EXPECT().Do("banana").Return(true, nil)EXPECT() 是期望拿到返回值,Call 的方法呼叫類似於 Java 中的 Build 模式,鏈式呼叫。有如下方法可供使用:

  • Call.Do():宣告在匹配時要執行的操作
  • Call.DoAndReturn():宣告在匹配呼叫時要執行的操作,並且模擬返回該函* 數的返回值
  • Call.MaxTimes():設定最大的呼叫次數為 n 次
  • Call.MinTimes():設定最小的呼叫次數為 n 次
  • Call.AnyTimes():允許呼叫次數為 0 次或更多次
  • Call.Times():設定呼叫次數為 n 次

我們測試一下呼叫順序檢測,多個 Call 的情況:

package mockDemo

import (
	"github.com/golang/mock/gomock"
	"testing"
)

func TestMock(t *testing.T) {
	ctl := gomock.NewController(t)
	defer ctl.Finish()
	task := NewMockTask(ctl)

	call1 := task.EXPECT().Do("banana").Return(true, nil)
	call2 := task.EXPECT().Do("apple").Return(true, nil)
	call3 := task.EXPECT().Do("pineapple").Return(true, nil)

	gomock.InOrder(call1, call2, call3)

	task.Do("apple")
	task.Do("banana")
	task.Do("pineapple")
}

順序不一樣的情況下是會報錯的。

總結一下 mock 的使用:mock 是面向介面的測試,當你想測試的邏輯只是一段獨立功能性的程式碼而沒有提供介面去抽象化的時候你無法使用 mock 功能。當然不是說必須要面向介面開發,有介面的定義會更加規範化你的程式碼讓你知道寫出來的邏輯是審慎總結的。

相關文章