1. 程式人生 > 程式設計 >Go語言Mock使用基本指南詳解

Go語言Mock使用基本指南詳解

當前的實踐中問題

在專案之間依賴的時候我們往往可以通過mock一個介面的實現,以一種比較簡潔、獨立的方式,來進行測試。但是在mock使用的過程中,因為大家的風格不統一,而且很多使用minimal implement的方式來進行mock,這就導致了通過mock出的實現各個函式的返回值往往是靜態的,就無法讓caller根據返回值進行的一些複雜邏輯。

首先來舉一個例子

package task

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

通過minimal implement的方式來進行手動的mock

package mock

type MinimalTask struct {
 // filed
}

func NewMinimalTask() *MinimalTask {
 return &MinimalTask{}
}

func (mt *MinimalTask) Do(idx int) (string,error) {
 return "",nil
}

在其他包使用Mock出的實現的過程中,就會給測試帶來一些問題。

舉個例子,假如我們有如下的介面定義與函式定義

package pool

import "github.com/ultramesh/mock-example/task"

type TaskPool interface {
 Run(times int) error
}

type NewTask func() task.Task

我們基於介面定義和介面建構函式定義,封裝了一個實現

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
}

接著我們來寫測試的話應該是下面

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實際被測試到的路徑為

Go語言Mock使用基本指南詳解

可以看到的手動實現MinimalTask的問題在於,由於對於caller來說,callee的返回值是不可控的,我們只能覆蓋到由MinimalTask所定死的返回值的路徑,此外mock在我們的實踐中往往由被依賴的專案來操作,他不知道caller怎樣根據返回值進行處理,沒有辦法封裝出一個簡單、夠用的最小實現供介面測試使用,因此我們需要改進我們mock策略,使用golang官方的mock工具——gomock來進行更好地介面測試。

gomock實踐

我們使用golang官方的mock工具的優勢在於

  • 我們可以基於工具生成的mock程式碼,我們可以用一種更精簡的方式,封裝出一個minimal implement,完成和手工實現一個minimal implement一樣的效果。
  • 可以允許caller自己靈活地、有選擇地控制自己需要用到的那些介面方法的入參以及出參。

還是上面TaskPool的例子,我們現在使用gomock提供的工具來自動生成一個mock Task

mockgen -destination mock/mock_task.go -package mock -source task/interface.go

在mock包中生成一個mock_task.go來實現介面Task

首先基於mock_task.go,我們可以實現一個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
}

於是這樣我們就可以實現一個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) },err)
  })
 }
}

我們使用這個新的測試檔案進行覆蓋率測試

Go語言Mock使用基本指南詳解

可以看到測試結果是一樣的,那當我們想要達到更高的測試覆蓋率的時候應該怎麼辦呢?我們進一步修改測試

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",{
   name: "return err",newTask: func() task.Task {
    mockTask := mock.NewMockTask(ctrl)
  // 加入了返回錯誤的邏輯
    mockTask.EXPECT().Do(gomock.Any()).Return("",errors.New("return err")).AnyTimes()
    return mockTask
   },isErr: true,suit.size)
   err := taskPool.Run(suit.size)
   if suit.isErr {
    assert.Error(t,err)
   } else {
    assert.NoError(t,err)
   }
  })
 }
}

這樣我們就能夠覆蓋到error的處理邏輯

Go語言Mock使用基本指南詳解

甚至我們可以更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 {
    mockTask := mock.NewMockTask(ctrl)
    mockTask.EXPECT().Do(gomock.Any()).Return("",{
   name: "check input and output",newTask: func() task.Task {
    mockTask := mock.NewMockTask(ctrl)
  // 這裡我們通過Do的設定檢查了mackTask.Do呼叫時候的入參以及呼叫次數
  // 通過Return來設定發生呼叫時的返回值
    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 _,func(t *testing.T) {
   taskPool = NewTaskPoolImpl(suit.newTask,suit.size)
   err := taskPool.Run(suit.times)
   if suit.isErr {
    assert.Error(t,err)
   }

  })
 }
}

這樣我們就可以覆蓋到所有語句

Go語言Mock使用基本指南詳解

思考Mock的意義

之前和一些同學討論過,我們為什麼要使用mock這個問題,發現很多同學的覺得寫mock的是約定好介面,然後在面向介面做開發的時候能夠方便測試,因為不需要介面實際的實現,而是依賴mock的Minimal Implement就可以進行單元測試。我認為這是對的,但是同時也覺得mock的意義不僅僅是如此。

在我看來,面向介面開發的實踐中,你應該時刻對介面的輸入和輸出保持敏感,更進一步的說,在進行單元測試的時候,你需要知道在給定的用例、輸入下,你的包會對起使用的介面方法輸入什麼,呼叫幾次,然後返回值可能是什麼,什麼樣的返回值對你有影響,如果你對這些不瞭解,那麼我覺得或者你應該去做更多地嘗試和了解,這樣才能儘可能通過mock設計出更多的單測用例,做更多且謹慎的檢查,提高測試程式碼的覆蓋率,確保模組功能的完備性。

Go語言Mock使用基本指南詳解

Mock與設計模式

mock與單例

客觀來講,藉助go語言官方提供的同步原語sync.Once,實現單例、使用單例是很容易的事情。在使用單例實現的過程中,單例的呼叫者往往邏輯中依賴提供的get方法在需要的時候獲取單例,而不會在自身的資料結構中儲存單例的控制代碼,這也就導致我們很難類比前面介紹的case,使用mock進行單元測試,因為caller沒有辦法控制通過get方法獲取的單例。

既然是因為沒有辦法更改單例返回,那麼解決這個問題最簡單的方式就是我們就應改提供一個set方法來設定更改單例。假設我們需要基於上面的case實現一個單例的TaskPool。假設我們定義了PoolImpl實現了Pool的介面,在建立單例的時候我們可能是這麼做的(為了方便說明,這裡我們用最早手工寫的基於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
}

這個時候問題就來了,假設某個依賴於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
}

那麼這個Run函式的單測應該怎麼寫呢?這裡的例子還比較簡單,要是TaskPool的實現還要依賴一些外部配置檔案,實際情形就會更加複雜,當然我們在這裡不討論這個情況,就是舉一個簡單的例子。在這種情況下,如果單例僅僅只提供了get方法的話是很難進行解耦測試的,如果使用GetTaskPool勢必會給測試引入不必要的複雜性,我們還需要提供一個單例的實現者提供一個set方法來解決單元測試解耦的問題。將單例的實現改成下面這樣,對外暴露一個單例的set方法,那麼我們就可以通過set方法來進行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生成一個MockTaskPool實現

mockgen -destination mock/mock_task_pool.go -package mock -source pool/interface.go

類似的,基於前面介紹的思想我們基於自動生成的程式碼實現一個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實現的邏輯拆除,在單測中只測試自己的程式碼

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)
}

到此這篇關於Go語言Mock使用基本指南詳解的文章就介紹到這了,更多相關Go語言Mock使用內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!