序言

GoMock是由Golang官方开发维护的测试框架,实现了较为完整的基于interface的Mock功能,能够与Golang内置的testing包良好集成,也能用于其它的测试环境中。GoMock测试框架包含了GoMock包和mockgen工具两部分,其中GoMock包完成对桩对象生命周期的管理,mockgen工具用来生成interface对应的Mock类源文件。

常用方法

1
func InOrder(calls ...*Call)

InOrder声明给定调用的调用顺序

1
func (ctrl *Controller) Call(receiver interface{}, method string, args ...interface{}) []interface{}

Call表示对mock对象的一个期望调用

1
func (c *Call) After(preReq *Call) *Call

After声明调用在preReq完成后执行

1
func (c *Call) AnyTimes() *Call

允许调用0次或多次

1
func (c *Call) Do(f interface{}) *Call

声明在匹配时要运行的操作

1
func (c *Call) MaxTimes(n int) *Call

设置最大的调用次数为n次

1
func (c *Call) MinTimes(n int) *Call

设置最小的调用次数为n次

1
func (c *Call) Return(rets ...interface{}) *Call

Return声明模拟函数调用返回的值

1
func (c *Call) SetArg(n int, value interface{}) *Call

SetArg声明使用指针设置第n个参数的值

1
func (c *Call) Times(n int) *Call

设置调用的次数为n次

1
func NewController(t TestReporter) *Controller

获取控制对象

1
func WithContext(ctx context.Context, t TestReporter) (*Controller, context.Context)

WithContext返回一个控制器和上下文,如果发生任何致命错误时会取消。

1
func (ctrl *Controller) Call(receiver interface{}, method string, args ...interface{}) []interface{}

Mock对象调用,不应由用户代码调用。

1
func (ctrl *Controller) Finish()

检查所有预计调用的方法是否被调用,每个控制器都应该调用。本函数只应该被调用一次。

1
func (ctrl *Controller) RecordCall(receiver interface{}, method string, args ...interface{}) *Call

被mock对象调用,不应由用户代码调用。

1
func (ctrl *Controller) RecordCallWithMethodType(receiver interface{}, method string, methodType reflect.Type, args ...interface{}) *Call

被mock对象调用,不应由用户代码调用。

1
func Any() Matcher

匹配任意值

1
func AssignableToTypeOf(x interface{}) Matcher

AssignableToTypeOf是一个匹配器,用于匹配赋值给模拟调用函数的参数和函数的参数类型是否匹配。

1
func Eq(x interface{}) Matcher

通过反射匹配到指定的类型值,而不需要手动设置

1
func Nil() Matcher

返回nil

1
func Not(x interface{}) Matcher

不递归给定子匹配器的结果

安装

在命令行运行命令:

1
go get github.com/golang/mock/gomock

运行完后你会发现,在$GOPATH/src目录下有了github.com/golang/mock子目录,且在该子目录下有GoMock包和mockgen工具。

继续运行命令:

1
2
cd $GOPATH/src/github.com/golang/mock/mockgen
go build

则在当前目录下生成了一个可执行程序mockgen。

将mockgen程序移动到$GOPATH/bin目录下:

1
mv mockgen $GOPATH/bin

这时在命令行运行mockgen,如果列出了mockgen的使用方法和例子,则说明mockgen已经安装成功,否则会显示:

1
-bash: mockgen: command not found

一般是由于没有在环境变量PATH中配置$GOPATH/bin导致。

mockgen

在 mockgen 命令中,支持两种生成模式:

  1. source:从源文件生成 mock 接口(通过 -source 启用) mockgen -source=foo.go [other options]
  2. reflect:通过使用反射程序来生成 mock 接口。它通过传递两个非标志参数来启用:导入路径和逗号分隔的接口列表 mockgen database/sql/driver Conn,Driver

从本质上来讲,两种方式生成的 mock 代码并没有什么区别。因此选择合适的就可以了

source举例:

1
mockgen -source=foo.go [other options]

reflect举例:

1
mockgen database/sql/driver Conn,Driver

反射模式通过构建一个程序用反射理解接口生成一个mock类文件,它通过两个非标志参数生效:导入路径和用逗号分隔的符号列表(多个interface)。

注意:第一个参数是基于GOPATH的相对路径,第二个参数可以为多个interface,并且interface之间只能用逗号分隔,不能有空格。

有一个包含打算Mock的interface的源文件,就可用mockgen命令生成一个mock类的源文件。mockgen支持的选项如下:

  • -source: 一个文件包含打算mock的接口列表
  • -destination: 存放mock类代码的文件。如果你没有设置这个选项,代码将被打印到标准输出
  • -package: 用于指定mock类源文件的包名。如果你没有设置这个选项,则包名由mock_和输入文件的包名级联而成
  • -aux_files: 参看附加的文件列表是为了解析类似嵌套的定义在不同文件中的interface。指定元素列表以逗号分隔,元素形式为foo=bar/baz.go,其中bar/baz.go是源文件,foo是-source选项指定的源文件用到的包名

在简单的场景下,你将只需使用-source选项。在复杂的情况下,比如一个文件定义了多个interface而你只想对部分interface进行mock,或者interface存在嵌套,这时你需要用反射模式。由于 -destination 选项输入太长,笔者一般不使用该标识符,而使用重定向符号 >,并且mock类代码的输出文件的路径必须是绝对路径。

导入包

mock相关的包包括testing,gmock和mock_db,import包路径:

1
2
3
4
5
6
import (
    "testing"
    . "github.com/golang/mock/gomock"
    "test/mock/db"
    ...
)

创建接口

定义一个需要mock的接口Repository,infra/db.go文件如下:

1
2
3
4
5
6
7
8
package db

type Repository interface {
   Create(key string, value []byte) error
   Retrieve(key string) ([]byte, error)
   Update(key string, value []byte) error
   Delete(key string) error
}

控制器

mock控制器通过NewController接口生成,是mock生态系统的顶层控制,它定义了mock对象的作用域和生命周期,以及它们的期望。多个协程同时调用控制器的方法是安全的。

当用例结束后,控制器会检查所有剩余期望的调用是否满足条件。

控制器的代码如下所示:

1
2
ctrl := NewController(t)
defer ctrl.Finish()

mock对象创建时需要注入控制器,如果有多个mock对象则注入同一个控制器,如下所示:

1
2
3
4
ctrl := NewController(t)
defer ctrl.Finish()
mockRepo := mock_db.NewMockRepository(ctrl)
mockHttp := mock_api.NewHttpMethod(ctrl)

行为注入

对于mock对象的行为注入,控制器是通过map来维护的,一个方法对应map的一项。因为一个方法在一个用例中可能调用多次,所以map的值类型是数组切片。当mock对象进行行为注入时,控制器会将行为Add。当该方法被调用时,控制器会将该行为Remove。

假设有这样一个场景:先Retrieve领域对象失败,然后Create领域对象成功,再次Retrieve领域对象就能成功。这个场景对应的mock对象的行为注入代码如下所示:

1
2
3
mockRepo.EXPECT().Retrieve(Any()).Return(nil, ErrAny)
mockRepo.EXPECT().Create(Any(), Any()).Return(nil)
mockRepo.EXPECT().Retrieve(Any()).Return(objBytes, nil)

objBytes是领域对象的序列化结果,比如:

1
2
3
obj := Movie{...}
objBytes, err := json.Marshal(obj)
...

当批量Create对象时,可以使用Times关键字:

1
mockRepo.EXPECT().Create(Any(), Any()).Return(nil).Times(5)

当批量Retrieve对象时,需要注入多次mock行为:

1
2
3
4
5
mockRepo.EXPECT().Retrieve(Any()).Return(objBytes1, nil)
mockRepo.EXPECT().Retrieve(Any()).Return(objBytes2, nil)
mockRepo.EXPECT().Retrieve(Any()).Return(objBytes3, nil)
mockRepo.EXPECT().Retrieve(Any()).Return(objBytes4, nil)
mockRepo.EXPECT().Retrieve(Any()).Return(objBytes5, nil)

行为保序

默认情况下,行为调用顺序可以和mock对象行为注入顺序不一致,即不保序。如果要保序,有两种方法:

  • 通过After关键字来实现保序
  • 通过InOrder关键字来实现保序

通过After关键字实现的保序示例代码:

1
2
3
retrieveCall := mockRepo.EXPECT().Retrieve(Any()).Return(nil, ErrAny)
createCall := mockRepo.EXPECT().Create(Any(), Any()).Return(nil).After(retrieveCall)
mockRepo.EXPECT().Retrieve(Any()).Return(objBytes, nil).After(createCall)

通过InOrder关键字实现的保序示例代码:

1
2
3
4
5
InOrder(
    mockRepo.EXPECT().Retrieve(Any()).Return(nil, ErrAny)
    mockRepo.EXPECT().Create(Any(), Any()).Return(nil)
    mockRepo.EXPECT().Retrieve(Any()).Return(objBytes, nil)
)

可见,通过InOrder关键字实现的保序更简单自然,所以推荐这种方式。其实,关键字InOrder是After的语法糖,源码如下:

1
2
3
4
5
6
// InOrder declares that the given calls should occur in order.
func InOrder(calls ...*Call) {
    for i := 1; i < len(calls); i++ {
        calls[i].After(calls[i-1])
    }
}

当mock对象行为的注入保序后,如果行为调用的顺序和其不一致,就会触发测试失败。这就是说,对于上面的例子,如果在测试用例执行过程中,Repository的方法的调用顺序如果不是按 Retrieve -> Create -> Retrieve 的顺序进行,则会导致测试失败。

对象注入

mock对象的行为都注入到控制器以后,我们接着要将mock对象注入给interface,使得mock对象在测试中生效。

在使用GoStub框架之前,很多人都使用土方法,比如Set。这种方法有一个缺陷:当测试用例执行完成后,并没有回滚interface到真实对象,有可能会影响其它测试用例的执行。所以,笔者强烈建议大家使用GoStub框架完成mock对象的注入。

1
2
stubs := StubFunc(&redisrepo.GetInstance, mockDb)
defer stubs.Reset()

测试用例

在本文将模拟一个简单 Demo 来编写测试用例,熟悉整体的测试流程

步骤

  1. 想清楚整体逻辑
  2. 定义想要(模拟)依赖项的 interface(接口)
  3. 使用 mockgen 命令对所需 mock 的 interface 生成 mock 文件
  4. 编写单元测试的逻辑,在测试中使用 mock
  5. 进行单元测试的验证

目录

1
2
3
4
5
6
├── mock
├── person
│   └── male.go
└── user
    ├── user.go
    └── user_test.go

编写

interface 方法

打开 person/male.go 文件,写入以下内容:

1
2
3
4
5
package person

type Male interface {
    Get(id int64) error
}

调用方法

打开 user/user.go 文件,写入以下内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
 package user
 
 import "github.com/EDDYCJY/mockd/person"
 
 type User struct {
     Person person.Male
 }
 
 func NewUser(p person.Male) *User {
    return &User{Person: p}
}

func (u *User) GetUserInfo(id int64) error {
    return u.Person.Get(id)
}

生成 mock 文件

回到 mockd/ 的根目录下,执行以下命令

1
mockgen -source=./person/male.go -destination=./mock/male_mock.go -package=mock

输出的 mock 文件

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// Code generated by MockGen. DO NOT EDIT.
// Source: ./person/male.go

// Package mock is a generated GoMock package.
package mock

import (
    gomock "github.com/golang/mock/gomock"
    reflect "reflect"
)

// MockMale is a mock of Male interface
type MockMale struct {
    ctrl     *gomock.Controller
    recorder *MockMaleMockRecorder
}

// MockMaleMockRecorder is the mock recorder for MockMale
type MockMaleMockRecorder struct {
    mock *MockMale
}

// NewMockMale creates a new mock instance
func NewMockMale(ctrl *gomock.Controller) *MockMale {
    mock := &MockMale{ctrl: ctrl}
    mock.recorder = &MockMaleMockRecorder{mock}
    return mock
}

// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockMale) EXPECT() *MockMaleMockRecorder {
    return m.recorder
}

// Get mocks base method
func (m *MockMale) Get(id int64) error {
    ret := m.ctrl.Call(m, "Get", id)
    ret0, _ := ret[0].(error)
    return ret0
}

// Get indicates an expected call of Get
func (mr *MockMaleMockRecorder) Get(id interface{}) *gomock.Call {
    return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockMale)(nil).Get), id)
}

测试用例

打开 user/user_test.go 文件,写入以下内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package user

import (
    "testing"

    "github.com/EDDYCJY/mockd/mock"

    . "github.com/golang/mock/gomock"
)

func TestUser_GetUserInfo(t *testing.T) {
    ctl := NewController(t)
    defer ctl.Finish()

    var id int64 = 1
    mockMale := mock.NewMockMale(ctl)
    InOrder(
        mockMale.EXPECT().Get(id).Return(nil),
    )
    user := NewUser(mockMale)
    err := user.GetUserInfo(id)
    if err != nil {
        t.Errorf("user.GetUserInfo err: %v", err)
    }
}
}
  • NewController:返回 gomock.Controller,它代表 mock 生态系统中的顶级控件。定义了 mock 对象的范围、生命周期和期待值。另外它在多个 goroutine 中是安全的
  • mock.NewMockMale:创建一个新的 mock 实例
  • InOrder:声明给定的调用应按顺序进行(是对 gomock.After 的二次封装)
  • mockMale.EXPECT().Get(id).Return(nil):这里有三个步骤:
    1. EXPECT()返回一个允许调用者设置期望和返回值的对象。
    2. Get(id) 是设置入参并调用 mock 实例中的方法。
    3. Return(nil) 是设置先前调用的方法出参。简单来说,就是设置入参并调用,最后设置返回值
  • NewUser(mockMale):创建 User 实例,值得注意的是,在这里注入了 mock 对象,因此实际在随后的 user.GetUserInfo(id) 调用(入参:id 为 1)中。它调用的是我们事先模拟好的 mock 方法
  • ctl.Finish():进行 mock 用例的期望值断言,一般会使用 defer 延迟执行,以防止我们忘记这一操作

测试

回到 mockd/ 的根目录下,执行以下命令

1
2
$ go test ./user
ok      github.com/EDDYCJY/mockd/user

看到这样的结果,就大功告成啦!你可以自己调整一下 Return() 的返回值,以此得到不一样的测试结果哦

批量生成mock文件

你可能会想一条条命令生成 mock 文件,岂不得崩溃?

当然,官方提供了更方便的方式,我们可以利用 go:generate 来完成批量处理的功能

1
go generate [-run regexp] [-n] [-v] [-x] [build flags] [file.go... | packages]

修改 interface 方法

打开 person/male.go 文件,修改为以下内容:

1
2
3
4
5
6
7
package person

//go:generate mockgen -destination=../mock/male_mock.go package=mock github.com/EDDYCJY/mockd/person Male

type Male interface {
    Get(id int64) error
}

我们关注到 go:generate 这条语句,可分为以下部分:

  1. 声明 //go:generate (注意不要留空格)
  2. 使用 mockgen 命令
  3. 定义 -destination
  4. 定义 -package
  5. 定义 source,此处为 person 的包路径
  6. 定义 interfaces,此处为 Male

重新生成 mock 文件

回到 mockd/ 的根目录下,执行以下命令

1
go generate ./...

再检查 mock/ 发现也已经正确生成了,在多个文件时是不是很方便呢

参考:
https://cloud.tencent.com/developer/article/1377213
https://www.jianshu.com/p/f4e773a1b11f 使用 Gomock 进行单元测试