前言

由于项目时间比较紧, 我本来是没有打算写一篇文章来介绍mockery的, 但是无奈网上介绍mockery的文章比数量上较少(截至2023-04-27), 而且很多文章都过期了. 一方面由于golang更新比较快, 网上解释使用go get 安装mockery的, 到了go 1.6以后都安装不了. 另一方面mockery自身更新也比较快, 很多文章介绍的一些用法在新的版本中已经不灵了, 比如生成mock对象的命令选项-name已经调整为--name, -dir的意义也发生了变化等等, 出现了很多差异的地方.

所以本着稳扎稳打的原则, 不得不放慢脚步, 停下来把golang mock这一块的知识库补充完整.

mockery介绍

Mockery是一个用于生成Golang接口的Mock的工具. Mockery可以帮助您在测试期间模拟依赖, 以便更轻松地测试代码. Mockery v2是Mocker的最新版本.

mockery 各版本之间的区别

Mockery v1是Mockery的最初版本, 它支持生成带有单个返回值的函数和方法的Mock. Mockery v2和v3支持生成带有多个返回值的函数和方法的Mock, Mockery v3还支持生成带有可变参数的函数和方法的Mock.

另外Mockery v2的CLI在v1的基础上做了一些增强, 以下是Mockery v2新增的一些命令和选项:

  • –version: 显示Mockery的版本号.
  • –debug: 启用调试模式, 以便在生成Mock时输出更多信息.
  • –all: 生成所有接口的Mock, 而不仅仅是在命令行中指定的接口.
  • –recursive: 递归查找指定目录中的所有接口, 并生成它们的Mock.
  • –output: 指定生成Mock的输出目录.
  • –Case: 指定生成Mock时使用的命名约定(例如, snake_case或camelCase)

此外, Mockery v2还提供了一些新的命令, 例如mockery init, 它可以帮助我们在项目中设置Mockery. 执行mockery init命令将在当前目录中创建一个名为.mockery.yml的文件, 该文件包含Mockery的默认配置选项. 您可以编辑此文件以自定义Mockery的行为和输出.

例如您可以使用.mockery.yml文件来指定生成Mock时使用的命名规范, 包名, 注释等. 你还可以使用.mockery.yml文件来指定要生成Mock的接口和结构体名称, 以及要生成Mock的目录和文件名. 在V2中我们可以将一些运行mockery时需要指定的选项配置到.mockery

相对于Mockery v2而言, Mockery V3对Golang新版本的一些新特性支持更好一些, 例如: 支持Go 1.17中引入的新特性, 如泛型, 嵌入式接口, 以及Go 1.18中引入的新特性泛型类型参数, 嵌入式结构体, 嵌入式接口和结构体的混合使用, 类型别名等等.

安装Mockery

安装mockery比较简单. 在Golang 1.16及以上的版本需要使用go install 安装prebuilt(也就是binary的程序)的Mockery工具, 如果使用的是golang 1.16以前的版本仍然使用go get 来安装.

go install

go install github.com/vektra/mockery/v2@v2.25.0

这里我安装的是mockery v2当前最新版本2.25.0版本, 版本信息可以在Mockery的release notes页面找到

Docker
Mockery也可以结合docker使用

下载docker image

docker pull vektra/mockery

使用Mockery生成Mock

docker run -v "$PWD":/src -w /src vektra/mockery --all

Homebrew

在macOS上可以使用Hombrew来安装, 安装方法如下:

brew install mockery
brew upgrade mockery

Mockery CLI的使用

前面我们讲了Mockery是一个生成Mock的工具, 那么如何使用它呢, 这里就讲一讲Mockery CLI的用法.

讲解的过程中我们遵循由浅入深的规则. 先从简单的示例开始.

为某个特定的接口创建mock
这里假设我们有一个GreetingService的接口, 我们要为其创建mock

mockery --name GreetingService

我们可以使用 –name来指定我们需要生成mock的interface 由于我们没有指定查找GreetingService的目录, 所有我们要切换到与GreetingService同级的目录执行该命令.

为多个接口生成mock
在项目中往往不只一个接口, 如果我们需要为多个接口生成mock应该怎么做呢? 下面即是使用mockery为多个接口生成mock的例子. 这里假设我们有两个接口GreetingService 和 OrderService 并且都处在项目根目录下.

mockery --name "GreetingService|OrderService"

同时我们也可以使用正则表达式来指导接口, 例如我们可以将上面的命令使用正则表达式简化一下, 因为它们的名字中都含有Service, 所以我们可以利用这个命名规范带来的便利. 正则表达式的语法不在本教程的讲解范围之内, 可以执行搜索相关主题了解.

mockery --name ".*Service"

甚至, 由于我们举的例子中只有两个接口, 在实际项目中我们也许会有这样的需求, 那就是为当前目录下所有的接口生成mock或更新mock. 那我们就可以这样做.

mockery --all

指定查找service的路径

上面我们有一个假定, 多个接口都处在同一个目录, 而且都在根目录下. 这显然不符合项目实际, 在真实项目中, 往往接口是有层次结构并按类别分类存放的.

这里假设GreetingService在目录下的greeting目录下, 而OrderService在order目录下. 那么我们可以使用–dir选项来指定查找路径.

mockery --dir greeting --dir order --name "GreetingService|OrderService"

当然如果接口一多, 项目层次变深, 命令会变得很冗长, 这时我们可以使用-r或–recursive在当前目录的所有子目录中递归查找接口, 例如

mockery -r --name "GreetingService|OrderService"

这样就可以很好的解决命令冗长琐碎的问题, 另外就我个人见解,实际上–recursive这种选项可以做成默认行为, 我不知道mockery为什么不这样做.

为依赖包中的接口生成mock

有时我们的项目不仅仅需要mock 项目自身的接口, 有时也需要mock依赖包中的接口. 例如我们需要模拟sql.Result 这个接口.

此时我们可以使用–srcpkg这个选项.

mockery --srcpkg database/sql --name=Result

修改输出目录

mockery默认的输出目录为项目根目录的mocks文件夹, 我们可以使用–output这个选项改变默认的output文件夹, 也可以使用–outpkg改变默认的包名

mockery -r --output mymock --name "GreetingService|OrderService"

改变默认package那么

mockery -r --output mymock --outpkg mymock --name "GreetingService|OrderService"

更多关于mockery使用, 可以使用mockery –help或查看官方文档.

mockery mock实战

这里依然以之前我的关于golang单元测试的中所使用的范例为例, 讲解使用mockery如何简化我们的测试.

实现代码

我们创建一个非常简单的服务,如下所示:

GreetingService是一个向用户打招呼的服务。其由两种问候方式: Greet()根据设置的语言向用户打招呼 GreetDefaultMessage()将使用默认消息向用户打招呼致意,不涉及到语言设置. 在GreetingService内部,Greet()将调用db.FetchMessage(lang),GreetDefaultMessage()将呼叫db.FetchDefaultMessage()。我们可以在真实场景想象的样子,db类是调用真实数据库的类。因此,我们需要在测试中使用mock来避免测试调用实际的数据库。golang中没有class的概念,但我们可以认为struct行为与类是等效的。

首先我们定义一个名为service包。然后,我们将创建一个dv结构及其接口,并将其命名为db。

DB.go

package service

type db struct{}

// DB is fake database interface.
type DB interface {
    FetchMessage(lang string) (string, error)
    FetchDefaultMessage() (string, error)
}

然后我们将创建GreetingService接口和实现一个调用DB接口的greeter struct。greeter struct构造函数第二个参数接收lang参数。

type greeter struct {
    database DB
    lang     string
}

// GreetingService is service to greet your friends.
type GreetingService interface {
    Greet() string
    GreetInDefaultMsg() string
}

为了使数据库结构实现数据库接口,我们将添加所需的方法,并使用指针接收者。

func (d *db) FetchMessage(lang string) (string, error) {
    // in real life, this code will call an external db
    // but for this sample we will just return the hardcoded example value
    if lang == "en" {
        return "hello", nil
    }
    if lang == "es" {
        return "holla", nil
    }
    return "bzzzz", nil
}

func (d *db) FetchDefaultMessage() (string, error) {
    return "default message", nil
}

接下来,我们需要实现greeter的方法Greet()和GreetInDefaultMsg()。

func (g greeter) Greet() string {
    msg, _ := g.database.FetchMessage(g.lang) // call database to get the message based on the lang
    return "Message is: " + msg
}

func (g greeter) GreetInDefaultMsg() string {
    msg, _ := g.database.FetchDefaultMessage() // call database to get the default message
    return "Message is: " + msg
}

上面,greetiner方法将会调用DB以获取实际消息。 为Greeter和DB创建一个工厂方法用于创建greeter和db实例。

func NewDB() DB {
    return new(db)
}

func NewGreeter(db DB, lang string) GreetingService {
    return greeter{db, lang}
}

在实现的最后一部分,我们将编写一个主函数来运行服务。

package main

import (
    "fmt"
    "testify-mock/service"
)

func main() {
    d := service.NewDB()

    g := service.NewGreeter(d, "en")
    fmt.Println(g.Greet()) // Message is: hello
    fmt.Println(g.GreetInDefaultMsg()) // Message is: default message

    g = service.NewGreeter(d, "es")
    fmt.Println(g.Greet()) // Message is: holla

    g = service.NewGreeter(d, "random")
    fmt.Println(g.Greet()) // Message is: bzzzz
}

运行后的输出如下。

$ go run main.go
Message is: hello
Message is: default message
Message is: holla
Message is: bzzzz

Mock和测试

之前的博客中, 我们是手写Mock代码, 这次我们的Mock部分借助Mockery帮我们自动生成.

在生成Mock之前, 我们需要安装Mockery.

首先我们使用前面学到的知识为GreetingService生成mock

mockery -r --name "GreetingService|DB"

运行成功后, mockery帮我们生成了, 想要的mock如下

mocks/GreetingService.go

package mocks

import mock "github.com/stretchr/testify/mock"

// GreetingService is an autogenerated mock type for the GreetingService type
type GreetingService struct {
    mock.Mock
}

// Greet provides a mock function with given fields:
func (_m *GreetingService) Greet() string {
    ret := _m.Called()

    var r0 string
    if rf, ok := ret.Get(0).(func() string); ok {
        r0 = rf()
    } else {
        r0 = ret.Get(0).(string)
    }

    return r0
}

// GreetInDefaultMsg provides a mock function with given fields:
func (_m *GreetingService) GreetInDefaultMsg() string {
    ret := _m.Called()

    var r0 string
    if rf, ok := ret.Get(0).(func() string); ok {
        r0 = rf()
    } else {
        r0 = ret.Get(0).(string)
    }

    return r0
}

type mockConstructorTestingTNewGreetingService interface {
    mock.TestingT
    Cleanup(func())
}

// NewGreetingService creates a new instance of GreetingService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewGreetingService(t mockConstructorTestingTNewGreetingService) *GreetingService {
    mock := &GreetingService{}
    mock.Mock.Test(t)

    t.Cleanup(func() { mock.AssertExpectations(t) })

    return mock
}

mocks/DB.go

package mocks

import mock "github.com/stretchr/testify/mock"

// DB is an autogenerated mock type for the DB type
type DB struct {
    mock.Mock
}

// FetchDefaultMessage provides a mock function with given fields:
func (_m *DB) FetchDefaultMessage() (string, error) {
    ret := _m.Called()

    var r0 string
    var r1 error
    if rf, ok := ret.Get(0).(func() (string, error)); ok {
        return rf()
    }
    if rf, ok := ret.Get(0).(func() string); ok {
        r0 = rf()
    } else {
        r0 = ret.Get(0).(string)
    }

    if rf, ok := ret.Get(1).(func() error); ok {
        r1 = rf()
    } else {
        r1 = ret.Error(1)
    }

    return r0, r1
}

// FetchMessage provides a mock function with given fields: lang
func (_m *DB) FetchMessage(lang string) (string, error) {
    ret := _m.Called(lang)

    var r0 string
    var r1 error
    if rf, ok := ret.Get(0).(func(string) (string, error)); ok {
        return rf(lang)
    }
    if rf, ok := ret.Get(0).(func(string) string); ok {
        r0 = rf(lang)
    } else {
        r0 = ret.Get(0).(string)
    }

    if rf, ok := ret.Get(1).(func(string) error); ok {
        r1 = rf(lang)
    } else {
        r1 = ret.Error(1)
    }

    return r0, r1
}

type mockConstructorTestingTNewDB interface {
    mock.TestingT
    Cleanup(func())
}

// NewDB creates a new instance of DB. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewDB(t mockConstructorTestingTNewDB) *DB {
    mock := &DB{}
    mock.Mock.Test(t)

    t.Cleanup(func() { mock.AssertExpectations(t) })

    return mock
}

Mock无参方法

在上一节中, 我们使用mockery cli创建了一个DB的mock struct, 现在我们可以在测试中使用它了.

在DB interface上有一个不带参数的方法FetchDefaultMessage, 我们想要在测试中模拟它. 我们可以像下面这样创建一个模拟对象:

package service_test

import (
    "mocks"
    "service"
    "testing"

    "github.com/stretchr/testify/assert"
)

func TestMockMethodWithoutArgs(t *testing.T) {
    theDBMock := &mocks.DB{}                                         // create the mock
    theDBMock.On("FetchDefaultMessage").Return("foofofofof", nil)    // mock the expectation
    g := service.NewGreeter(theDBMock, "en")                         // create greeter object using mocked db
    assert.Equal(t, "Message is: foofofofof", g.GreetInDefaultMsg()) // assert what actual value that will come
    theDBMock.AssertNumberOfCalls(t, "FetchDefaultMessage", 1)       // we can assert how many times the mocked method will be called
    theDBMock.AssertExpectations(t)                                  // this method will ensure everything specified with On and Return was in fact called as expected
}

在上面的代码中, 我们创建了一个dbMock对象, 并使用On方法指定了要模拟的方法FetchDefaultMessage(). 然后, 我们使用Return方法指定了模拟方法的返回值. 当该方法被调用时, 将返回我们指定的模拟值.

5. Mock带参数的方法

在上一节中, 我们已经了解了如何模拟没有参数的方法. 在这一节中, 我们将学习如何模拟带有参数的方法.

在DB interface上有一个带参数的方法FetchMessage(lang string), 我们想要在测试中模拟它. 我们可以像下面这样创建一个模拟对象:

func TestMockMethodWithArgs(t *testing.T) {
    theDBMock := &mocks.DB{}
    theDBMock.On("FetchMessage", "sg").Return("lah", nil) // if FetchMessage("sg") is called, then return "lah"
    g := service.NewGreeter(theDBMock, "sg")
    assert.Equal(t, "Message is: lah", g.Greet())
    theDBMock.AssertExpectations(t)
}

总结

在本文中我们介绍了mockery这个mock工具, 以及它的使用方法, 另外列出了两个mockery结合testify进行单元测试的实例, 希望对您有帮助.

转自:https://studygolang.com/articles/36131#reply0