共计 8459 个字符,预计需要花费 22 分钟才能阅读完成。
当前的实践中问题
在项目之间依赖的时候我们往往可以通过 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)
}