序言
要寫出好的測試代碼,必須精通相關的測試框架。對於Golang的程序員來說,至少需要掌握下面四個測試框架:
- GoConvey
- GoStub
- GoMock
- Monkey
讀者通過前面三篇文章的學習可以對框架GoConvey和GoStub優雅的組合使用了,本文將接着介紹第三個框架GoMock的使用方法,目的是使得讀者掌握框架GoConvey + GoStub + GoMock組合使用的正確姿勢,從而提高測試代碼的質量。
GoMock是由Golang官方開發維護的測試框架,實現了較為完整的基於interface的Mock功能,能夠與Golang內置的testing包良好集成,也能用於其它的測試環境中。GoMock測試框架包含了GoMock包和mockgen工具兩部分,其中GoMock包完成對樁對象生命周期的管理,mockgen工具用來生成interface對應的Mock類源文件。
安裝
在命令行運行命令:
go get github.com/golang/mock/gomock
運行完后你會發現,在$GOPATH/src目錄下有了github.com/golang/mock子目錄,且在該子目錄下有GoMock包和mockgen工具。
繼續運行命令:
cd $GOPATH/src/github.com/golang/mock/mockgen go build
則在當前目錄下生成了一個可執行程序mockgen。
將mockgen程序移動到$GOPATH/bin目錄下:
mv mockgen $GOPATH/bin
這時在命令行運行mockgen,如果列出了mockgen的使用方法和例子,則說明mockgen已經安裝成功,否則會顯示:
-bash: mockgen: command not found
一般是由於沒有在環境變量PATH中配置$GOPATH/bin導致。
文檔
GoMock框架安裝完成后,可以使用go doc命令來獲取文檔:
go doc github.com/golang/mock/gomock
另外,有一個在線的參考文檔,即package gomock。
使用方法
定義一個接口
我們先定義一個打算mock的接口Repository:
package db type Repository interface { Create(key string, value []byte) error Retrieve(key string) ([]byte, error) Update(key string, value []byte) error Delete(key string) error }
Repository是領域驅動設計中戰術設計的一個元素,用來存儲領域對象,一般將對象持久化在數據庫中,比如Aerospike,Redis或Etcd等。對於領域層來說,只知道對象在Repository中維護,並不care對象到底在哪持久化,這是基礎設施層的職責。微服務在啟動時,根據部署參數實例化Repository接口,比如AerospikeRepository,RedisRepository或EtcdRepository。
假設有一個領域對象Movie要進行持久化,則先要通過json.Marshal進行序列化,然后再調用Repository的Create方法來存儲。當要根據key(實體Id)查找領域對象時,則先通過Repository的Retrieve方法獲得領域對象的字節切片,然后通過json.Unmarshal進行反序列化的到領域對象。當領域對象的數據有變化時,則先要通過json.Marshal進行序列化,然后再調用Repository的Update方法來更新。當領域對象生命周期結束而要消亡時,則直接調用Repository的Delete方法進行刪除。
生成mock類文件
這下該mockgen工具登場了。mockgen有兩種操作模式:源文件和反射。
源文件模式通過一個包含interface定義的文件生成mock類文件,它通過 -source 標識生效,-imports 和 -aux_files 標識在這種模式下也是有用的。
舉例:
mockgen -source=foo.go [other options]
反射模式通過構建一個程序用反射理解接口生成一個mock類文件,它通過兩個非標志參數生效:導入路徑和用逗號分隔的符號列表(多個interface)。
舉例:
mockgen database/sql/driver Conn,Driver
注意:第一個參數是基於GOPATH的相對路徑,第二個參數可以為多個interface,並且interface之間只能用逗號分隔,不能有空格。
有一個包含打算Mock的interface的源文件,就可用mockgen命令生成一個mock類的源文件。mockgen支持的選項如下:
- -source: 一個文件包含打算mock的接口列表
- -destination: 存放mock類代碼的文件。如果你沒有設置這個選項,代碼將被打印到標准輸出
- -package: 用於指定mock類源文件的包名。如果你沒有設置這個選項,則包名由mock_和輸入文件的包名級聯而成
- -aux_files: 參看附加的文件列表是為了解析類似嵌套的定義在不同文件中的interface。指定元素列表以逗號分隔,元素形式為foo=bar/baz.go,其中bar/baz.go是源文件,foo是-source選項指定的源文件用到的包名
在簡單的場景下,你將只需使用-source選項。在復雜的情況下,比如一個文件定義了多個interface而你只想對部分interface進行mock,或者interface存在嵌套,這時你需要用反射模式。由於 -destination 選項輸入太長,筆者一般不使用該標識符,而使用重定向符號 >,並且mock類代碼的輸出文件的路徑必須是絕對路徑。
現在我們運行mockgen命令通過反射模式生成Repository的Mock類源文件:
mockgen infra/db Repository > $GOPATH/src/test/mock/db/mock_repository.go
注意:
- 輸出目錄test/mock/db必須提前建好,否則mockgen會運行失敗
- 如果你的工程中的第三方庫統一放在vendor目錄下,則需要拷貝一份gomock的代碼到$GOPATH/src下,gomock的代碼即github.com/golang/mock/gomock,這是因為mockgen命令運行時要在這個路徑訪問gomock
可以在test/mock/db目錄下看到mock_repository.go文件已經生成,該文件的代碼片段如下:
// Automatically generated by MockGen. DO NOT EDIT! // Source: infra/db (interfaces: Repository) package mock_db import ( gomock "github.com/golang/mock/gomock" ) // MockRepository is a mock of Repository interface type MockRepository struct { ctrl *gomock.Controller recorder *MockRepositoryMockRecorder } // MockRepositoryMockRecorder is the mock recorder for MockRepository type MockRepositoryMockRecorder struct { mock *MockRepository } // NewMockRepository creates a new mock instance func NewMockRepository(ctrl *gomock.Controller) *MockRepository { mock := &MockRepository{ctrl: ctrl} mock.recorder = &MockRepositoryMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use func (_m *MockRepository) EXPECT() *MockRepositoryMockRecorder { return _m.recorder } // Create mocks base method func (_m *MockRepository) Create(_param0 string, _param1 []byte) error { ret := _m.ctrl.Call(_m, "Create", _param0, _param1) ret0, _ := ret[0].(error) return ret0 } // Create indicates an expected call of Create func (_mr *MockRepositoryMockRecorder) Create(arg0, arg1 interface{}) *gomock.Call { return _mr.mock.ctrl.RecordCall(_mr.mock, "Create", arg0, arg1) } ...
使用mock對象進行打樁測試
mock類源文件生成后,就可以寫測試用例了。
導入mock相關的包
mock相關的包包括testing,gmock和mock_db,import包路徑:
import ( "testing" . "github.com/golang/mock/gomock" "test/mock/db" ... )
mock控制器
mock控制器通過NewController接口生成,是mock生態系統的頂層控制,它定義了mock對象的作用域和生命周期,以及它們的期望。多個協程同時調用控制器的方法是安全的。
當用例結束后,控制器會檢查所有剩余期望的調用是否滿足條件。
控制器的代碼如下所示:
ctrl := NewController(t) defer ctrl.Finish()
mock對象創建時需要注入控制器,如果有多個mock對象則注入同一個控制器,如下所示:
ctrl := NewController(t) defer ctrl.Finish() mockRepo := mock_db.NewMockRepository(ctrl) mockHttp := mock_api.NewHttpMethod(ctrl)
mock對象的行為注入
對於mock對象的行為注入,控制器是通過map來維護的,一個方法對應map的一項。因為一個方法在一個用例中可能調用多次,所以map的值類型是數組切片。當mock對象進行行為注入時,控制器會將行為Add。當該方法被調用時,控制器會將該行為Remove。
假設有這樣一個場景:先Retrieve領域對象失敗,然后Create領域對象成功,再次Retrieve領域對象就能成功。這個場景對應的mock對象的行為注入代碼如下所示:
mockRepo.EXPECT().Retrieve(Any()).Return(nil, ErrAny) mockRepo.EXPECT().Create(Any(), Any()).Return(nil) mockRepo.EXPECT().Retrieve(Any()).Return(objBytes, nil)
objBytes是領域對象的序列化結果,比如:
obj := Movie{...} objBytes, err := json.Marshal(obj) ...
當批量Create對象時,可以使用Times關鍵字:
mockRepo.EXPECT().Create(Any(), Any()).Return(nil).Times(5)
當批量Retrieve對象時,需要注入多次mock行為:
mockRepo.EXPECT().Retrieve(Any()).Return(objBytes1, nil) mockRepo.EXPECT().Retrieve(Any()).Return(objBytes2, nil) mockRepo.EXPECT().Retrieve(Any()).Return(objBytes3, nil) mockRepo.EXPECT().Retrieve(Any()).Return(objBytes4, nil) mockRepo.EXPECT().Retrieve(Any()).Return(objBytes5, nil)
行為調用的保序
默認情況下,行為調用順序可以和mock對象行為注入順序不一致,即不保序。如果要保序,有兩種方法:
- 通過After關鍵字來實現保序
- 通過InOrder關鍵字來實現保序
通過After關鍵字實現的保序示例代碼:
retrieveCall := mockRepo.EXPECT().Retrieve(Any()).Return(nil, ErrAny) createCall := mockRepo.EXPECT().Create(Any(), Any()).Return(nil).After(retrieveCall) mockRepo.EXPECT().Retrieve(Any()).Return(objBytes, nil).After(createCall)
通過InOrder關鍵字實現的保序示例代碼:
InOrder( mockRepo.EXPECT().Retrieve(Any()).Return(nil, ErrAny) mockRepo.EXPECT().Create(Any(), Any()).Return(nil) mockRepo.EXPECT().Retrieve(Any()).Return(objBytes, nil) )
可見,通過InOrder關鍵字實現的保序更簡單自然,所以推薦這種方式。其實,關鍵字InOrder是After的語法糖,源碼如下:
// InOrder declares that the given calls should occur in order. func InOrder(calls ...*Call) { for i := 1; i < len(calls); i++ { calls[i].After(calls[i-1]) } }
當mock對象行為的注入保序后,如果行為調用的順序和其不一致,就會觸發測試失敗。這就是說,對於上面的例子,如果在測試用例執行過程中,Repository的方法的調用順序如果不是按 Retrieve -> Create -> Retrieve 的順序進行,則會導致測試失敗。
mock對象的注入
mock對象的行為都注入到控制器以后,我們接着要將mock對象注入給interface,使得mock對象在測試中生效。
在使用GoStub框架之前,很多人都使用土方法,比如Set。這種方法有一個缺陷:當測試用例執行完成后,並沒有回滾interface到真實對象,有可能會影響其它測試用例的執行。所以,筆者強烈建議大家使用GoStub框架完成mock對象的注入。
stubs := StubFunc(&redisrepo.GetInstance, mockDb) defer stubs.Reset()
測試Demo
編寫測試用例有一些基本原則,我們一起回顧一下:
- 每個測試用例只關注一個問題,不要寫大而全的測試用例
- 測試用例是黑盒的
- 測試用例之間彼此獨立,每個用例要保證自己的前置和后置完備
- 測試用例要對產品代碼非入侵
- ...
根據基本原則,我們不要在一個測試函數的多個測試用例之間共享mock控制器,於是就有了下面的Demo:
func TestObjDemo(t *testing.T) { Convey("test obj demo", t, func() { Convey("create obj", func() { ctrl := NewController(t) defer ctrl.Finish() mockRepo := mock_db.NewMockRepository(ctrl) mockRepo.EXPECT().Retrieve(Any()).Return(nil, ErrAny) mockRepo.EXPECT().Create(Any(), Any()).Return(nil) mockRepo.EXPECT().Retrieve(Any()).Return(objBytes, nil) stubs := StubFunc(&redisrepo.GetInstance, mockRepo) defer stubs.Reset() ... }) Convey("bulk create objs", func() { ctrl := NewController(t) defer ctrl.Finish() mockRepo := mock_db.NewMockRepository(ctrl) mockRepo.EXPECT().Create(Any(), Any()).Return(nil).Times(5) stubs := StubFunc(&redisrepo.GetInstance, mockRepo) defer stubs.Reset() ... }) Convey("bulk retrieve objs", func() { ctrl := NewController(t) defer ctrl.Finish() mockRepo := mock_db.NewMockRepository(ctrl) objBytes1 := ... objBytes2 := ... objBytes3 := ... objBytes4 := ... objBytes5 := ... mockRepo.EXPECT().Retrieve(Any()).Return(objBytes1, nil) mockRepo.EXPECT().Retrieve(Any()).Return(objBytes2, nil) mockRepo.EXPECT().Retrieve(Any(