golang單元測試之mock
golang單元測試之mock
序言
前面介紹了golang的一般單元測試,以及如何使用vscode進行高效的go單元測試開發。同時也說過一般單元測試重點在於cpu和記憶體型別的測試,而對io型別的測試是比較敏感的。那麼針對這類測試就沒法做單元測試了嗎?有的,肯定是有的,這就是mock技術。
mock測試不但可以支援io型別的測試,比如:資料庫,網路API請求,檔案訪問等。mock測試還可以做為未開發服務的模擬、服務壓力測試支援、對未知複雜的服務進行模擬,比如開發階段我們依賴的服務還沒有開發好,那麼就可以使用mock方法來模擬一個服務,模擬的這個服務接收的引數和返回的引數和規劃設計的服務是一致的,那我們就可以直接使用這個模擬的服務來協助開發測試了;再比如要對服務進行壓力測試,這個時候我們就要把服務依賴的網路,資料等服務進行模擬,不然得到的結果不純粹。總結一下,有以下幾種情況下使用mock會比較好:
1. IO型別的,本地檔案,資料庫,網路API,RPC等
2. 依賴的服務還沒有開發好,這時候我們自己可以模擬一個服務,加快開發進度提升開發效率
3. 壓力效能測試的時候遮蔽外部依賴,專注測試本模組
4. 依賴的內部函式非常複雜,要構造資料非常不方便,這也是一種
mock測試,簡單來說就是通過對服務或者函式傳送設計好的引數,並且通過構造注入期望返回的資料來方便以上幾種測試開發。
一般情況自己寫mock服務是比較費事的事情,而且如果風格不統一,那麼後期的管理維護將是軟體開發的一個巨大坑,是開發給自己挖的一個坑。所以在就有了很多mock測試框架的出現,框架的出現首先提升了編寫mock測試服務的效率,而且編寫風格得到了比較好的統一。c/c++也有很多mock框架,Google Mock就是一個比較經典了,java也有很多mock框架,這裡就不列舉了,今天我們要介紹的是針對golang的mock測試框架。
GoMock是由Golang官方開發維護的測試框架,實現了較為完整的基於interface的Mock功能,能夠與Golang內建的testing包良好整合,也能用於其它的測試環境中。GoMock測試框架包含了GoMock包和mockgen工具兩部分,其中GoMock包完成對樁物件生命週期的管理,mockgen工具用來生成interface對應的Mock類原始檔。
安裝
GoMock官網:
https://github.com/golang/mock
GoMock安裝:
go get github.com/golang/mock/gomock
mockgen程式碼生成工具安裝:
go get github.com/golang/mock/mockgen
安裝好之後,在$GOPATH/src目錄下有了github.com/golang/mock子目錄,且在該子目錄下有GoMock包和mockgen工具。
cd $GOPATH/src/github.com/golang/mock/mockgen go build
編譯後在這個目錄下會生成了一個可執行程式mockgen。將mockgen程式移動到$PATH可以找到的目錄中:
下面我是在window下的路徑,使用了git的shell環境,可以直接看PATH,找到合適的或者新加入進去都ok。
echo $PATH ..... cp mockgen.exe C:\Users\helightxu\go\bin\
安裝之後就可以在命令列直接運行了:mockgen
$ mockgen mockgen has two modes of operation: source and reflect. Source mode generates mock interfaces from a source file. It is enabled by using the -source flag. Other flags that may be useful in this mode are -imports and -aux_files. Example: mockgen -source=foo.go [other options] ......
這裡暫時先不細說。
GoMock文件:
GoMock框架安裝完成後,可以使用go doc命令來獲取文件:
go doc github.com/golang/mock/gomock
這個檔案比較簡短,但給出了核心的使用說明。
在GoPkgDoc上也有一個 網頁版的文件
使用方式介紹
mockgen模式介紹
mockgen有兩種操作模式:source和reflect。
Source模式下會從原始檔產生mock的interfaces檔案。 使用-source引數即可。和這個模式配套使用的引數常有-imports和-aux_files。
mockgen -source=foo.go [other options]
Reflect模式是通過反射的方式來生成mock interfaces。它只需要兩個非標誌性引數:import路徑和需要mock的interface列表,列表使用逗號分割。
例如:
mockgen database/sql/driver Conn,Driver
基本引數介紹
mockgen命令可以把一個包含要Mock的interface的原始檔生成一個mock類的原始檔。mockgen支援的引數有以下幾種:
- -source: 需要mock的檔案,這個檔案中有需要mock的介面
- -destination: 生成mock程式碼的檔名。如果你沒有設定,生成的程式碼會被列印到標準輸出
- -package: 指定生成的mock檔案的包名。如果你沒有設定,則包名由mock_和輸入檔案的包名級拼接而成
- -imports: 生成程式碼中需要import的包名,形式如foo=bar/baz,並且用逗號分隔。bar/baz是要import的包,foo這個生成的原始檔中包的標識。
- -aux_files: 參看附加的檔案列表是為了解析類似巢狀的定義在不同檔案中的interface。指定元素列表以逗號分隔,元素形式為* foo=bar/baz.go,其中bar/baz.go是原始檔,foo是-source選項指定的原始檔用到的包名
- -build_flags: 這個引數只在reflect模式下使用,用於go build的時候使用
- -imports: 依賴的需要import的包
- -mock_names:自定義生成mock檔案的列表,使用逗號分割。如Repository=MockSensorRepository,Endpoint=MockSensorEndpoint。
Repository、Endpoint為介面,MockSensorRepository,MockSensorEndpoint為相應的mock檔案。
在簡單的場景下,你將只需使用-source選項。在複雜的情況下,比如一個檔案定義了多個interface而你只想對部分interface進行mock,或者interface存在巢狀,這時你需要用反射模式。由於 -destination 選項輸入太長,筆者一般不使用該識別符號,而使用重定向符號 >,並且mock類程式碼的輸出檔案的路徑必須是絕對路徑。
想了解更多的指令符,可參見 官方文件
mockgen工作模式適用場景
mockgen工作模式適用場景如下:
1. 對於簡單場景,只需使用-source選項。
2. 對於複雜場景,如一個原始檔定義了多個interface而只想對部分interface進行mock,或者interface存在巢狀,則需要使用反射模式。
測試示例
目錄結構
D:\CODE_DEV\SRC\GOMOCKDEMO │student.go │student_test.go │ └─mock mock_people.go
定義一個介面
我們先定義一個打算mock的介面Repository:
package gomockdemo type Ipeople interface { GetName() string SetName(string) string } func GetPeopleName(mi Ipeople) string { mi.GetName() return mi.GetName() } func SetPeopleName(mi Ipeople, name string) string { returnmi.SetName(name) }
生成mock類檔案
$ mockgen gomockdemo Ipeople > mock/mock_people.go
這裡需要注意幾點:
1. mock_people.go檔案的mock資料夾,必須先建立好,否則會失敗
2. go_mock一定在$GOPATH/src/的目錄下
生成後的檔案如下:
// Code generated by MockGen. DO NOT EDIT. // Source: gomockdemo (interfaces: Ipeople) // Package mock_gomockdemo is a generated GoMock package. package mock_gomockdemo import ( gomock "github.com/golang/mock/gomock" reflect "reflect" ) // MockIpeople is a mock of Ipeople interface type MockIpeople struct { ctrl*gomock.Controller recorder *MockIpeopleMockRecorder } // MockIpeopleMockRecorder is the mock recorder for MockIpeople type MockIpeopleMockRecorder struct { mock *MockIpeople } // NewMockIpeople creates a new mock instance func NewMockIpeople(ctrl *gomock.Controller) *MockIpeople { mock := &MockIpeople{ctrl: ctrl} mock.recorder = &MockIpeopleMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use func (m *MockIpeople) EXPECT() *MockIpeopleMockRecorder { return m.recorder } // GetName mocks base method func (m *MockIpeople) GetName() string { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetName") ret0, _ := ret[0].(string) return ret0 } // GetName indicates an expected call of GetName func (mr *MockIpeopleMockRecorder) GetName() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetName", reflect.TypeOf((*MockIpeople)(nil).GetName)) } // SetName mocks base method func (m *MockIpeople) SetName(arg0 string) string { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "SetName", arg0) ret0, _ := ret[0].(string) return ret0 } // SetName indicates an expected call of SetName func (mr *MockIpeopleMockRecorder) SetName(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetName", reflect.TypeOf((*MockIpeople)(nil).SetName), arg0) }
測試用例
編寫測試用例有一些基本原則,我們一起回顧一下:
- 每個測試用例只關注一個問題,不要寫大而全的測試用例
- 測試用例是黑盒的
- 測試用例之間彼此獨立,每個用例要保證自己的前置和後置完備
- 測試用例要對產品程式碼非入侵
package gomockdemo import ( "fmt" "testing" "github.com/golang/mock/gomock" "gomockdemo/mock" ) func TestGetPeopleName(t *testing.T) { mockCtl := gomock.NewController(t) defer mockCtl.Finish() // 構造mock類, mock_gomockdemo就是生成的mock程式碼,以包的形式存在 mockpeople := mock_gomockdemo.NewMockIpeople(mockCtl) //注入期望的返回值 mockpeople.EXPECT().GetName().Return("helight") mockpeople.EXPECT().GetName().Return("helight") mockedName := GetPeopleName(mockpeople) if "helight" != mockedName { t.Error("Get wrong name1: ", mockedName) } //指定輸入引數,返回指定結果 mockpeople.EXPECT().SetName(gomock.Eq("he")).Return("ok") //輸出參不做指定,但是指定返回結果 mockpeople.EXPECT().SetName(gomock.Any()).Do(func(format string) { fmt.Println("recv param2 :", format) }).Return("ok1") mockedSetName := SetPeopleName(mockpeople,"he") fmt.Println("mockedSetName: ", mockedSetName) if "ok" != mockedSetName{ t.Error("Set wrong name2: ", mockedSetName) } mockedSetName = SetPeopleName(mockpeople,"al222") fmt.Println("mockedSetName: ", mockedSetName) if "ok1" != mockedSetName{ t.Error("Set wrong name2: ", mockedSetName) } }
- gomock.NewController:返回gomock.Controller,它代表 mock 生態系統中的頂級控制元件。定義了 mock 物件的範圍、生命週期和* 期待值。另外它在多個 goroutine 中是安全的
- mockpeople := mock_gomockdemo.NewMockIpeople(mockCtl) // 構造mock例項, mock_gomockdemo就是生成的mock程式碼,以包的形式存在
- defer mockCtl.Finish() 關閉mock測試
- mockpeople.EXPECT().GetName().Return(“helight”) :EXPECT()是期望拿到返回值,呼叫的方法是GetName,設定的返回值是“helight”,呼叫函式也可以指定引數,比如下面的mockpeople.EXPECT().SetName(gomock.Eq(“he”)).Return(“ok”)
- mockedSetName := SetPeopleName(mockpeople,”he”),這裡SetPeopleName函式是呼叫people類的函式,這時候我們傳遞mockpeople給SetPeopleName就可以測試了,SetPeopleName內部呼叫的mockpeople和呼叫真實的people的方式是一模一樣的。
測試結果
$ go test -v === RUNTestGetPeopleName gomock.Any:is anything gomock.Eq:is equal to he gomock.Any:is anything mockedSetName:ok recv param2 : al222 mockedSetName:ok1 --- PASS: TestGetPeopleName (0.00s) PASS okgomockdemo0.485s
到此,我們的mock測試就算是ok了,可以再增加一些測試用例和測試值。
測試覆蓋率
這裡在介紹一下另外一個簡單的測試功能,測試覆蓋率的測試cover,只要在go test後面加上-cover就可以了,如下面的例子,這裡還加了一個引數-coverprofile=cover.out,這個引數是把覆蓋率測試資料匯出到cover.out這個檔案,然後我們可以使用圖形化的方式來看具體的測試覆蓋情況。
$ go test -v-cover -coverprofile=cover.out === RUNTestGetPeopleName gomock.Any:is anything gomock.Eq:is equal to he gomock.Any:is anything mockedSetName:ok recv param2 : al222 mockedSetName:ok1 --- PASS: TestGetPeopleName (0.00s) PASS coverage: 100.0% of statements okgomockdemo0.403s
執行下面這個工具就可以直接把覆蓋率以網頁的形式開啟來看了。
go tool cover -html=cover.out
gomock的其它用法
常用 mock 方法
呼叫方法
- Call.Do():宣告在匹配時要執行的操作
- Call.DoAndReturn():宣告在匹配呼叫時要執行的操作,並且模擬返回該函* 數的返回值
- Call.MaxTimes():設定最大的呼叫次數為 n 次
- Call.MinTimes():設定最小的呼叫次數為 n 次
- Call.AnyTimes():允許呼叫次數為 0 次或更多次
- Call.Times():設定呼叫次數為 n 次
這裡我展開分析了一下,其實gomock的實現上是這樣的,以這個為例:
mockpeople.EXPECT().GetName().Return("helight")
mockpeople的EXPECT方法如下:都在生成的mock檔案中。
// EXPECT returns an object that allows the caller to indicate expected use func (m *MockIpeople) EXPECT() *MockIpeopleMockRecorder { return m.recorder }
m.recorder是這個
// NewMockIpeople creates a new mock instance func NewMockIpeople(ctrl *gomock.Controller) *MockIpeople { mock := &MockIpeople{ctrl: ctrl} mock.recorder = &MockIpeopleMockRecorder{mock} return mock }
所以這裡呼叫的就是MockIpeopleMockRecorder的GetName方法。這個函式如下:
func (mr *MockIpeopleMockRecorder) GetName() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetName", reflect.TypeOf((*MockIpeople)(nil).GetName)) }
RecordCallWithMethodType是mock.ctrl的方法,這個方法是在gomock庫裡面的controller.go檔案中,在看一下這個函式的實現:
// RecordCallWithMethodType is called by a mock. It should not be called by user code. func (ctrl *Controller) RecordCallWithMethodType(receiver interface{}, method string, methodType reflect.Type, args ...interface{}) *Call { ctrl.T.Helper() call := newCall(ctrl.T, receiver, method, methodType, args...) ctrl.mu.Lock() defer ctrl.mu.Unlock() ctrl.expectedCalls.Add(call) return call }
這裡看到,其實就是在ctrl.expectedCalls中把新生成函式按照函式名,引數等生成一個call,在call.go中就有Return、Do、Times等函式。這裡看一些簡單的例子,有興趣的同學可以繼續深入看看call.go這個檔案。
// AnyTimes allows the expectation to be called 0 or more times func (c *Call) AnyTimes() *Call { c.minCalls, c.maxCalls = 0, 1e8 // close enough to infinity return c } // MinTimes requires the call to occur at least n times. If AnyTimes or MaxTimes have not been called, MinTimes also // sets the maximum number of calls to infinity. func (c *Call) MinTimes(n int) *Call { c.minCalls = n if c.maxCalls == 1 { c.maxCalls = 1e8 } return c } // MaxTimes limits the number of calls to n times. If AnyTimes or MinTimes have not been called, MaxTimes also // sets the minimum number of calls to 0. func (c *Call) MaxTimes(n int) *Call { c.maxCalls = n if c.minCalls == 1 { c.minCalls = 0 } return c }
引數匹配
- gomock.Any():匹配任意引數值
- gomock.Eq():指定引數
- gomock.Nil():返回nil
行為呼叫的保序
-
gomock.InOrder:宣告指定了呼叫順序
預設情況下,行為呼叫順序可以和mock物件行為注入順序不一致,即不保序。如果要保序,有兩種方法:
- 通過After關鍵字來實現保序
- 通過InOrder關鍵字來實現保序
// call := mockpeople.EXPECT().GetName().Return("helight1") // mockpeople.EXPECT().GetName().Return("helight").After(call) // gomock.InOrder( mockpeople.EXPECT().GetName().Return("helight") mockpeople.EXPECT().GetName().Return("helight") // )
建議更多的方法可參見 官方文件
參見 官方文件
總結
gomock是單元測試的升級,幫助我們可以把之前無法做單元測試又非常重要的模組能夠進行單元測試。gomock整體功能還是非常強大的,更多的功能可以在實際使用中不斷熟悉,另外也可以多看看官方文件和gomock的原始碼。
參考
- https://github.com/golang/mock
感覺有意思?來鼓勵一下!
