Go語言Mock使用基本指南詳解
當(dāng)前的實(shí)踐中問題
在項(xiàng)目之間依賴的時(shí)候我們往往可以通過mock一個(gè)接口的實(shí)現(xiàn),以一種比較簡潔、獨(dú)立的方式,來進(jìn)行測試。但是在mock使用的過程中,因?yàn)榇蠹业娘L(fēng)格不統(tǒng)一,而且很多使用minimal implement的方式來進(jìn)行mock,這就導(dǎo)致了通過mock出的實(shí)現(xiàn)各個(gè)函數(shù)的返回值往往是靜態(tài)的,就無法讓caller根據(jù)返回值進(jìn)行的一些復(fù)雜邏輯。
首先來舉一個(gè)例子
package task type Task interface { Do(int) (string, error) }
通過minimal implement的方式來進(jìn)行手動(dòng)的mock
package mock type MinimalTask struct { // filed } func NewMinimalTask() *MinimalTask { return &MinimalTask{} } func (mt *MinimalTask) Do(idx int) (string, error) { return "", nil }
在其他包使用Mock出的實(shí)現(xiàn)的過程中,就會(huì)給測試帶來一些問題。
舉個(gè)例子,假如我們有如下的接口定義與函數(shù)定義
package pool import "github.com/ultramesh/mock-example/task" type TaskPool interface { Run(times int) error } type NewTask func() task.Task
我們基于接口定義和接口構(gòu)造函數(shù)定義,封裝了一個(gè)實(shí)現(xiàn)
package pool import ( "fmt" "github.com/pkg/errors" "github.com/ultramesh/mock-example/task" ) type TaskPoolImpl struct { pool []task.Task } func NewTaskPoolImpl(newTask NewTask, size int) *TaskPoolImpl { tp := &TaskPoolImpl{ pool: make([]task.Task, size), } for i := 0; i < size; i++ { tp.pool[i] = newTask() } return tp } func (tp *TaskPoolImpl) Run(times int) error { poolLen := len(tp.pool) for i := 0; i < times; i++ { ret, err := tp.pool[i%poolLen].Do(i) if err != nil { // process error return errors.Wrap(err, fmt.Sprintf("error while run task %d", i%poolLen)) } switch ret { case "": // process 0 fmt.Println(ret) case "a": // process 1 fmt.Println(ret) case "b": // process 2 fmt.Println(ret) case "c": // process 3 fmt.Println(ret) } } return nil }
接著我們來寫測試的話應(yīng)該是下面
package pool import ( "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/ultramesh/mock-example/mock" "github.com/ultramesh/mock-example/task" "testing" ) type TestSuit struct { name string newTask NewTask size int times int } func TestTaskPoolRunImpl(t *testing.T) { testSuits := []TestSuit{ { nam e: "minimal task pool", newTask: func() task.Task { return mock.NewMinimalTask() }, size: 100, times: 200, }, } for _, suit := range testSuits { t.Run(suit.name, func(t *testing.T) { var taskPool TaskPool = NewTaskPoolImpl(suit.newTask, suit.size) err := taskPool.Run(suit.size) assert.NoError(t, err) }) } }
這樣通過go test自帶的覆蓋率測試我們能看到TaskPoolImpl實(shí)際被測試到的路徑為
可以看到的手動(dòng)實(shí)現(xiàn)MinimalTask的問題在于,由于對(duì)于caller來說,callee的返回值是不可控的,我們只能覆蓋到由MinimalTask所定死的返回值的路徑,此外mock在我們的實(shí)踐中往往由被依賴的項(xiàng)目來操作,他不知道caller怎樣根據(jù)返回值進(jìn)行處理,沒有辦法封裝出一個(gè)簡單、夠用的最小實(shí)現(xiàn)供接口測試使用,因此我們需要改進(jìn)我們mock策略,使用golang官方的mock工具——gomock來進(jìn)行更好地接口測試。
gomock實(shí)踐
我們使用golang官方的mock工具的優(yōu)勢在于
- 我們可以基于工具生成的mock代碼,我們可以用一種更精簡的方式,封裝出一個(gè)minimal implement,完成和手工實(shí)現(xiàn)一個(gè)minimal implement一樣的效果。
- 可以允許caller自己靈活地、有選擇地控制自己需要用到的那些接口方法的入?yún)⒁约俺鰠ⅰ?/li>
還是上面TaskPool的例子,我們現(xiàn)在使用gomock提供的工具來自動(dòng)生成一個(gè)mock Task
mockgen -destination mock/mock_task.go -package mock -source task/interface.go
在mock包中生成一個(gè)mock_task.go來實(shí)現(xiàn)接口Task
首先基于mock_task.go,我們可以實(shí)現(xiàn)一個(gè)MockMinimalTask用于最簡單的測試
package mock import "github.com/golang/mock/gomock" func NewMockMinimalTask(ctrl *gomock.Controller) *MockTask { mock := NewMockTask(ctrl) mock.EXPECT().Do().Return("", nil).AnyTimes() return mock }
于是這樣我們就可以實(shí)現(xiàn)一個(gè)MockMinimalTask用來做一些測試
package pool import ( "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/ultramesh/mock-example/mock" "github.com/ultramesh/mock-example/task" "testing" ) type TestSuit struct { name string newTask NewTask size int times int } func TestTaskPoolRunImpl(t *testing.T) { testSuits := []TestSuit{ //{ // name: "minimal task pool", // newTask: func() task.Task { return mock.NewMinimalTask() }, // size: 100, // times: 200, //}, { name: "mock minimal task pool", newTask: func() task.Task { return mock.NewMockMinimalTask(ctrl) }, size: 100, times: 200, }, } for _, suit := range testSuits { t.Run(suit.name, func(t *testing.T) { var taskPool TaskPool = NewTaskPoolImpl(suit.newTask, suit.size) err := taskPool.Run(suit.size) assert.NoError(t, err) }) } }
我們使用這個(gè)新的測試文件進(jìn)行覆蓋率測試
可以看到測試結(jié)果是一樣的,那當(dāng)我們想要達(dá)到更高的測試覆蓋率的時(shí)候應(yīng)該怎么辦呢?我們進(jìn)一步修改測試
package pool import ( "errors" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/ultramesh/mock-example/mock" "github.com/ultramesh/mock-example/task" "testing" ) type TestSuit struct { name string newTask NewTask size int times int isErr bool } func TestTaskPoolRunImpl_MinimalTask(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() testSuits := []TestSuit{ //{ // name: "minimal task pool", // newTask: func() task.Task { return mock.NewMinimalTask() }, // size: 100, // times: 200, //}, { name: "mock minimal task pool", newTask: func() task.Task { return mock.NewMockMinimalTask(ctrl) }, size: 100, times: 200, }, { name: "return err", newTask: func() task.Task { mockTask := mock.NewMockTask(ctrl) // 加入了返回錯(cuò)誤的邏輯 mockTask.EXPECT().Do(gomock.Any()).Return("", errors.New("return err")).AnyTimes() return mockTask }, size: 100, times: 200, isErr: true, }, } for _, suit := range testSuits { t.Run(suit.name, func(t *testing.T) { var taskPool TaskPool = NewTaskPoolImpl(suit.newTask, suit.size) err := taskPool.Run(suit.size) if suit.isErr { assert.Error(t, err) } else { assert.NoError(t, err) } }) } }
這樣我們就能夠覆蓋到error的處理邏輯
甚至我們可以更trick的方式來將所有語句都覆蓋到,代碼中的testSuits改成下面這樣
package pool import ( "errors" "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/ultramesh/mock-example/mock" "github.com/ultramesh/mock-example/task" "testing" ) type TestSuit struct { name string newTask NewTask size int times int isErr bool } func TestTaskPoolRunImpl_MinimalTask(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() strs := []string{"a", "b", "c"} count := 0 size := 3 rounds := 1 testSuits := []TestSuit{ //{ // name: "minimal task pool", // newTask: func() task.Task { return mock.NewMinimalTask() }, // size: 100, // times: 200, //}, { name: "mock minimal task pool", newTask: func() task.Task { return mock.NewMockMinimalTask(ctrl) }, size: 100, times: 200, }, { name: "return err", newTask: func() task.Task { mockTask := mock.NewMockTask(ctrl) mockTask.EXPECT().Do(gomock.Any()).Return("", errors.New("return err")).AnyTimes() return mockTask }, size: 100, times: 200, isErr: true, }, { name: "check input and output", newTask: func() task.Task { mockTask := mock.NewMockTask(ctrl) // 這里我們通過Do的設(shè)置檢查了mackTask.Do調(diào)用時(shí)候的入?yún)⒁约罢{(diào)用次數(shù) // 通過Return來設(shè)置發(fā)生調(diào)用時(shí)的返回值 mockTask.EXPECT().Do(count).Return(strs[count%3], nil).Times(rounds) count++ return mockTask }, size: size, times: size * rounds, isErr: false, }, } var taskPool TaskPool for _, suit := range testSuits { t.Run(suit.name, func(t *testing.T) { taskPool = NewTaskPoolImpl(suit.newTask, suit.size) err := taskPool.Run(suit.times) if suit.isErr { assert.Error(t, err) } else { assert.NoError(t, err) } }) } }
這樣我們就可以覆蓋到所有語句
思考Mock的意義
之前和一些同學(xué)討論過,我們?yōu)槭裁匆褂胢ock這個(gè)問題,發(fā)現(xiàn)很多同學(xué)的覺得寫mock的是約定好接口,然后在面向接口做開發(fā)的時(shí)候能夠方便測試,因?yàn)椴恍枰涌趯?shí)際的實(shí)現(xiàn),而是依賴mock的Minimal Implement就可以進(jìn)行單元測試。我認(rèn)為這是對(duì)的,但是同時(shí)也覺得mock的意義不僅僅是如此。
在我看來,面向接口開發(fā)的實(shí)踐中,你應(yīng)該時(shí)刻對(duì)接口的輸入和輸出保持敏感,更進(jìn)一步的說,在進(jìn)行單元測試的時(shí)候,你需要知道在給定的用例、輸入下,你的包會(huì)對(duì)起使用的接口方法輸入什么,調(diào)用幾次,然后返回值可能是什么,什么樣的返回值對(duì)你有影響,如果你對(duì)這些不了解,那么我覺得或者你應(yīng)該去做更多地嘗試和了解,這樣才能盡可能通過mock設(shè)計(jì)出更多的單測用例,做更多且謹(jǐn)慎的檢查,提高測試代碼的覆蓋率,確保模塊功能的完備性。
Mock與設(shè)計(jì)模式
mock與單例
客觀來講,借助go語言官方提供的同步原語sync.Once,實(shí)現(xiàn)單例、使用單例是很容易的事情。在使用單例實(shí)現(xiàn)的過程中,單例的調(diào)用者往往邏輯中依賴提供的get方法在需要的時(shí)候獲取單例,而不會(huì)在自身的數(shù)據(jù)結(jié)構(gòu)中保存單例的句柄,這也就導(dǎo)致我們很難類比前面介紹的case,使用mock進(jìn)行單元測試,因?yàn)閏aller沒有辦法控制通過get方法獲取的單例。
既然是因?yàn)闆]有辦法更改單例返回,那么解決這個(gè)問題最簡單的方式就是我們就應(yīng)改提供一個(gè)set方法來設(shè)置更改單例。假設(shè)我們需要基于上面的case實(shí)現(xiàn)一個(gè)單例的TaskPool。假設(shè)我們定義了PoolImpl
實(shí)現(xiàn)了Pool的接口,在創(chuàng)建單例的時(shí)候我們可能是這么做的(為了方便說明,這里我們用最早手工寫的基于MinimalTask
來寫TaskPool
的單例)
package pool import ( "github.com/ultramesh/mock-example/mock" "github.com/ultramesh/mock-example/task" "sync" ) var once sync.Once var p TaskPool func GetTaskPool() TaskPool{ once.Do(func(){ p = NewTaskPoolImpl(func() task.Task {return mock.NewMinimalTask()},10) }) return p }
這個(gè)時(shí)候問題就來了,假設(shè)某個(gè)依賴于TaskPool的模塊中有這么一段邏輯
package runner import ( "fmt" "github.com/pkg/errors" "github.com/ultramesh/mock-example/pool" ) func Run(times int) error { // do something fmt.Println("do something") // call pool p := pool.GetTaskPool() err := p.Run(times) if err != nil { return errors.Wrap(err, "task pool run error") } // do something fmt.Println("do something") return nil }
那么這個(gè)Run函數(shù)的單測應(yīng)該怎么寫呢?這里的例子還比較簡單,要是TaskPool的實(shí)現(xiàn)還要依賴一些外部配置文件,實(shí)際情形就會(huì)更加復(fù)雜,當(dāng)然我們在這里不討論這個(gè)情況,就是舉一個(gè)簡單的例子。在這種情況下,如果單例僅僅只提供了get方法的話是很難進(jìn)行解耦測試的,如果使用GetTaskPool勢必會(huì)給測試引入不必要的復(fù)雜性,我們還需要提供一個(gè)單例的實(shí)現(xiàn)者提供一個(gè)set方法來解決單元測試解耦的問題。將單例的實(shí)現(xiàn)改成下面這樣,對(duì)外暴露一個(gè)單例的set方法,那么我們就可以通過set方法來進(jìn)行mock。
import ( "github.com/ultramesh/mock-example/mock" "github.com/ultramesh/mock-example/task" "sync" ) var once sync.Once var p TaskPool func SetTaskPool(tp TaskPool) { p = tp } func GetTaskPool() TaskPool { once.Do(func(){ if p != nil { p = NewTaskPoolImpl(func() task.Task {return mock.NewMinimalTask()},10) } }) return p }
使用mockgen生成一個(gè)MockTaskPool實(shí)現(xiàn)
mockgen -destination mock/mock_task_pool.go -package mock -source pool/interface.go
類似的,基于前面介紹的思想我們基于自動(dòng)生成的代碼實(shí)現(xiàn)一個(gè)MockMinimalTaskPool
package mock import "github.com/golang/mock/gomock" func NewMockMinimalTaskPool(ctrl *gomock.Controller) *MockTaskPool { mock := NewMockTaskPool(ctrl) mock.EXPECT().Run(gomock.Any()).Return(nil).AnyTimes() return mock }
基于MockMinimalTaskPool和單例暴露出的set方法,我們就可以將TaskPool實(shí)現(xiàn)的邏輯拆除,在單測中只測試自己的代碼
package runner import ( "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" "github.com/ultramesh/mock-example/mock" "github.com/ultramesh/mock-example/pool" "testing" ) func TestRun(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() p := mock.NewMockMinimalTaskPool(ctrl) pool.SetTaskPool(p) err := Run(100) assert.NoError(t, err) }
到此這篇關(guān)于Go語言Mock使用基本指南詳解的文章就介紹到這了,更多相關(guān)Go語言Mock使用內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
golang gorm 結(jié)構(gòu)體的表字段缺省值設(shè)置方式
這篇文章主要介紹了golang gorm 結(jié)構(gòu)體的表字段缺省值設(shè)置方式,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2020-12-12golang DNS服務(wù)器的簡單實(shí)現(xiàn)操作
這篇文章主要介紹了golang DNS服務(wù)器的簡單實(shí)現(xiàn)操作,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過來看看吧2021-04-04Go實(shí)現(xiàn)分布式唯一ID的生成之雪花算法
本文主要介紹了Go實(shí)現(xiàn)分布式唯一ID的生成之雪花算法,文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2022-05-05goland中使用leetcode插件實(shí)現(xiàn)
本文主要介紹了goland中使用leetcode插件實(shí)現(xiàn),文中通過示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2023-04-04go panic時(shí)如何讓函數(shù)返回?cái)?shù)據(jù)?
今天小編就為大家分享一篇關(guān)于go panic時(shí)如何讓函數(shù)返回?cái)?shù)據(jù)?,小編覺得內(nèi)容挺不錯(cuò)的,現(xiàn)在分享給大家,具有很好的參考價(jià)值,需要的朋友一起跟隨小編來看看吧2019-04-04Golang通道阻塞情況與通道無阻塞實(shí)現(xiàn)小結(jié)
本文主要介紹了Golang通道阻塞情況與通道無阻塞實(shí)現(xiàn)小結(jié),詳細(xì)解析了通道的類型、操作方法以及垃圾回收機(jī)制,從基礎(chǔ)概念到高級(jí)應(yīng)用,具有一定的參考價(jià)值,感興趣的可以了解一下2024-03-03golang連接池檢查連接失敗時(shí)如何重試(示例代碼)
在Go中,可以通過使用database/sql包的DB類型的Ping方法來檢查數(shù)據(jù)庫連接的可用性,本文通過示例代碼,演示了如何在連接檢查失敗時(shí)進(jìn)行重試,感興趣的朋友一起看看吧2023-10-10