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)
}
相关文章
相关标签/搜索