Contents

Gomock 实战指南:提升 Go 代码测试质量

本文将介绍 Gomock 的基本概念和用法,并提供一些最佳实践,帮助您利用 Gomock 来提升 Go 代码的测试质量。

0. 引言

在软件开发中,测试是确保代码质量和可靠性的关键步骤。良好的测试覆盖率可以减少 bug 的产生,增强代码的可维护性和可读性。然而,对于复杂的代码和依赖关系,测试变得更加具有挑战性。

依赖太多了,臣妾做不到啊,😂

在这种情况下,使用 Gomock 这样的测试工具可以大大提升我们编写高质量单元测试的效率和可靠性。本文将介绍 Gomock 的基本概念和用法,并提供一些最佳实践,帮助您利用 Gomock 来提升 Go 代码的测试质量。

1. 什么是 gomock

gomock 是官方提供的 mock 框架,同时还提供了 mockgen 工具用来辅助生成测试代码。

官方描述如下:

GoMock is a mocking framework for the Go programming language. It integrates well with Go’s built-in testing package, but can be used in other contexts too.

gomock 主要是为了帮助我们更好的进行代码测试。

gomock 包括以下 3 个核心概念:

  • 1)控制器(Controller):控制器是 Gomock 的核心组件,用于创建和管理模拟对象。它可以跟踪模拟对象的方法调用,并验证预期的行为。
  • 2)模拟对象(Mock Object):模拟对象是对实际对象的模拟,用于模拟外部依赖的交互。在 Gomock 中,通过在控制器上创建模拟对象,我们可以定义模拟对象的行为和预期。
  • 3)预期行为(Expected Behavior):预期行为是在测试中对模拟对象方法的调用和返回值进行设定。通过设置预期行为,我们可以定义模拟对象应该如何被调用,以及在调用时应该返回什么结果。

简而言之,通过使用模拟对象和预期行为,我们可以模拟外部依赖的行为,使得测试更加独立和可控。

2. 为什么需要 gomock

gomock 主要为了解决测试时遇到的依赖问题。当待测试的函数/对象的依赖关系很复杂,并且有些依赖不能直接创建,例如数据库连接文件I/O等。这种场景就非常适合使用 mock/stub 测试。

简单来说,就是用 mock 对象模拟依赖项的行为,以屏蔽待测试方法对外部的依赖

demo

假设我们需要对下面这个 QueryUser 方法做测试:

type IUser interface {
	Get(id string) (User, error)
}
type User struct {
	Username string
	Password string
}

var ErrEmptyID = errors.New("id is empty")

func QueryUser(db IUser, id string) (User, error) {
	if id == "" {
		return User{}, ErrEmptyID
	}
	return db.Get(id)
}

在不使用 gomock 的时候,我们需要真的启动一个数据库,并往数据库里写入一些测试数据才能开始测试。

这种测试方式缺点比较明显:

  • 1)需要准备一个复杂的外部环境:正常情况下需要启动一个数据库,然后写入测试数据,才能测试,因此整个流程比较复杂
    • 如果不管依赖 db 一个组件,还依赖其他 mq、cache 之类的组件,那么整个流程就更加复杂了
  • 2)测试结果会被外部环境干扰:而且数据库查询失败也会导致 QueryUser 返回错误,因此不好区分到底是 QueryUser 的逻辑有问题,还是数据库的查询有问题。
    • 失败可能是数据库连不上,或者说数据库报错了,也有可能是 QueryUser 逻辑有问题,返回的错误
    • 查询不到用户可能是我们方法写的有问题(比如 id 传错了),也可能是数据库里真的没有这个用户

因此测试的时候我们希望尽量不依赖外部环境,同时也不要被依赖的方法干扰到测试结果。

而 gomock 正是为了解决这些问题而生。

gomock 的作用

小结一下,gomock 有以下作用:

  • 1)消除外部依赖:在单元测试中,我们希望集中关注待测试的单元,而不是受其影响的外部依赖。通过使用 Gomock,我们可以轻松创建模拟对象来替代真实的外部依赖,从而将被测单元与其依赖解耦。
  • 2)简化测试设置和验证:Gomock 提供了简洁的 API,可以用来设置预期行为和验证模拟对象的调用。我们可以设定模拟对象的方法应该被调用多少次、以及在调用时应该返回什么结果。然后,通过调用控制器的验证方法,我们可以确保模拟对象的调用符合预期。
  • 3)提高测试代码的可读性和可维护性:使用 Gomock,我们可以编写更简洁、更可读的测试代码。通过定义模拟对象的预期行为,测试人员可以清晰地了解待测试单元的行为和逻辑,而无需深入研究外部依赖的实现细节。

3. 基本使用

以前面的 QueryUser 方法为例,演示一下如何使用 gomock 工具。以下是一些示例代码,展示了不同的测试情况:

具体使用大致分为两个步骤:

  • 1)生成 mock 代码:通过 mockgen 工具根据接口(go 里的 interface)生成 mock 代码
  • 2)注入具体逻辑:然后由于 mock 工具是不知道我们的具体逻辑的,因此需要在使用的时候通过指定具体的请求参数以及该参数对应的响应来注入具体的逻辑。

安装

首先使用以下命令安装 gomock 库以及 mockgen 工具

go get -u github.com/golang/mock/gomock
go install github.com/golang/mock/mockgen

检查是否安装成功

$ mockgen -version
1.6.0

生成 mock 代码

首先使用 mockgen 工具生成 mock 代码

命令如下:

# 语法: mockgen [-flag] path interface
mockgen -source=user.go -destination=mock_user.go -package mock i-go/test/mock IUser

各个参数具体含义:

  • -source:源文件名字
  • -destination:生成的 mock 文件名,可以指定路径,默认在当前目录
    • -destination=mockdest/mock_user.go :会在当前目录下在创建一个 mockdest 目录
    • -destination=../mockdest/mock_user.go :会在当前目录同级在创建一个 mockdest 目录
  • -package:生成的 mock 文件中的 go package
  • i-go/test/mock:mock 命令的工作目录,源文件指定的时 user.go ,那么完整就是 i-go/test/mock/user.go
  • IUser:要生成 mock 代码的接口名,可以指定多个以逗号分隔
    • 比如:Iuser,IOrder

小技巧:go-generate

当然了在 go 里推荐使用 go-generate 工具,以注释形式直接把 mock 命令写到对应接口这里,go 会把 //go:generate 后根据内容作为命令执行

//go:generate mockgen -package mock -destination mock_user.go i-go/test/mock IUser
type IUser interface {
	Get(id string) (User, error)
}

需要执行的直接在项目根目录执行

go generate ./...

即可触发所有的 generate,这样就不需要手动为每个接口执行生成了。

生成的代码如下:

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

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

import (
	reflect "reflect"

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

// MockIUser is a mock of IUser interface.
type MockIUser struct {
	ctrl     *gomock.Controller
	recorder *MockIUserMockRecorder
}

// MockIUserMockRecorder is the mock recorder for MockIUser.
type MockIUserMockRecorder struct {
	mock *MockIUser
}

// NewMockIUser creates a new mock instance.
func NewMockIUser(ctrl *gomock.Controller) *MockIUser {
	mock := &MockIUser{ctrl: ctrl}
	mock.recorder = &MockIUserMockRecorder{mock}
	return mock
}

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

// Get mocks base method.
func (m *MockIUser) Get(id string) (User, error) {
	m.ctrl.T.Helper()
	ret := m.ctrl.Call(m, "Get", id)
	ret0, _ := ret[0].(User)
	ret1, _ := ret[1].(error)
	return ret0, ret1
}

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

首先是有一个 MockIUser 的结构体实现了 IUser 接口。

比较重要的是EXPECT()方法,该方法返回一个允许调用者设置期望返回值的对象,也就是我们前面提到的注入具体逻辑的步骤。

注入具体逻辑

生成好 mock 代码后,只需要写 test 方法的是使用EXPECT方法往 mock 代码里注入具体逻辑就好了。

测试用例如下:

func TestQueryUser(t *testing.T) {
	mockCtrl := gomock.NewController(t)
	defer mockCtrl.Finish()
	mockDB := NewMockIUser(mockCtrl)

	t.Run("empty id", func(t *testing.T) {
		mockDB.EXPECT().Get("").Return(User{}, ErrEmptyID).Times(0)
		user, err := QueryUser(mockDB, "")
		require.Equal(t, err, ErrEmptyID)
		require.Empty(t, user)
	})

	t.Run("normal id", func(t *testing.T) {
		targetUser := User{
			Username: "tom",
			Password: "pwd",
		}
		mockDB.EXPECT().Get("tom").Return(targetUser, nil).Times(1)
		user, err := QueryUser(mockDB, "tom")
		require.NoError(t, err)
		require.Equal(t, user, user)
	})
}

首先是 NewController 这个是固定写法,创建一个 ctrl 对象,它代表 mock 生态系统中的顶级控件。定义了 mock 对象的范围、生命周期和期待值。另外它在多个 goroutine 中是安全的。

进行 mock 用例的期望值断言,一般会使用 defer 延迟执行,以防止我们忘记这一操作,该方法会检测 mock 代码是否有按照预期情况执行的,没有的话会直接报错。

	mockCtrl := gomock.NewController(t)
	defer mockCtrl.Finish()

然后就是创建实现了 IUser 接口的 Mock 实例

mockDB := NewMockIUser(mockCtrl)

最后终于到了注入具体逻辑的方法了

mockDB.EXPECT().Get("").Return(User{}, ErrEmptyID).Times(0)

通过 EXPECT 方法指定请求参数及其对应的返回值,这里就是当调用 Get 方法传递的 id 为空时,会返回一个 ErrEmptyID 的 err,最后的 Times(0) 表示这种情况下 Get 方法会被调用 0 次,因为如果真的为空直接就返回错误了。

核心逻辑就在这里,指定入参和返回值,然后后续调用的时候来测试是否真的安装这个逻辑运行。

这里直接指定了 Get 方法的入参和返回值,就不需要真的去连接数据库了,起到了 mock 的效果。

后续则是测试逻辑

		user, err := QueryUser(mockDB, "")
		require.Equal(t, err, ErrEmptyID)
		require.Empty(t, user)

调用 QueryUser 并传递一个空的 ID,最后肯定会得到一个 ErrEmptyID 错误,且返回的 User 是空对象。

对多种情况都做了覆盖,正常 id 的情况测试用例如下:

	t.Run("normal id", func(t *testing.T) {
		targetUser := User{
			Username: "tom",
			Password: "pwd",
		}
		mockDB.EXPECT().Get("tom").Return(targetUser, nil).Times(1)
		user, err := QueryUser(mockDB, "tom")
		require.NoError(t, err)
		require.Equal(t, user, user)
	})

指定输入 id 为 tom 的时候返回 targetUser 这个用户,然后调用 QueryUser 参数传入 tom,判断最终返回的 user 是不是和 targetUser 一致。

最后测试一下用户真的不存在的情况

	t.Run("not exist user", func(t *testing.T) {
		mockDB.EXPECT().Get("not_exist").Return(User{}, ErrUserNotFond).Times(1)
		user, err := QueryUser(mockDB, "not_exist")
		require.Equal(t, err, ErrUserNotFond)
		require.Empty(t, user)
	})

使用 EXPECT 方法指定 db.Get 传入 not_exist 这个 id 的时候返回 ErrUserNotFond 错误。

然后用 not_exist 作为参数去调用 QueryUser,理论上也会返回 ErrUserNotFond 错误。

核心逻辑就在这里,指定入参和返回值,然后后续调用的时候来测试是否真的安装这个逻辑运行。

NewUser(mockMale):创建 User 实例,值得注意的是,在这里注入了 mock 对象,因此实际在随后的 user.GetUserInfo(id) 调用(入参:id 为 1)中。它调用的是我们事先模拟好的 mock 方法

ctl.Finish():进行 mock 用例的期望值断言,一般会使用 defer 延迟执行,以防止我们忘记这一操作

测试

最后则是用 go test 命令执行测试

go test
// 可通过设置 `-cover` 标志符来开启覆盖率的统计
go test -cover
// 可视化
// 1.生成测试覆盖率的 profile 文件
go test  -coverprofile=cover.out
// 2.利用 profile 文件生成可视化界面
go tool cover -html=cover.out

执行 go test 看一下,因为分支逻辑比较少,所以达到了 100% 覆盖率。

❯ go test -cover
PASS
        i-go/test/mock  coverage: 100.0% of statements
ok      i-go/test/mock  0.305s

4. 高级功能与技巧

除了基础用法之外,gomock 还提供一些高级用法,以应对更加复杂的测试场景。

匹配参数

调用m.EXPECT()时可以指定参数满足的条件,将原方法的参数替换为对应的Matcher

  • gomock.Eq(x): 通过反射匹配到指定的类型值,而不需要手动设置
  • gomock.Not(x) 不等于x
  • gomock.Any() 匹配任何值
  • gomock.Nil() 值是nil
  • gomock.Len(i) 长度为i

例如:

mockDB.EXPECT().Get(gomock.Eq("123")).Return(user, nil)
mockDB.EXPECT().Delete(gomock.Any()).Return(nil)

在上述示例中,我们使用了 gomock.Eq("123") 来匹配参数值为 "123" 的调用,并设置了相应的返回值。另外,我们还使用了 gomock.Any() 来匹配任意参数值的调用。

返回值

m.EXPECT()返回的是*gomock.Call类型的值,可使用以下方法指定被调用时的行为:

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

注意:这些方法的返回值仍然是 *Call 类型,因此可以链式调用。

如果没有指定任何返回值则返回零值

调用次数

可以通过 Times 相关方法来指定方法被调用的次数。

如前面 demo 里参数为空的时候直接返回错误了,都不会调用到 mock 的方法,因此指定了 Times(0),如果在测试的时候发现 mock 有被调用到则说明逻辑有问题。

*Call的以下方法用于断言方法的调用次数

  • Times(n) 被调用n次
  • MinTimes(n) 至少被调用n次
  • MaxTimes(n) 至多被调用n次
  • AnyTimes() 可以被调用任意次(包括0次)

调用顺序

在某些情况下,我们可能需要验证模拟对象方法的调用顺序。Gomock 提供了gomock.After以及 gomock.InOrder 函数,用于设定方法调用的顺序。

以下是一个示例代码,演示了如何使用 gomock.After 来验证调用顺序:

实例中指定了callFirst方法必需在callA或者callB之前被调用。

callFirst := mockDoer.EXPECT().DoSomething(1, "first this")
callA := mockDoer.EXPECT().DoSomething(2, "then this").After(callFirst)
callB := mockDoer.EXPECT().DoSomething(2, "or this").After(callFirst)

另一个函数 gomock.InOrder 则更加便捷,虽然使用起来不如gomock.After灵活,但是可以使得较长的一串调用顺序看起来更清晰。

以下是一个示例代码:

gomock.InOrder(
    mockDoer.EXPECT().DoSomething(1, "first this"),
    mockDoer.EXPECT().DoSomething(2, "then this"),
    mockDoer.EXPECT().DoSomething(3, "then this"),
    mockDoer.EXPECT().DoSomething(4, "finally this"),
)

更多详细用法见官方文档

5. 编写可 mock 的代码

写可测试的代码与写好测试用例是同等重要的,如何写可 mock 的代码呢?

设计可测试的代码结构

  • 将代码分解为小而可测试的单元:将代码分解为更小的模块或函数,每个单元负责完成一个清晰的任务。这样可以提高代码的可测试性,使单元测试更加精确和可靠。
  • 遵循单一职责原则:确保每个单元只关注特定的功能或行为,避免将多个责任耦合在一起。这样可以使单元测试更加独立和可维护。

依赖注入和接口设计策略

  • 使用接口来定义依赖关系:通过定义接口,我们可以明确模块之间的依赖关系,并使其可替换。这为后续使用模拟对象进行测试提供了便利。
  • 使用依赖注入来传递模拟对象:通过依赖注入的方式,我们可以将模拟对象传递给被测试的代码。这样,我们可以在测试中传递模拟对象,而在实际生产环境中传递真实的依赖对象。

在软件工程中,依赖注入的意思为,给予调用方它所需要的事物。 “依赖”是指可被方法调用的事物。依赖注入形式下,调用方不再直接指使用“依赖”,取而代之是“注入” 。“注入”是指将“依赖”传递给调用方的过程。在“注入”之后,调用方才会调用该“依赖”。传递依赖给调用方,而不是让让调用方直接获得依赖,这个是该设计的根本需求。 – 依赖注入 - Wikipedia

举例说明

前面的 QueryUser 方法依赖的时参数里的 IUser 入参,具体实例由调用者提供,因此可以很方便的 mock。

func QueryUser(db IUser, id string) (User, error) {
	if id == "" {
		return User{}, ErrEmptyID
	}
	return db.Get(id)
}

如果我们这样写呢:

func QueryUser(id string) (User, error) {
	if id == "" {
		return User{}, ErrEmptyID
	}
  db:= NewDB()
	return db.Get(id)
}

这种情况下用的是 QueryUser 内部的依赖,则无法进行 mock 了。

6. 小结

本文主要记录了 gomock 的基本使用流程。

  • Gomock 提供了一种简单而强大的方式来生成和管理模拟对象,帮助解耦外部依赖、控制测试环境,并提高测试效率和可靠性。
  • 它适用于需要模拟外部依赖的单元测试场景,特别是涉及复杂依赖关系、异步调用或需要对外部资源进行控制的情况。

注意:gomock 主要作用是对依赖的接口进行 mock 便于测试,而不是对接口的实现进行测试。

大致流程:

  • 1)生成 mock 代码:使用 mockgen 为你想要 mock 的接口生成一个 mock。
  • 2)注入具体逻辑:调用 EXPECT() 为你的 mock 对象设置各种期望和返回值。

批量生成:

  • 使用 go generate 调用 mockgen 实现批量生成

编写可 mock 的代码:

  • 将依赖抽象为接口,使用依赖注入降低耦合性

7. 参考

gomock 仓库

gomock 官方文档

go generate 用法

gomock 简明教程

使用 Gomock 进行单元测试