Go语言Mock使用基本指南详解

 更新时间:2020年6月11日 12:43  点击:1985

当前的实践中问题

在项目之间依赖的时候我们往往可以通过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实际被测试到的路径为

可以看到的手动实现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)
  })
 }
}

我们使用这个新的测试文件进行覆盖率测试

可以看到测试结果是一样的,那当我们想要达到更高的测试覆盖率的时候应该怎么办呢?我们进一步修改测试

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的处理逻辑

甚至我们可以更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)
   }

  })
 }
}

这样我们就可以覆盖到所有语句

思考Mock的意义

之前和一些同学讨论过,我们为什么要使用mock这个问题,发现很多同学的觉得写mock的是约定好接口,然后在面向接口做开发的时候能够方便测试,因为不需要接口实际的实现,而是依赖mock的Minimal Implement就可以进行单元测试。我认为这是对的,但是同时也觉得mock的意义不仅仅是如此。

在我看来,面向接口开发的实践中,你应该时刻对接口的输入和输出保持敏感,更进一步的说,在进行单元测试的时候,你需要知道在给定的用例、输入下,你的包会对起使用的接口方法输入什么,调用几次,然后返回值可能是什么,什么样的返回值对你有影响,如果你对这些不了解,那么我觉得或者你应该去做更多地尝试和了解,这样才能尽可能通过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使用内容请搜索猪先飞以前的文章或继续浏览下面的相关文章希望大家以后多多支持猪先飞!

[!--infotagslink--]

相关文章

  • Go应用中优雅处理Error的技巧总结

    在程序员中,尤其是go新手,经常听到的一个讨论话题是:如何处理错误,这篇文章主要给大家介绍了关于Go应用中优雅处理Error的一些相关技巧,需要的朋友可以参考下...2021-09-08
  • Django def clean()函数对表单中的数据进行验证操作

    这篇文章主要介绍了Django def clean()函数对表单中的数据进行验证操作,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧...2020-07-09
  • golang官方嵌入文件到可执行程序的示例详解

    这篇文章主要介绍了golang官方嵌入文件到可执行程序,本文通过示例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下...2021-02-20
  • go浮点数转字符串保留小数点后N位的完美解决方法

    这篇文章主要介绍了go浮点数转字符串保留小数点后N位解决办法,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下...2020-05-11
  • Go语言使用读写OPC详解

    这篇文章主要介绍了Go语言使用读写OPC详解,图文讲解的很清晰,有感兴趣的同学可以学习下...2021-03-05
  • Go中string与[]byte高效互转的方法实例

    string与[]byte经常需要互相转化,普通转化会发生底层数据的复制,下面这篇文章主要给大家介绍了关于Go中string与[]byte高效互转的相关资料,文中通过示例代码介绍的非常详细,需要的朋友可以参考下...2021-09-20
  • Go项目的目录结构详解

    这篇文章主要介绍了Go项目的目录结构,对基础目录做了讲解,对项目开发中的其它目录也一并做了介绍,需要的朋友可以参考下...2020-05-01
  • Go 容器遍历的实现示例

    Go 语言提供的基础容器,免不了要查询容器中的数据,那么是如何实现遍历的呢?本文将会介绍几种常用容易的遍历及其使用。感兴趣的可以了解一下...2021-06-13
  • 创建第一个Go语言程序Hello,Go!

    这篇文章主要介绍了创建第一个Go语言程序Hello,Go!本文详细的给出项目创建、代码编写的过程,同时讲解了GOPATH、Go install等内容,需要的朋友可以参考下...2020-05-01
  • 在Django中使用MQTT的方法

    这篇文章主要介绍了在Django中使用MQTT的方法,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧...2021-05-10
  • go语言中的Carbon库时间处理技巧

    这篇文章主要介绍了go语言中的Carbon库时间处理,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下...2021-02-05
  • go嵌套匿名结构体的初始化详解

    这篇文章主要介绍了go嵌套匿名结构体的初始化详解,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧...2020-12-16
  • 解决导入django_filters不成功问题No module named 'django_filter'

    这篇文章主要介绍了解决导入django_filters不成功问题No module named 'django_filter',具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧...2020-07-15
  • 详解如何使用Docker部署Django+MySQL8开发环境

    这篇文章主要介绍了详解如何使用Docker部署Django+MySQL8开发环境,文中通过示例代码以及图文介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面来一起学习学习吧...2020-07-19
  • Go语言json编码驼峰转下划线、下划线转驼峰的实现

    这篇文章主要介绍了Go语言json编码驼峰转下划线、下划线转驼峰的实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧...2020-06-09
  • Django 解决由save方法引发的错误

    这篇文章主要介绍了Django 解决由save方法引发的错误,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧...2020-05-21
  • django数据模型中null和blank的区别说明

    这篇文章主要介绍了django数据模型中null和blank的区别说明,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧...2020-09-03
  • Go 自定义package包设置与导入操作

    这篇文章主要介绍了Go 自定义package包设置与导入操作,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧...2021-05-06
  • Go语言实现汉诺塔算法

    之前的文章,我们给大家分享了不少汉诺塔算法的实现语言,包括C、c++、java、python等,今天我们就来使用go语言来实现一下,需要的小伙伴来参考下吧。...2020-05-07
  • Django表单外键选项初始化的问题及解决方法

    这篇文章主要介绍了Django表单外键选项初始化的问题及解决方法,需本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,要的朋友可以参考下...2021-04-29