解析 Golang 测试(2)- gomock

时间:2022-9-2     作者:smarteng     分类: Go语言


上一篇文章 解析 Golang 测试 - 原生支持(1)中,我们了解了 Golang 官方 testing 库提供的支持。

今天我们一起看一下官方提供的一个 mock 工具:gomock

1. 单测中的依赖

在日常开发中,我们经常需要写单测来验证代码逻辑是否符合预期,如果你的函数只存在本地依赖,或是纯计算型的逻辑,写单测的成本并不高。

但事实上,我们经常需要去应对各种各样的外部依赖,也许是一个RPC下游服务,也许是一个 DB 实例,或是一个 HTTP 接口。这些外部依赖并不是我们可以控制的,作为上游也无需考虑他们的细节逻辑,大家只需要定好交互的规范(也许是一个接口,一个协议),然后按照规范来发请求即可。

设想一下,如果你的某次功能依赖一个下游基础服务,比如用户信息的 User 服务,需要对一个已经存在的用户数据进行读取,拿到 value 后进行一些操作,你会怎样实现对应的单测?

选项一:写死一个已知的内部用户的 UserID,直接读线上的真实的下游服务;

选项二:同样写死一个内部的 UserID,读测试环境的数据;

选项三:代码中写死一份用户数据。

首先我们排除选项一,线上的数据不应当与本地开发环境存在交互,存在安全隐私风险。

选项二是一个办法,但是强依赖了测试环境的稳定性,如果其他业务也在测试,依赖了同一个UserID,对其状态进行了更改,下游服务的返回就会随之变化,很可能导致你的单测case不过。

深入想一下:我们写单测是要验证什么?

是为了验证下游服务么?不!

我们要验证的是自己的代码,下游服务对我们来说只是个外部依赖,我们定义好接口即可,如果下游服务没有按照接口标准来实现,或因为bug 无法正常提供服务,这是他们的责任。

我们要做的,是针对下游服务提供的协议来做相应的处理。如果下游数据正确,我们期待自己的逻辑也能正常执行。如果下游返回了错误码,我们也需要相应地处理错误,给自己的上游返回对应的报错。

每个人把自己的事情做好,而不是一锅端,什么都放在一起测。这样只会延迟暴露和处理问题的时间。

简言之:单测是测试自己的代码在各种情况下的处理是否符合预期,不要因为偷懒,直接依靠某些写死的数据,这样本质上是引入了更多不确定的因素。

2. 为什么需要 mock 框架

前面我们排除了选项一二,明确了单测的范围,是验证自己的代码能否应对各种场景。ok,那是不是我在代码里本地去构造一个虚拟的 User 供包里的单测函数调用就可以了呢?

是的,但不完全是。实际业务开发中,我们要面对的常常是大量的测试场景,一个包下几十个单测函数很常见。如果没有一个系统的测试数据构造方案,手动去维护虚拟数据 => 单测函数的映射,是一件让人头疼的事。

mock 框架就是来帮助我们,以最小的心智负担来处理掉外部依赖。

你可以自行 mock 外部依赖的实现,在这个假的实现里面,自由掌控你关心的字段和返回值。

3. mockgen

3.1 安装

$ go get -u github.com/golang/mock/gomock
$ go install github.com/golang/mock/mockgen
复制代码

这一步我们将会安装 gomock 提供的命令行工具 mockgen ,他能够帮助我们根据一个接口定义,实现一套 mock 的实现。

安装成功后在你的 Terminal 执行 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]

Reflect mode generates mock interfaces by building a program
that uses reflection to understand interfaces. It is enabled
by passing two non-flag arguments: an import path, and a
comma-separated list of symbols.
Example:
        mockgen database/sql/driver Conn,Driver

  -aux_files string
        (source mode) Comma-separated pkg=path pairs of auxiliary Go source files.
  -build_flags string
        (reflect mode) Additional flags for go build.
  -copyright_file string
        Copyright file used to add copyright header
  -debug_parser
        Print out parser results only.
  -destination string
        Output file; defaults to stdout.
  -exec_only string
        (reflect mode) If set, execute this reflection program.
  -imports string
        (source mode) Comma-separated name=path pairs of explicit imports to use.
  -mock_names string
        Comma-separated interfaceName=mockName pairs of explicit mock names to use. Mock names default to 'Mock'+ interfaceName suffix.
  -package string
        Package of the generated code; defaults to the package of the input with a 'mock_' prefix.
  -prog_only
        (reflect mode) Only generate the reflection program; write it to stdout and exit.
  -self_package string
        The full package import path for the generated code. The purpose of this flag is to prevent import cycles in the generated code by trying to include its own package. This can happen if the mock's package is set to one of its inputs (usually the main one) and the output is stdio so mockgen cannot detect the final output package. Setting this flag will then tell mockgen which import to exclude.
  -source string
        (source mode) Input Go source file; enables source mode.
  -version
        Print version.
  -write_package_comment
        Writes package documentation comment (godoc) if true. (default true)
复制代码

3.2 两种模式

mockgen 提供了两种模式:

  1. source: 根据接口定义文件来生成 mock 实现
mockgen -source=foo.go [other options] 
复制代码
  1. reflect:通过反射生成 mock 实现,我们需要传入 import 路径,以及用逗号分隔的一系列接口
mockgen database/sql/driver Conn,Driver 
复制代码

两种模式并没有本质差异,根据你的场景选择即可。

3.3 常用的选项

  • -source : 设置你需要生成mock实现的接口文件;

  • -destination : 设置mock实现将要生成到的地址,若未设置,将会打印到标准输出;

  • -package : 设置生成的mock文件的包名,如果不设置,默认会采用 mock_ 前缀加上文件名。

4. 实战测试

首先我们建立如下结构的目录,用于测试

├── mock
├── person
│   └── male.go
└── user
    ├── user.go
    └── user_test.go
复制代码

在 person/male.go 中定义一个 Male 的接口:

package person

type Male interface {
    Get(id int64) error
}
复制代码

只提供一个方法,根据 id 获取数据,如果找不到返回 error,否则返回 nil。

再到 user/user.go 中,填充 User 相关逻辑:

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)
}

复制代码

现在回到项目根目录下,执行 mockgen 命令

$ mockgen -source=./person/male.go -destination=./mock/male_mock.go -package=mock 
复制代码

执行完毕后,你会发现 mock 目录下多了一个 male_mock.go 文件,这就是 mockgen 根据我们的 Male 接口生成的 mock 实现。内容如下:

// 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)
}
复制代码

参照注释可以发现 MockMale 就是对应的 mock 实现,我们可以通过 NewMockMale 函数来生成一个 MockMale 。下面我们来写测试函数,在 user/user_test.go 里面实现:

package user

import (
"testing"

"github.com/EDDYCJY/mockd/mock"

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

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

var id int64 = 1
mockMale := mock.NewMockMale(ctl)
gomock.InOrder(
mockMale.EXPECT().Get(id).Return(nil),
)

user := NewUser(mockMale)
err := user.GetUserInfo(id)
if err != nil {
t.Errorf("user.GetUserInfo err: %v", err)
}
}
复制代码
  • gomock.NewController : 接收 *testing.T 参数,生成一个 controller,它定义了 mock 实现的范围,生命周期,以及预期的值。 gomock.Controller 是并发安全的;

  • mock.NewMockMale : 创建一个新的mock实现;

  • gomock.InOrder : 声明需要按照顺序进行指定的调用;

  • mockMale.EXPECT().Get(id).Return(nil) : EXPECT()会返回一个对象,允许调用方设置期望的动作和返回值。Get(id) 设置了输入并调用 mock 实现里的方法。Return(nil) 设置输出;

  • NewUser(mockMale) 创建一个 User 实例,注入 mockMale 实现;

  • ctl.Finish() 断言 mock 的用例的期望值。

此时我们回到根目录,执行 go test ./user ,得到下面的输出:

$ go test ./user
ok    github.com/EDDYCJY/mockd/user
复制代码

5. 常见的 mock 能力

5.1 调用方法

  • Call.Do() : 声明条件匹配时,要执行的动作;

  • Call.DoAndReturn() : 声明条件匹配时,要执行的动作以及返回值;

  • MaxTimes() : 设置调用的最大次数;

  • Call.MinTimes() : 设置最小调用次数;

  • AnyTimes() : 允许任意次数的调用(0次也ok)

  • Times() : 设置调用次数。

5.2 参数匹配

  • gomock.Any() : 匹配任意值

  • gomock.Eq() : 匹配指定的值,这里会用反射。

  • gomock.Nil() : 返回 nil

6. 使用 go generate 简化

类似 mockgen 这类命令,如果每次都要手动去敲是很麻烦的,可以考虑统一管理在 Makefile 或者直接使用 go generate 来注释。

go generate [-run regexp] [-n] [-v] [-x] [build flags] [file.go... | packages] 
复制代码

比如我们的 person/male.go 中,直接在 Male 接口上加上 go generate 注释即可

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 ./... 即可自动更新生成的 mock 实现。

参考资料

gomock github

Testing with GoMock: A Tutorial

using gomock for unit testing

标签: 测试