单元测试之gomock

1,099 阅读11分钟

简介

gomock 的由来和概述

gomock 是 Uber 公司开源的一个 mock 框架,于 2015 年开源。它是基于 Go 语言中的反射机制实现的,可以自动生成接口的 mock 对象。传统的 mock 测试需要手动编写 mock 对象的代码,而 gomock 可以根据接口描述自动生成 mock 代码,大大简化了 mock 对象的创建过程。开发人员只需提供接口描述,gomock 会生成对应的 mock struct,并在测试中直接使用。gomock 的关键优势包括:

  • 自动生成 mock 代码:不需要手写 mock 对象代码,提高开发效率
  • 类型安全:基于接口自动生成 mock,类型检查友好
  • 灵活的匹配:支持自定义 matcher,可以进行灵活的方法参数匹配
  • 丰富的 action:支持设置返回值、返回错误等 action
  • 便于调试:生成的 mock 代码简洁易读,便于调试

总体来说,gomock 是一个轻量级的 mock 框架,通过自动生成可以减少重复代码的编写,使测试代码更简洁清晰。它的类型安全、灵活的 matcher 和 action 也使 mock 测试更加便捷和可靠。gomock 已经被许多 Go 语言项目采用,是 Go 语言生态里一个流行和实用的 mock 测试框架。

为什么需要使用 gomock

  1. 效率更高,它可以自动生成 mock 代码,无需开发者手动编写;
  2. 更安全可靠,基于接口的 mock 对象类型安全,可以充分利用 Go 的接口优势;
  3. 更灵活可控,可以方便地设置 mock 方法的各种行为,提高测试场景的覆盖率;

总之,gomock 使编写单元测试更高效、更安全和更灵活,是 Go 测试不可或缺的重要框架。它良好的自动化、类型安全和行为可控性,可以大大提升 Go 测试的效率和质量。

gomock 的基本用法

安装

go install go.uber.org/mock/mockgen@latest

定义接口

创建名为 biz.go 的文件,定义下面接口

type INacosRepo interface {
   GetConfig(ctx context.Context, dataId string) interface{}
}

type IAdRepo interface {
   ESSearchAds(ctx context.Context, grade string, version, endpoint, userStatus int, preview bool, previewUserId int, previewDeviceId string) ([]model.Ad, error)
   ESCheckAdPreview(ctx context.Context, previewUserId int, previewDeviceId string) (bool, error)
   DBExpireAds(ctx context.Context) error
   ESExpireAds(ctx context.Context) error
   DBAdPreviewExpire(ctx context.Context) error
   ESAdPreviewExpire(ctx context.Context) error
}

生成 mock 对象

mockgen -source=user_pk.go -destination user_pk_mock.go -package biz

通过上面命令生成名为 biz_mock.go 的 mock 文件,代码如下所示

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

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

import (
   context "context"
   model "oralArithmetic/pkg/model"
   reflect "reflect"

   gomock "go.uber.org/mock/gomock"
)

// MockINacosRepo is a mock of INacosRepo interface.
type MockINacosRepo struct {
   ctrl     *gomock.Controller
   recorder *MockINacosRepoMockRecorder
}

// MockINacosRepoMockRecorder is the mock recorder for MockINacosRepo.
type MockINacosRepoMockRecorder struct {
   mock *MockINacosRepo
}

// NewMockINacosRepo creates a new mock instance.
func NewMockINacosRepo(ctrl *gomock.Controller) *MockINacosRepo {
   mock := &MockINacosRepo{ctrl: ctrl}
   mock.recorder = &MockINacosRepoMockRecorder{mock}
   return mock
}

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

// GetConfig mocks base method.
func (m *MockINacosRepo) GetConfig(ctx context.Context, dataId string) interface{} {
   m.ctrl.T.Helper()
   ret := m.ctrl.Call(m, "GetConfig", ctx, dataId)
   ret0, _ := ret[0].(interface{})
   return ret0
}

// GetConfig indicates an expected call of GetConfig.
func (mr *MockINacosRepoMockRecorder) GetConfig(ctx, dataId interface{}) *gomock.Call {
   mr.mock.ctrl.T.Helper()
   return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetConfig", reflect.TypeOf((*MockINacosRepo)(nil).GetConfig), ctx, dataId)
}

// MockIAdRepo is a mock of IAdRepo interface.
type MockIAdRepo struct {
   ctrl     *gomock.Controller
   recorder *MockIAdRepoMockRecorder
}

// MockIAdRepoMockRecorder is the mock recorder for MockIAdRepo.
type MockIAdRepoMockRecorder struct {
   mock *MockIAdRepo
}

// NewMockIAdRepo creates a new mock instance.
func NewMockIAdRepo(ctrl *gomock.Controller) *MockIAdRepo {
   mock := &MockIAdRepo{ctrl: ctrl}
   mock.recorder = &MockIAdRepoMockRecorder{mock}
   return mock
}

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

// DBAdPreviewExpire mocks base method.
func (m *MockIAdRepo) DBAdPreviewExpire(ctx context.Context) error {
   m.ctrl.T.Helper()
   ret := m.ctrl.Call(m, "DBAdPreviewExpire", ctx)
   ret0, _ := ret[0].(error)
   return ret0
}

// DBAdPreviewExpire indicates an expected call of DBAdPreviewExpire.
func (mr *MockIAdRepoMockRecorder) DBAdPreviewExpire(ctx interface{}) *gomock.Call {
   mr.mock.ctrl.T.Helper()
   return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DBAdPreviewExpire", reflect.TypeOf((*MockIAdRepo)(nil).DBAdPreviewExpire), ctx)
}

// DBExpireAds mocks base method.
func (m *MockIAdRepo) DBExpireAds(ctx context.Context) error {
   m.ctrl.T.Helper()
   ret := m.ctrl.Call(m, "DBExpireAds", ctx)
   ret0, _ := ret[0].(error)
   return ret0
}

// DBExpireAds indicates an expected call of DBExpireAds.
func (mr *MockIAdRepoMockRecorder) DBExpireAds(ctx interface{}) *gomock.Call {
   mr.mock.ctrl.T.Helper()
   return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DBExpireAds", reflect.TypeOf((*MockIAdRepo)(nil).DBExpireAds), ctx)
}

// ESAdPreviewExpire mocks base method.
func (m *MockIAdRepo) ESAdPreviewExpire(ctx context.Context) error {
   m.ctrl.T.Helper()
   ret := m.ctrl.Call(m, "ESAdPreviewExpire", ctx)
   ret0, _ := ret[0].(error)
   return ret0
}

// ESAdPreviewExpire indicates an expected call of ESAdPreviewExpire.
func (mr *MockIAdRepoMockRecorder) ESAdPreviewExpire(ctx interface{}) *gomock.Call {
   mr.mock.ctrl.T.Helper()
   return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ESAdPreviewExpire", reflect.TypeOf((*MockIAdRepo)(nil).ESAdPreviewExpire), ctx)
}

// ESCheckAdPreview mocks base method.
func (m *MockIAdRepo) ESCheckAdPreview(ctx context.Context, previewUserId int, previewDeviceId string) (bool, error) {
   m.ctrl.T.Helper()
   ret := m.ctrl.Call(m, "ESCheckAdPreview", ctx, previewUserId, previewDeviceId)
   ret0, _ := ret[0].(bool)
   ret1, _ := ret[1].(error)
   return ret0, ret1
}

// ESCheckAdPreview indicates an expected call of ESCheckAdPreview.
func (mr *MockIAdRepoMockRecorder) ESCheckAdPreview(ctx, previewUserId, previewDeviceId interface{}) *gomock.Call {
   mr.mock.ctrl.T.Helper()
   return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ESCheckAdPreview", reflect.TypeOf((*MockIAdRepo)(nil).ESCheckAdPreview), ctx, previewUserId, previewDeviceId)
}

// ESExpireAds mocks base method.
func (m *MockIAdRepo) ESExpireAds(ctx context.Context) error {
   m.ctrl.T.Helper()
   ret := m.ctrl.Call(m, "ESExpireAds", ctx)
   ret0, _ := ret[0].(error)
   return ret0
}

// ESExpireAds indicates an expected call of ESExpireAds.
func (mr *MockIAdRepoMockRecorder) ESExpireAds(ctx interface{}) *gomock.Call {
   mr.mock.ctrl.T.Helper()
   return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ESExpireAds", reflect.TypeOf((*MockIAdRepo)(nil).ESExpireAds), ctx)
}

// ESSearchAds mocks base method.
func (m *MockIAdRepo) ESSearchAds(ctx context.Context, grade string, version, endpoint, userStatus int, preview bool, previewUserId int, previewDeviceId string) ([]model.Ad, error) {
   m.ctrl.T.Helper()
   ret := m.ctrl.Call(m, "ESSearchAds", ctx, grade, version, endpoint, userStatus, preview, previewUserId, previewDeviceId)
   ret0, _ := ret[0].([]model.Ad)
   ret1, _ := ret[1].(error)
   return ret0, ret1
}

// ESSearchAds indicates an expected call of ESSearchAds.
func (mr *MockIAdRepoMockRecorder) ESSearchAds(ctx, grade, version, endpoint, userStatus, preview, previewUserId, previewDeviceId interface{}) *gomock.Call {
   mr.mock.ctrl.T.Helper()
   return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ESSearchAds", reflect.TypeOf((*MockIAdRepo)(nil).ESSearchAds), ctx, grade, version, endpoint, userStatus, preview, previewUserId, previewDeviceId)
}

使用 mock 对象编写测试

func (s *_Suite) TestOralPkBiz_PkRecommend() {
   ctx := context.Background()
   type args struct {
      ctx    context.Context
      userId int
      req    request.PkRecommendReq
   }
   tests := []struct {
      name     string
      args     args
      wantResp response.PkRecommendResp
      wantErr  bool
   }{
      // TODO: Add test cases.
      {
         name: "test_session_over",
         args: args{
            ctx:    ctx,
            userId: 11108,
            req: request.PkRecommendReq{
               GradeId:   "2",
               VersionId: "5",
               TermId:    "1",
            },
         },
         wantResp: response.PkRecommendResp{},
         wantErr:  false,
      },
   }
   pkInfo := preparePkConfig(ctx)
   pkRobotInfo := preparePkRobotConfig(ctx)
   s.RepoMock.INacosRepo.EXPECT().GetConfig(ctx, constant.DataIdPkConfig).Return(pkInfo).AnyTimes()
   s.RepoMock.INacosRepo.EXPECT().GetConfig(ctx, constant.DataIdPkRobotCfgConfig).Return(pkRobotInfo).AnyTimes()
   s.RepoMock.ICacheRepo.EXPECT().Set(ctx, gomock.Any(), gomock.Any(), gomock.Any()).Return(true).AnyTimes()
   s.RepoMock.IKnowledgeSearchRepo.EXPECT().SearchPkKnowledge(ctx, gomock.Any()).Return(&elastic.SearchResult{
      Hits: &elastic.SearchHits{
         Hits: []*elastic.SearchHit{
            {Source: []byte(`{"knowledgeId":"2ebf9c53589d43ce966ac52429f52337","knowledgeIdForMerge":["2ebf9c53589d43ce966ac52429f52337"],"knowledgeName":"","example":""}`)},
         },
      },
   }, nil).AnyTimes()
   s.RepoMock.IOralPkRepo.EXPECT().GetUserPkScoreById(ctx, gomock.Any()).Return(&model.UserPk{
      PkSeasonId:     "",
      UserId:         0,
      PkGrade:        2,
      PkSeasonPoints: 100,
   }, nil).AnyTimes()
   s.RepoMock.IOralPkRepo.EXPECT().GetPkQuestionByKnowledgeIDs(ctx, gomock.Any()).Return([]*model.OralArithmeticQuestion{
      {
         QuestionId: "2a44e4fb7aee4dbcbfd1f7a6f20a65d2",
      },
   }, nil).AnyTimes()
   s.RepoMock.IOralPkRepo.EXPECT().GetQuestionListByIDs(ctx, gomock.Any()).Return([]*model.OralArithmeticQuestion{
      {
         Id:                 608177,
         KnowledgeId:        "2ebf9c53589d43ce966ac52429f52337",
         QuestionId:         "2a44e4fb7aee4dbcbfd1f7a6f20a65d2",
         ContentOperated:    "5-3=\square",
         AnswerOperated:     "2",
         QuestionType:       1,
         QuestionStatus:     2,
         QuestionStatusName: "",
         Difficulty:         1,
         ParseType:          0,
      },
   }, nil).AnyTimes()
   for _, tt := range tests {
      s.T().Run(tt.name, func(t *testing.T) {
         gotResp, err := s.OralPkBiz.PkRecommend(tt.args.ctx, tt.args.userId, tt.args.req)
         if (err != nil) != tt.wantErr {
            s.T().Errorf("PkRecommend() error = %v, wantErr %v", err, tt.wantErr)
            return
         }
         s.T().Logf("PkRecommend() gotResp = %v", gotResp)
      })
   }
}

gomock 的高级用法

提升测试覆盖率

  1. 增加参数化测试可以通过设置不同的参数来增加测试场景:
func TestAdd(t *testing.T) {

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

  adder := NewMockAdder(mockCtrl)

  adder.EXPECT().Add(1, 2).Return(3) 
  adder.EXPECT().Add(3, 4).Return(7)

  // ... 
}
  1. 测试错误场景返回错误来测试错误处理:
adder.EXPECT().Add(4, 5).Return(0, errors.New("Error"))
  1. 模拟异常使用 gomock.Panic() 来模拟 panic:
adder.EXPECT().Add(4, 5).Do(gomock.Panic())
  1. 设置方法副作用可以通过 Do() 来产生副作用:
var sharedValue int
adder.EXPECT().Add(1, 2).Do(func() {
  sharedValue = 10 
})
  1. 并发测试使用 Go 并发功能并发调用:
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
  wg.Add(1)
  go func() {
    adder.Add(1, 2)
    wg.Done() 
  }()
}
wg.Wait()

模拟不同场景

  1. 模拟不同的返回值可以通过 Return 方法来设置 mock 方法的不同返回值,模拟不同的返回场景:
mockObj.EXPECT().Get(gomock.Eq(1)).Return(10) 
mockObj.EXPECT().Get(gomock.Eq(2)).Return(20)
  1. 模拟返回错误使用 Return 方法返回 error 对象可以模拟返回错误的场景:
mockObj.EXPECT().Get(gomock.Eq(3)).Return(0, errors.New("not found"))
  1. 模拟网络延迟使用 Do 方法来添加自定义行为,可以添加 sleep 来模拟网络延迟:
mockObj.EXPECT().Get(gomock.Eq(4)).Do(func() {
  time.Sleep(1 * time.Second)
  return 30
})
  1. 模拟 RPC 错误可以返回自定义的 RPC 错误:
mockObj.EXPECT().Get(gomock.Eq(5)).Return(nil, rpctypes.Errorf(codes.NotFound, "not found"))
  1. 模拟并发问题在 Do 方法中使用共享的状态变量和锁来模拟并发场景:
var mutex sync.Mutex
var count int
mockObj.EXPECT().Get(gomock.Eq(6)).Do(func() {
  mutex.Lock()
  count++
  mutex.Unlock()
  return count
})

自定义匹配器

  1. 实现 Matcher 接口
// 实现 Matcher 接口
type myMatcher struct {}

func (m *myMatcher) Matches(x interface{}) bool {
  // 匹配逻辑
}

func (m *myMatcher) String() string {
  return "myMatcher"
}
  1. 定义匹配器函数
func MyMatch(param int) gomock.Matcher {
  return &myMatcher{wanted: param} 
}
  1. 在 EXPECT 中使用自定义匹配器
mockObj.EXPECT().Foo(MyMatch(5))

此时 Foo 方法调用时会使用 MyMatch 匹配器进行参数匹配。自定义匹配器的常见场景:

  • 正则表达式匹配;
  • 对象字段匹配;
  • 复杂逻辑匹配自定义匹配器可以让我们摆脱仅通过 Equals 等有限的匹配方式,实现更加灵活和宽松的匹配逻辑。

编写易用的 wrapper

  1. 定义 interface 编写测试要用的 interface,包含待 mock 的所有方法。
  2. 实现 wrapper 实现一个结构体,内部含有 gomock 生成的 mock 对象。
  3. 封装方法在 wrapper 的方法中直接调用内部 mock 对象的方法。
  4. 提供便捷方法根据需求在 wrapper 中提供一些便捷方法,如默认返回值、可选参数等。示例:
// 1. 接口定义
type Adder interface {
  Add(int, int) int
}

// 2. 实现 wrapper
type AdderMock struct {
  MockAdder *MockAdder
}

// 3. 封装方法
func (a *AdderMock) Add(x, y int) int {
  return a.MockAdder.Add(x, y) 
}

// 4. 提供便捷方法
func (a *AdderMock) AddWithDefault() int {
  return a.Add(1, 2)
}

// 测试中使用 wrapper
mock := NewAdderMock(ctrl) 
mock.AddWithDefault()

这样可以简化 gomock 的使用代码,提高测试用例的复用性和可读性。

gomock 的实现原理

生成 mock 对象的过程

  1. 解析接口 gomock 会使用 reflect 包解析需要 mock 的接口,获取接口中的方法名、参数和返回值等信息。
  2. 生成代码根据接口信息,gomock 使用 text/template 自动生成接口方法的 mock 实现代码,包括函数体、匹配器、调用记录等。
  3. 构建对象使用 go generate 工具自动编译生成的 mock 代码,得到 mock 对象的实现。
  4. 调节行为测试时通过调用 mock 对象的 EXPECT() 等方法设置返回值、行为等,调节 mock 行为。
  5. 记录调用 mock 对象记录每次方法调用,assert 是否与预设的行为一致。

gomock 通过自动化代码生成和运行时行为控制,可以快速灵活地构造 mock 对象,减轻编写 mock 对象的工作量。其核心是通过解析接口定义自动生成针对接口的 mock 实现代码。这样可以确保 mock 对象与目标接口一致, typing 安全。

matchers 的工作原理

  1. 实现 Matcher 接口每个 matcher 都实现了 Matcher 接口,该接口定义了 Matches 和 String 两个方法。
  2. Matches 方法判断匹配当调用 Expect() 配置 mock 行为时,会用 matcher 的 Matches 方法来判断参数是否匹配。
  3. String 方法生成代码 matcher 的 String 方法返回其类型名称,gomock 使用该名称生成对应 mock 方法的代码。
  4. 生成匹配表达式针对每个 Expect() 调用和 matcher,gomock 会生成对应的匹配表达式代码。
  5. 运行时评估表达式当 mock 方法被调用时,会执行生成的匹配表达式,以判断参数是否满足预期。这样通过匹配器接口和生成的匹配代码,可以实现灵活 parameter matching,如 Eq()、Any()等。

常用的匹配器有:

  • Eq() 等于
  • Any() 任意参数
  • Nil() nil 参数
  • Not() 排除指定参数

我们也可以定制 matcher 来实现自定义匹配逻辑。

执行 mock 方法的流程

  1. 测试代码中调用 mock 对象的方法。

  2. 根据方法参数,在预设的期望(EXPECT call)中查找匹配的调用记录。

  3. 如果找到匹配的调用记录,则执行该调用记录配置的行为。

  4. 如果是返回值,直接返回预设的返回值。

  5. 如果是函数,则执行函数实现的自定义逻辑。

  6. 如果没有匹配的调用记录,则测试失败。

  7. 调用结束后,在当前的 Controller 中记录这次调用。

  8. 用户可以通过 Controller 查看方法调用次数、参数等信息来进行断言。

  9. 如果配置了调用后重置期望(After call),则重置对该方法的期望配置。

所以执行流程主要分为:

  • 查找匹配的预设期望
  • 执行已配置的行为
  • 记录调用
  • 重置期望这使得我们可以方便地设置 mock 对象的 RETURN、DO 等行为,增强测试的灵活性。

gomock 的优缺点

  • 优点:自动生成 mock,提高测试效率等
  • 缺点:依赖反射, debug 困难等

总结

gomock 的用途

  1. 单元测试 - 生成 mock 对象来隔离被测系统,进行更聚焦的测试。
  2. 集成测试 - 通过 mock 依赖来编写关键路径的集成测试。
  3. 模拟依赖 - 在本地开发时 mock 真实后端服务,使开发环境独立。
  4. 验证交互 - 使用 gomock.Controller 来记录并验证 mock 对象的交互行为。
  5. 测试不确定性 - 配置 mock 对象来模拟错误、延迟等情况。
  6. 提高覆盖率 - mock 对象可以灵活设置返回值,提高代码覆盖率。
  7. 模拟容错 - 配置各种错误情况来测试容错能力。
  8. 增强测试可控性 - 使用 gomock 可以显著提高测试用例的可控性和稳定性。

总之,gomock 适用于各类测试场景,是 Go 语言单元测试不可或缺的重要组件,可以帮助编写更优秀和可靠的测试。

参考文档

xiaoming.net.cn/2021/06/29/…

mojotv.cn/2018/12/26/…

github.com/uber-go/moc…