学习与交流:Go语言技术微信群
商务合作加微信:LetsFeng
学习与分享:GoLand2022 正版激活码 全家桶通用版
课本,文档学习Go语言,个人强烈推荐这本书
现在就开始你的Go语言学习之旅吧!人生苦短,let’s Go.
在项目之间依赖的时候我们往往可以通过mock一个接口的实现,以一种比较简洁、独立的方式,来进行测试。
但是在mock使用的过程中,因为大家的风格不统一,而且很多使用minimal implement的方式来进行mock,这就导致了通过mock出的实现各个函数的返回值往往是静态的,就无法让caller根据返回值进行的一些复杂逻辑。
首先来举一个例子
package task
type Task interface {
Do(int) (string, error)
}
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设计出更多的单测用例,做更多且谨慎的检查,提高测试代码的覆盖率,确保模块功能的完备性。
参考链接:https://www.jb51.net/article/188480.htm
更多相关Go语言的技术文章或视频教程,请关注本公众号获取并查看,感谢你的支持与信任!
文章评论