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实际被测试到的路径为
可以看到的手动实现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使用内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!