Golang Mock使用入门

1. 概述

  前面介绍了golang的一般单元测试,以及如何使用vscode进行高效的go单元测试开发。同时也说过一般单元测试重点在于cpu和内存类型的测试,而对io类型的测试是比较敏感的。那么针对这类测试就没法做单元测试了吗?有的,肯定是有的,这就是mock技术。

  mock测试不但可以支持io类型的测试,比如:数据库,网络API请求,文件访问等。mock测试还可以做为未开发服务的模拟、服务压力测试支持、对未知复杂的服务进行模拟,比如开发阶段我们依赖的服务还没有开发好,那么就可以使用mock方法来模拟一个服务,模拟的这个服务接收的参数和返回的参数和规划设计的服务是一致的,那我们就可以直接使用这个模拟的服务来协助开发测试了;再比如要对服务进行压力测试,这个时候我们就要把服务依赖的网络,数据等服务进行模拟,不然得到的结果不纯粹。总结一下,有以下几种情况下使用mock会比较好:

  1. IO类型的,本地文件,数据库,网络API,RPC等
  2. 依赖的服务还没有开发好,这时候我们自己可以模拟一个服务,加快开发进度提升开发效率
  3. 压力性能测试的时候屏蔽外部依赖,专注测试本模块
  4. 依赖的内部函数非常复杂,要构造数据非常不方便,这也是一种mock测试,简单来说就是通过对服务或者函数发送设计好的参数,并且通过构造注入期望返回的数据来方便以上几种测试开发。

  一般情况自己写mock服务是比较费事的事情,而且如果风格不统一,那么后期的管理维护将是软件开发的一个巨大坑,是开发给自己挖的一个坑。所以在就有了很多mock测试框架的出现,框架的出现首先提升了编写mock测试服务的效率,而且编写风格得到了比较好的统一。c/c++也有很多mock框架,Google Mock就是一个比较经典了,java也有很多mock框架,这里就不列举了,今天我们要介绍的是针对golang的mock测试框架。

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

2. 安装

  • GoMock官网:https://github.com/golang/mock
  • GoMock安装:go get -u github.com/golang/mock/gomock
  • Mockgen代码生成工具安装:go get github.com/golang/mock/mockgen

安装好之后,在$GOPATH/src目录下有了github.com/golang/mock子目录,且在该子目录下有GoMock包和mockgen工具。

# 1.直接下载可执行文件方式
go get github.com/golang/mock/mockgen@v1.6.0
go install github.com/golang/mock/mockgen@latest

# 2.主动构建二进制方式
cd $GOPATH/src/github.com/golang/mock/mockgen
go build
#然后将mockgen二进制文件拷贝到执行性环境变量路径中

编译后在这个目录下会生成了一个可执行程序mockgen。将mockgen程序移动到$PATH可以找到的目录中:
下面我是在window下的路径,使用了git的shell环境,可以直接看PATH,找到合适的或者新加入进去都ok。

安装之后就可以在命令行直接运行了:mockgen

$ mockgen
mockgen has two modes of operation: source and reflect.

Source mode generates mock interfaces from a source file.
It is enabled by using the -source flag. Other flags that
may be useful in this mode are -imports and -aux_files.
Example:
        mockgen -source=foo.go [other options]
......

这里暂时先不细说。

  • GoMock文档,可以使用go doc命令来获取文档:go doc github.com/golang/mock/gomock。这个文件比较简短,但给出了核心的使用说明。 在GoPkgDoc上也有一个网页版的文档

3. 使用方式介绍

3.1. mockgen模式介绍

mockgen有两种操作模式:source和reflect。

  • Source模式下会从源文件产生mock的interfaces文件。 使用-source参数即可。和这个模式配套使用的参数常有-imports和-aux_files。

    #设置环境变量,尝试调用mockgen
    mockgen -destination student_mock.go -package student -source student_api.go
  • Reflect模式是通过反射的方式来生成mock interfaces。它只需要两个非标志性参数:import路径和需要mock的interface列表,列表使用逗号分割。例如:

    mockgen database/sql/driver Conn,Driver

3.2. 基本参数介绍

mockgen命令可以把一个包含要Mock的interface的源文件生成一个mock类的源文件。mockgen支持的参数有以下几种:

  • -source: 需要mock的文件,这个文件中有需要mock的接口
  • -destination: 生成mock代码的文件名。如果你没有设置,生成的代码会被打印到标准输出
  • -package: 指定生成的mock文件的包名。如果你没有设置,则包名由mock_和输入文件的包名级拼接而成
  • -imports: 生成代码中需要import的包名,形式如foo=bar/baz,并且用逗号分隔。bar/baz是要import的包,foo这个生成的源文件中包的标识。
  • -aux_files: 参看附加的文件列表是为了解析类似嵌套的定义在不同文件中的interface。指定元素列表以逗号分隔,元素形式为* foo=bar/baz.go,其中bar/baz.go是源文件,foo是-source选项指定的源文件用到的包名
  • -build_flags: 这个参数只在reflect模式下使用,用于go build的时候使用
  • -imports: 依赖的需要import的包
  • -mock_names:自定义生成mock文件的列表,使用逗号分割。如Repository=MockSensorRepository,Endpoint=MockSensorEndpoint。 Repository、Endpoint为接口,MockSensorRepository,MockSensorEndpoint为相应的mock文件。

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

想了解更多的指令符,可参见官方文档

3.3. mockgen工作模式适用场景

mockgen工作模式适用场景如下:

  1. 对于简单场景,只需使用-source选项。
  2. 对于复杂场景,如一个源文件定义了多个interface而只想对部分interface进行mock,或者interface存在嵌套,则需要使用反射模式。

4. 测试示例

4.1. 目录结构

D:/Student
│_ studentapi.go
student_test.go

4.2. 定义一个接口

我们先定义一个打算mock的接口student_api.go:

package student

//go:generate mockgen -destination student_mock.go -package student -source student_api.go

type Istudent interface {
    GetName() string
    SetName(string) string
}

type Student struct {
    Name     string
    Exchange string
}

func (s *Student) GetName() string {
    if s.Name == "" {
        return "none"
    }
    return s.Name
}

func (s *Student) SetName(name string) string {
    s.Name = name
    return "done"
}

4.3. 生成mock类文件

采用该方法需环境变量可以使用mockgen二进制文件

# 1. go generate生成接口文件
# 在当前目录中,采用该方法需同时代码中存在预编译注释  //go:generate mockgen -destination student_mock.go -package student -source student_api.go
$ go generate

# 2. 直接使用二进制生成接口文件
mockgen -destination student_mock.go -package student -source student_api.go

这里需要注意几点:

mock_people.go文件的mock文件夹,必须先创建好,否则会失败
go_mock一定在$GOPATH/src/的目录下 生成后的文件如下:

// Code generated by MockGen. DO NOT EDIT.
// Source: student_api.go

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

import (
    reflect "reflect"

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

// MockIstudent is a mock of Istudent interface.
type MockIstudent struct {
    ctrl     *gomock.Controller
    recorder *MockIstudentMockRecorder
}

// MockIstudentMockRecorder is the mock recorder for MockIstudent.
type MockIstudentMockRecorder struct {
    mock *MockIstudent
}

// NewMockIstudent creates a new mock instance.
func NewMockIstudent(ctrl *gomock.Controller) *MockIstudent {
    mock := &MockIstudent{ctrl: ctrl}
    mock.recorder = &MockIstudentMockRecorder{mock}
    return mock
}

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

// GetName mocks base method.
func (m *MockIstudent) GetName() string {
    m.ctrl.T.Helper()
    ret := m.ctrl.Call(m, "GetName")
    ret0, _ := ret[0].(string)
    return ret0
}

// GetName indicates an expected call of GetName.
func (mr *MockIstudentMockRecorder) GetName() *gomock.Call {
    mr.mock.ctrl.T.Helper()
    return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetName", reflect.TypeOf((*MockIstudent)(nil).GetName))
}

// SetName mocks base method.
func (m *MockIstudent) SetName(arg0 string) string {
    m.ctrl.T.Helper()
    ret := m.ctrl.Call(m, "SetName", arg0)
    ret0, _ := ret[0].(string)
    return ret0
}

// SetName indicates an expected call of SetName.
func (mr *MockIstudentMockRecorder) SetName(arg0 interface{}) *gomock.Call {
    mr.mock.ctrl.T.Helper()
    return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetName", reflect.TypeOf((*MockIstudent)(nil).SetName), arg0)
}

4.4. 测试用例

编写测试用例有一些基本原则,我们一起回顾一下:

  1. 每个测试用例只关注一个问题,不要写大而全的测试用例
  2. 测试用例是黑盒的
  3. 测试用例之间彼此独立,每个用例要保证自己的前置和后置完备
  4. 测试用例要对产品代码非入侵
package student

import (
    "fmt"
    "testing"

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

func TestMain(t *testing.T) {
    mockCtl := gomock.NewController(t)
    defer mockCtl.Finish()
    mockStudent := NewMockIstudent(mockCtl) //NewMockImetal是生成的mock代码,以包的形式存在

    s := new(Student)
    mockCtl.RecordCall(s, "GetName")
    mockCtl.Call(s, "GetName")

    //获取Name测试1
    mockStudent.EXPECT().GetName().Return("jack").MaxTimes(10)
    mockedGetName := mockStudent.GetName()

    //获取Name测试2
    // call := mockStudent.EXPECT().GetName().Return("tom").MaxTimes(10)
    // mockStudent.EXPECT().GetName().Return("peer").After(call) //注入期望的返回值
    // mockedGetName2 := mockStudent.GetName()
    fmt.Printf("mock get name1: %s\n", mockedGetName)
    if mockedGetName != "jack" {
        t.Error("Get wrong name:", mockedGetName)
    }

    //设置Name测试1
    mockStudent.EXPECT().SetName(gomock.Eq("xiaoming")).Do(func(arg string) { //入参校验
        fmt.Println("mock function recv param :", arg)
    }).Return("setdone")

    mockedSetName := mockStudent.SetName("xiaoming")
    fmt.Printf("mock function return value: %s\n", mockedSetName)
    if mockedSetName != "setdone" {
        t.Error("Set wrong name:", mockedSetName)
    }

    //设置Name测试2
    mockStudent.EXPECT().SetName(gomock.Any()).Do(func(arg string) { //入参不做校验
        fmt.Println("mock function recv param :", arg)
    }).Return("setdone")
    mockedSetName1 := mockStudent.SetName("xiaohong")
    fmt.Printf("mock function return value: %s\n", mockedSetName1)
    if mockedSetName1 != "setdone" {
        t.Error("Set wrong name:", mockedSetName1)
    }
}
  • gomock.NewController:返回gomock.Controller,它代表 mock 生态系统中的顶级控件。
  • 定义了 mock 对象的范围、生命周期和* 期待值。另外它在多个 goroutine 中是安全的

4.5. 测试结果

$ go test -v
=== RUN   TestMain
mock get name1: jack
mock function recv param : xiaoming
mock function return value: setdone
mock function recv param : xiaohong
mock function return value: setdone
--- PASS: TestMain (0.00s)
PASS
ok      gitee.com/turbock/golib/mock    0.066s

到此,我们的mock测试就算是ok了,可以再增加一些测试用例和测试值。

4.6. 测试覆盖率

这里在介绍一下另外一个简单的测试功能,测试覆盖率的测试cover,只要在go test后面加上-cover就可以了,如下面的例子,这里还加了一个参数-coverprofile=cover.out,这个参数是把覆盖率测试数据导出到cover.out这个文件,然后我们可以使用图形化的方式来看具体的测试覆盖情况。

$ go test -v -cover -coverprofile cover.out
=== RUN   TestMain
mock get name1: jack
mock function recv param : xiaoming
mock function return value: setdone
mock function recv param : xiaohong
mock function return value: setdone
--- PASS: TestMain (0.00s)
PASS
coverage: 76.2% of statements
ok      gitee.com/turbock/golib/mock    0.233s

运行下面这个工具就可以直接把覆盖率以网页的形式打开来看了。

go tool cover -html cover.out

5. gomock的其它用法

5.1. 常用 mock 返回值

  • Call.Return 用于返回确定的值的场景
  • Call.Do():声明在匹配时要运行的操作
  • Call.DoAndReturn():声明在匹配调用时要运行的操作,并且模拟返回该函* 数的返回值

5.2. 常用 mock 调用次数

  • Call.MaxTimes():设置最大的调用次数为 n 次
  • Call.MinTimes():设置最小的调用次数为 n 次
  • Call.AnyTimes():允许调用次数为 0 次或更多次
  • Call.Times():设置调用次数为 n 次 这里我展开分析了一下,其实gomock的实现上是这样的,以这个为例:

mockpeople.EXPECT().GetName().Return("helight")

mockpeople的EXPECT方法如下:都在生成的mock文件中。

// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockIstudent) EXPECT() *MockIstudentMockRecorder { return m.recorder}
//m.recorder是这个

// NewMockIstudent creates a new mock instance
func NewMockIstudent(ctrl *gomock.Controller) *MockIstudent {   mock := &MockIstudent{ctrl: ctrl}
    mock.recorder = &MockIstudentMockRecorder{mock} return mock
}

//所以这里调用的就是MockIstudentMockRecorder的GetName方法。这个函数如下:
func (mr *MockIstudentMockRecorder) GetName() *gomock.Call {
    mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetName", reflect.TypeOf((*MockIstudent)(nil).GetName))
}

//RecordCallWithMethodType是mock.ctrl的方法,这个方法是在gomock库里面的controller.go文件中,在看一下这个函数的实现:
// RecordCallWithMethodType is called by a mock. It should not be called by user code.
func (ctrl *Controller) RecordCallWithMethodType(receiver interface{}, method string, methodType reflect.Type, args ...interface{}) *Call {
    ctrl.T.Helper() call := newCall(ctrl.T, receiver, method, methodType, args...)
    ctrl.mu.Lock()  defer ctrl.mu.Unlock()
    ctrl.expectedCalls.Add(call)    return call
}

//这里看到,其实就是在ctrl.expectedCalls中把新生成函数按照函数名,参数等生成一个call,在call.go中就有Return、Do、Times等函数。这里看一些简单的例子,有兴趣的同学可以继续深入看看call.go这个文件。
// AnyTimes allows the expectation to be called 0 or more times
func (c *Call) AnyTimes() *Call {
    c.minCalls, c.maxCalls = 0, 1e8 // close enough to infinity
    return c
}

// MinTimes requires the call to occur at least n times. If AnyTimes or MaxTimes have not been called, MinTimes also// sets the maximum number of calls to infinity.
func (c *Call) MinTimes(n int) *Call {
    c.minCalls = n  if c.maxCalls == 1 {
        c.maxCalls = 1e8
    }   return c
}

// MaxTimes limits the number of calls to n times. If AnyTimes or MinTimes have not been called, MaxTimes also// sets the minimum number of calls to 0.
func (c *Call) MaxTimes(n int) *Call {
    c.maxCalls = n  if c.minCalls == 1 {
        c.minCalls = 0
    }   return c
}

5.3. 常用 mock 参数匹配

参数支持Eq,Any,Not,Nil,分别代表一下含义:

  • gomock.Eq(value) 用于参数为固定值的场景。
  • gomock.Any() 用于任意参数的场景。
  • gomock.Not(value) 用于表示参数非 value 以外的值场景。
  • gomock.Nil() 用于表示参数None 值的场景

5.4. 常用 mock 行为调用的保序

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

  • 通过After关键字来实现保序
  • 通过InOrder关键字来实现保序
// call := mockpeople.EXPECT().GetName().Return("helight1")
// mockpeople.EXPECT().GetName().Return("helight").After(call)   
// gomock.InOrder(
    mockpeople.EXPECT().GetName().Return("helight")
    mockpeople.EXPECT().GetName().Return("helight")
// )

6. 总结

  gomock是单元测试的升级,帮助我们可以把之前无法做单元测试又非常重要的模块能够进行单元测试。gomock整体功能还是非常强大的,更多的功能可以在实际使用中不断熟悉,另外也可以多看看官方文档和gomock的源码。

7. 参考

  1. https://github.com/golang/mock
  2. https://cloud.tencent.com/developer/article/1870807
  3. http://www.manongjc.com/detail/52-njzgdwqdchsomdv.html
  4. https://blog.csdn.net/daliucheng/article/details/125032350

发表评论

邮箱地址不会被公开。 必填项已用*标注