Go語言Mock使用基本指南

當前的實踐中問題

在項目之間依賴的時候咱們每每能夠經過mock一個接口的實現,以一種比較簡潔、獨立的方式,來進行測試。可是在mock使用的過程當中,由於你們的風格不統一,並且不少使用minimal implement的方式來進行mock,這就致使了經過mock出的實現各個函數的返回值每每是靜態的,就沒法讓caller根據返回值進行的一些複雜邏輯。git

首先來舉一個例子github

package task

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

經過minimal implement的方式來進行手動的mockgolang

package mock

type MinimalTask struct {
    // filed
}

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

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

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

舉個例子,假如咱們有以下的接口定義與函數定義設計模式

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實際被測試到的路徑爲
image-20190906105654667.png工具

能夠看到的手動實現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) },
            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)
        })
    }
}

咱們使用這個新的測試文件進行覆蓋率測試
image-20190906162109263.png

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

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)
        // 加入了返回錯誤的邏輯
                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的處理邏輯
image-20190906163614323.png

甚至咱們能夠更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的設置檢查了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 _, 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)
            }

        })
    }
}

這樣咱們就能夠覆蓋到全部語句
image-20190906192151839.png

思考Mock的意義

以前和一些同窗討論過,咱們爲何要使用mock這個問題,發現不少同窗的以爲寫mock的是約定好接口,而後在面向接口作開發的時候可以方便測試,由於不須要接口實際的實現,而是依賴mock的Minimal Implement就能夠進行單元測試。我認爲這是對的,可是同時也以爲mock的意義不只僅是如此。

在我看來,面向接口開發的實踐中,你應該時刻對接口的輸入和輸出保持敏感,更進一步的說,在進行單元測試的時候,你須要知道在給定的用例、輸入下,你的包會對起使用的接口方法輸入什麼,調用幾回,而後返回值多是什麼,什麼樣的返回值對你有影響,若是你對這些不瞭解,那麼我以爲或者你應該去作更多地嘗試和了解,這樣才能儘量經過mock設計出更多的單測用例,作更多且謹慎的檢查,提升測試代碼的覆蓋率,確保模塊功能的完備性。
image-20190906200142181.png

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)
}
相關文章
相關標籤/搜索