一、gomock 工具介绍

gomock 是一个 Go 语言的测试框架,在实际项目中,需要进行单元测试的时候。却往往发现有一大堆依赖项。这时候就是 Gomock 大显身手的时候了,用于编写单元测试时模拟和测试依赖于外部服务的代码。它允许你创建模拟对象(Mock Objects),这些对象可以预设期望的行为,以便在测试时模拟外部依赖,通常使用它对代码中的那些接口类型进行mock。

原本 Go 团队提供了一个 mock 工具 https://github.com/golang/mock,但在今年放弃维护了,改用 https://github.com/uber-go/mock

二、安装

要安装 gomock,你可以使用 Go 包管理器 go get

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

三、使用

首先确保你已经安装了gomock ,并且在项目中执行了go mod tidy

3.1 指定三个参数

在使用 mockgen 生成模拟对象(Mock Objects)时,通常需要指定三个主要参数:

  • source:这是你想要生成模拟对象的接口定义所在的文件路径。
  • destination:这是你想要生成模拟对象代码的目标路径。
  • package:这是生成代码的包名。

3.2 使用命令为接口生成 mock 实现

一旦你指定了上述参数,mockgen 就会为你提供的接口生成模拟实现。生成的模拟实现将包含一个 EXPECT 方法,用于设置预期的行为,以及一些方法实现,这些实现将返回默认值或调用真实的实现。

例如,如果你的接口定义在 ./webook/internal/service/user.go 文件中,你可以使用以下命令来生成模拟对象:

mockgen -source=./webook/internal/service/user.go -package=svcmocks destination=./webook/internal/service/mocks/user.mock.go

3.3 使用make 命令封装处理mock

在实际项目中,你可能会使用 make 命令来自动化构建过程,包括生成模拟对象。你可以创建一个 Makefilemake.bash 文件,并添加一个目标来处理 mockgen 的调用。例如:

# Makefile 示例
# mock 目标 ,可以直接使用 make mock命令
.PHONY: mock
# 生成模拟对象
mock:
@mockgen -source=internal/service/user.go -package=svcmocks -destination=internal/service/mocks/user.mock.go
@mockgen -package=redismocks -destination=internal/repository/cache/redismocks/cmdable.mock.go github.com/redis/go-redis/v9 Cmdable
@go mod tidy

最后,只要我们执行make mock 命令,就会生成mock文件。

四、接口单元测试步骤

  1. 想清楚整体逻辑
  2. 定义想要(模拟)依赖项的interface(接口)
  3. 使用mockgen命令对所需mock的interface生成mock文件
  4. 编写单元测试的逻辑,在测试中使用mock
  5. 进行单元测试的验证

三、小黄书Service层单元测试

这里我们已注册接口为例子,代码如下:

// gmock/webook/backend/internal/web/user.go
func (u *UserHandler) SignUp(ctx *gin.Context) {
type SignUpReq struct {
Email string `json:"email"`
ConfirmPassword string `json:"confirmPassword"`
Password string `json:"password"`
} var req SignUpReq
// Bind 方法会根据 Content-Type 来解析你的数据到 req 里面
// 解析错了,就会直接写回一个 400 的错误
if err := ctx.Bind(&req); err != nil {
return
} ok, err := u.emailExp.MatchString(req.Email)
if err != nil {
ctx.String(http.StatusOK, "系统错误")
return
}
if !ok {
ctx.String(http.StatusOK, "你的邮箱格式不对")
return
}
if req.ConfirmPassword != req.Password {
ctx.String(http.StatusOK, "两次输入的密码不一致")
return
}
ok, err = u.passwordExp.MatchString(req.Password)
if err != nil {
// 记录日志
ctx.String(http.StatusOK, "系统错误")
return
}
if !ok {
ctx.String(http.StatusOK, "密码必须大于8位,包含数字、特殊字符")
return
} // 调用一下 svc 的方法
err = u.svc.SignUp(ctx, domain.User{
Email: req.Email,
Password: req.Password,
})
if err == service.ErrUserDuplicateEmail {
ctx.String(http.StatusOK, "邮箱冲突")
return
}
if err != nil {
ctx.String(http.StatusOK, "系统异常")
return
} ctx.String(http.StatusOK, "注册成功")
}

执行命令,生成mock文件:

mockgen -source=./webook/internal/service/user.go -package=svcmocks destination=./webook/internal/service/mocks/user.mock.go

接着我们编写单元测试,代码如下:

// gmock/webook/backend/internal/web/user_test.go
package web import (
"bytes"
"context"
"errors"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"golang.org/x/crypto/bcrypt"
"net/http"
"net/http/httptest"
"testing"
"webook/internal/domain"
"webook/internal/service"
svcmocks "webook/internal/service/mocks"
) func TestEncrypt(t *testing.T) {
_ = NewUserHandler(nil, nil)
password := "hello#world123"
encrypted, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
t.Fatal(err)
}
err = bcrypt.CompareHashAndPassword(encrypted, []byte(password))
assert.NoError(t, err)
} func TestNil(t *testing.T) {
testTypeAssert(nil)
} func testTypeAssert(c any) {
_, ok := c.(*UserClaims)
println(ok)
} func TestUserHandler_SignUp(t *testing.T) {
testCases := []struct {
name string mock func(ctrl *gomock.Controller) service.UserService reqBody string wantCode int
wantBody string
}{
{
name: "注册成功",
mock: func(ctrl *gomock.Controller) service.UserService {
usersvc := svcmocks.NewMockUserService(ctrl)
usersvc.EXPECT().SignUp(gomock.Any(), domain.User{
Email: "123@qq.com",
Password: "hello#world123",
}).Return(nil)
// 注册成功是 return nil
return usersvc
}, reqBody: `
{
"email": "123@qq.com",
"password": "hello#world123",
"confirmPassword": "hello#world123"
}
`,
wantCode: http.StatusOK,
wantBody: "注册成功",
},
{
name: "参数不对,bind 失败",
mock: func(ctrl *gomock.Controller) service.UserService {
usersvc := svcmocks.NewMockUserService(ctrl)
// 注册成功是 return nil
return usersvc
}, reqBody: `
{
"email": "123@qq.com",
"password": "hello#world123"
`,
wantCode: http.StatusBadRequest,
},
{
name: "邮箱格式不对",
mock: func(ctrl *gomock.Controller) service.UserService {
usersvc := svcmocks.NewMockUserService(ctrl)
return usersvc
}, reqBody: `
{
"email": "123@q",
"password": "hello#world123",
"confirmPassword": "hello#world123"
}
`,
wantCode: http.StatusOK,
wantBody: "你的邮箱格式不对",
},
{
name: "两次输入密码不匹配",
mock: func(ctrl *gomock.Controller) service.UserService {
usersvc := svcmocks.NewMockUserService(ctrl)
return usersvc
}, reqBody: `
{
"email": "123@qq.com",
"password": "hello#world1234",
"confirmPassword": "hello#world123"
}
`,
wantCode: http.StatusOK,
wantBody: "两次输入的密码不一致",
},
{
name: "密码格式不对",
mock: func(ctrl *gomock.Controller) service.UserService {
usersvc := svcmocks.NewMockUserService(ctrl)
return usersvc
},
reqBody: `
{
"email": "123@qq.com",
"password": "hello123",
"confirmPassword": "hello123"
}
`,
wantCode: http.StatusOK,
wantBody: "密码必须大于8位,包含数字、特殊字符",
},
{
name: "邮箱冲突",
mock: func(ctrl *gomock.Controller) service.UserService {
usersvc := svcmocks.NewMockUserService(ctrl)
usersvc.EXPECT().SignUp(gomock.Any(), domain.User{
Email: "123@qq.com",
Password: "hello#world123",
}).Return(service.ErrUserDuplicateEmail)
// 注册成功是 return nil
return usersvc
}, reqBody: `
{
"email": "123@qq.com",
"password": "hello#world123",
"confirmPassword": "hello#world123"
}
`,
wantCode: http.StatusOK,
wantBody: "邮箱冲突",
},
{
name: "系统异常",
mock: func(ctrl *gomock.Controller) service.UserService {
usersvc := svcmocks.NewMockUserService(ctrl)
usersvc.EXPECT().SignUp(gomock.Any(), domain.User{
Email: "123@qq.com",
Password: "hello#world123",
}).Return(errors.New("随便一个 error"))
// 注册成功是 return nil
return usersvc
}, reqBody: `
{
"email": "123@qq.com",
"password": "hello#world123",
"confirmPassword": "hello#world123"
}
`,
wantCode: http.StatusOK,
wantBody: "系统异常",
},
} for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
server := gin.Default()
// 用不上 codeSvc
h := NewUserHandler(tc.mock(ctrl), nil)
h.RegisterRoutes(server) req, err := http.NewRequest(http.MethodPost,
"/users/signup", bytes.NewBuffer([]byte(tc.reqBody)))
require.NoError(t, err)
// 数据是 JSON 格式
req.Header.Set("Content-Type", "application/json")
// 这里你就可以继续使用 req resp := httptest.NewRecorder()
// 这就是 HTTP 请求进去 GIN 框架的入口。
// 当你这样调用的时候,GIN 就会处理这个请求
// 响应写回到 resp 里
server.ServeHTTP(resp, req) assert.Equal(t, tc.wantCode, resp.Code)
assert.Equal(t, tc.wantBody, resp.Body.String()) })
}
} func TestMock(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish() usersvc := svcmocks.NewMockUserService(ctrl) usersvc.EXPECT().SignUp(gomock.Any(), gomock.Any()).
Return(errors.New("mock error")) //usersvc.EXPECT().SignUp(gomock.Any(), domain.User{
// Email: "124@qq.com",
//}).Return(errors.New("mock error")) err := usersvc.SignUp(context.Background(), domain.User{
Email: "123@qq.com",
})
t.Log(err)
}

四、flags

gomock 有一些命令行标志,可以帮助你控制生成过程。这些标志通常在 gomock 工具的帮助下使用,例如 gomock generate

mockgen 命令用来为给定一个包含要mock的接口的Go源文件,生成mock类源代码。它支持以下标志:

  • -source:包含要mock的接口的文件。
  • -destination:生成的源代码写入的文件。如果不设置此项,代码将打印到标准输出。
  • -package:用于生成的模拟类源代码的包名。如果不设置此项包名默认在原包名前添加mock_前缀。
  • -imports:在生成的源代码中使用的显式导入列表。值为foo=bar/baz形式的逗号分隔的元素列表,其中bar/baz是要导入的包,foo是要在生成的源代码中用于包的标识符。
  • -aux_files:需要参考以解决的附加文件列表,例如在不同文件中定义的嵌入式接口。指定的值应为foo=bar/baz.go形式的以逗号分隔的元素列表,其中bar/baz.go是源文件,foo是-source文件使用的文件的包名。
  • -build_flags:(仅反射模式)一字不差地传递标志给go build
  • -mock_names:生成的模拟的自定义名称列表。这被指定为一个逗号分隔的元素列表,形式为Repository = MockSensorRepository,Endpoint=MockSensorEndpoint,其中Repository是接口名称,mockSensorrepository是所需的mock名称(mock工厂方法和mock记录器将以mock命名)。如果其中一个接口没有指定自定义名称,则将使用默认命名约定。
  • -self_package:生成的代码的完整包导入路径。使用此flag的目的是通过尝试包含自己的包来防止生成代码中的循环导入。如果mock的包被设置为它的一个输入(通常是主输入),并且输出是stdio,那么mockgen就无法检测到最终的输出包,这种情况就会发生。设置此标志将告诉 mockgen 排除哪个导入
  • -copyright_file:用于将版权标头添加到生成的源代码中的版权文件
  • -debug_parser:仅打印解析器结果
  • -exec_only:(反射模式) 如果设置,则执行此反射程序
  • -prog_only:(反射模式)只生成反射程序;将其写入标准输出并退出。
  • -write_package_comment:如果为true,则写入包文档注释 (godoc)。(默认为true)

五、打桩(stub)

在测试中,打桩是一种测试术语,用于为函数或方法设置一个预设的返回值,而不是调用真实的实现。在 gomock 中,打桩通常通过设置期望的行为来实现。

例如,您可以为 myServiceMockDoSomething 方法设置一个期望的行为,并返回一个特定的错误。这可以通过调用 myServiceMock.EXPECT().DoSomething().Return(error) 来实现。

在单元测试中,使用 gomock 可以帮助你更有效地模拟外部依赖,从而编写更可靠和更高效的测试。通常用来屏蔽或补齐业务逻辑中的关键代码方便进行单元测试。

屏蔽:不想在单元测试用引入数据库连接等重资源

补齐:依赖的上下游函数或方法还未实现

gomock支持针对参数、返回值、调用次数、调用顺序等进行打桩操作。

参数

参数相关的用法有:

  • gomock.Eq(value):表示一个等价于value值的参数
  • gomock.Not(value):表示一个非value值的参数
  • gomock.Any():表示任意值的参数
  • gomock.Nil():表示空值的参数
  • SetArg(n, value):设置第n(从0开始)个参数的值,通常用于指针参数或切片

六、总结

6.1 测试用例定义

测试用例定义,最完整的情况下应该包含:

  • 名字:简明扼要说清楚你测试的场景,建议用中文。
  • 预期输入:也就是作为你方法的输入。如果测试的是定义在类型上的方法,那么也可以包含类型实例。
  • 预期输出:你的方法执行完毕之后,预期返回的数据。如果方法是定义在类型上的方法,那么也可以包含执行之后的实例的状态。
  • mock:每一个测试需要使用到的mock状态。单元测试里面常见,集成测试一般没有。
  • 数据准备:每一个测试用例需要的数据。集成测试里常见。
  • 数据清理:每一个测试用例在执行完毕之后,需要执行一些数据清理动作。集成测试里常见。

如果你要测试的方法很简单,那么你用不上全部字段。

6.2 设计测试用例

测试用例定义和运行测试用例都是很模板化的东西。测试用例就是要根据具体的方法来设计。

  • 如果是单元测试:看代码,最起码做到分支覆盖。
  • 如果是集成测试:至少测完业务层面的主要正常流程和主要异常流程。

单元测试覆盖率做到80%以上,在这个要求之下,只有极少数的异常分支没有测试。其它测试就不是我们研发要考虑的了,让测试团队去搞。

6.3 执行测试用例代码

测试用例定义出来之后,怎么执行这些用例,就已经呼之欲出了。

这里分成几个部分:

  • 初始化 mock 控制器,每个测试用例都有独立的 mock 控制器。
  • 使用控制器 ctrl 调用 tc.mock,拿到 mock 的 UserService 和 CodeService。
  • 使用 mock 的服务初始化 UserHandler,并且注册路由。
  • 构造 HTTP 请求和响应 Recorder
  • 发起调用 ServeHTTP

6.4 运行测试用例

测试里面的testCases是一个匿名结构体的切片,所以运行的时候就是直接遍历。

那么针对每一个测试用例:

  • 首先调用mock部分,或者执行before。
  • 执行测试的方法。
  • 比较预期结果。
  • 调用after方法。

注意运行的时候,先调用了t.Run,并且传入了测试用例的名字。

6.5 不是所有的场景都很好测试

即便你的代码写得非常好,但是有一些场景基本上不可能测试到。如图中的error分支,就是属于很难测试的。

因为bcrypt包你控制不住,Generate这个方法只有在超时的时候才会返回error。那么你不测试也是可以的,代码review可以确保这边正确处理了error

记住:没有测试到的代码,一定要认真review

小黄书单元测试代码:https://github.com/tao-xiaoxin/demo/tree/main/gotest/gmock/webook/backend

Go 单元测试之mock接口测试的更多相关文章

  1. python笔记24-unittest单元测试之mock.patch

    前言 上一篇python笔记23-unittest单元测试之mock对mock已经有初步的认识, 本篇继续介绍mock里面另一种实现方式,patch装饰器的使用,patch() 作为函数装饰器,为您创 ...

  2. python文档2-unittest单元测试之mock.patch

    介绍mock里面另一种实现方式,patch装饰器的使用,patch() 作为函数装饰器,为您创建模拟并将其传递到装饰函数 patch简介 1.unittest.mock.patch(target,ne ...

  3. python笔记23-unittest单元测试之mock

    什么是mock unittest.mock是一个用于在Python中进行单元测试的库,Mock翻译过来就是模拟的意思,顾名思义这个库的主要功能是模拟一些东西. 它的主要功能是使用mock对象替代掉指定 ...

  4. python文档1-unittest单元测试之mock

    什么是mock unittest.mock是一个用于在Python中进行单元测试的库,Mock翻译过来就是模拟的意思,顾名思义这个库的主要功能是模拟一些东西.它的主要功能是使用mock对象替代掉指定的 ...

  5. CoreCRM 开发实录 —— 单元测试之 Mock UserManager 和 SignInManager

    单元测试的核心就是:只测试眼前的逻辑.这就要求所有的依赖项都要使用仿类来代替,也就是所谓的 Mock Object.在测试 ProfileRepository 和 AccountController ...

  6. 单元测试之Mock

    为什么需要Mock. 真实对象具有不确定的行为.所以会产生不可预测的结果. 真实对象很难被创建. 真实对象的某些行为很难被触发(如网络错误). 真实对象令程序的运行速度很慢. 真实对象有(或者是)用户 ...

  7. java单元测试之Mock静态方法

    1 public final class AmountUtil { public static String CustomFormatWith2Digits(int amount) { return ...

  8. 玩转单元测试之Testing Spring MVC Controllers

    玩转单元测试之 Testing Spring MVC Controllers 转载注明出处:http://www.cnblogs.com/wade-xu/p/4311657.html The Spri ...

  9. 玩转单元测试之WireMock -- Web服务模拟器

    玩转单元测试之WireMock -- Web服务模拟器 WireMock 是一个灵活的库用于 Web 服务测试,和其他测试工具不同的是,WireMock 创建一个实际的 HTTP服务器来运行你的 We ...

  10. [转载]单元测试之道(使用NUnit)

    首先来看下面几个场景你是否熟悉 1.你正在开发一个系统,你不断地编码-编译-调试-编码-编译-调试……终于,你负责的功能模块从上到下全部完成且编译通过!你长出一口气,怀着激动而又忐忑的心情点击界面上的 ...

随机推荐

  1. 工具 --- IL指令集解释

    引言 汇总一下所有的 .NET IL 指令,以及它们的名称.操作码值.堆栈转换行为和描述. 作为反编译IL代码时的查询字典. IL 指令集列表 以下内容来自微软官方文档,通过百度翻译API翻译为中文. ...

  2. MYSQL索引-下

    前言 丁奇老师的 MYSQL45讲 的关于索引的的两节 深入浅出索引,做个笔记 覆盖索引 插入一个实例表 mysql> create table T ( ID int primary key, ...

  3. 什么是docker的多阶段构建

    Docker多阶段构建是一种技术,允许在不同的构建阶段中使用不同的基础镜像,并只复制构建所需的文件和依赖项.这种技术旨在减少最终生成的Docker镜像的大小和运行时的资源消耗. 多阶段构建的一般工作流 ...

  4. FIR滤波器的设计和实现

    FIR的作用和价值   FIR(Finite Impulse Response)滤波器:有限长单位冲激响应滤波器,又称为非递归型滤波器,是数字信号处理系统中最基本的元件,它可以在保证任意幅频特性的同时 ...

  5. 加密与安全:非对称加密算法 RSA 1024 公钥、秘钥、明文和密文长度

    RSA 1024 是指公钥及私钥分别是 1024bit,也就是 1024/8 = 128Bytes. RSA 原理 RSA 算法密钥长度的选择是安全性和程序性能平衡的结果,密钥长度越长,安全性越好,加 ...

  6. idea的使用操作

    怎么让idea中的方法之间显示横线 点击setting 选择Editor 选择Appearance 选择Show method separators 效果: 如果设置idea的标签页可以多行显示 点击 ...

  7. Performance Improvements in .NET 8 & 7 & 6 -- Thread【翻译】

    线程 .NET 的最近版本在线程.并行.并发和异步等方面做出了巨大的改进,例如 ThreadPool 的完全重写(在 .NET 6 和 .NET 7 中),异步方法基础设施的完全重写(在 .NET C ...

  8. Csharp学习Linq

    Linq的学习 这里继续使用之前文章创建的学生类,首先简单介绍一下linq的使用. Student.cs public class Student { public int Id { get; set ...

  9. go两种遍历chan

    go中对通道chan遍历有两种方式一种是用range另一种为for select. package main import ( "fmt" "strings" ...

  10. Mysql中的锁(case篇)

    case1(表锁的读-写-读阻塞) 上篇文档中提到过 WRITE locks normally have higher priority than READ locks to ensure that ...