文章目录
一、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
命令来自动化构建过程,包括生成模拟对象。你可以创建一个
Makefile
或
make.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
文件。
四、接口单元测试步骤
- 想清楚整体逻辑
- 定义想要(模拟)依赖项的
interface
(接口) - 使用
mockgen
命令对所需mock的interface生成mock文件 - 编写单元测试的逻辑,在测试中使用mock
- 进行单元测试的验证
三、小黄书Service层单元测试
这里我们已注册接口为例子,代码如下:
// gmock/webook/backend/internal/web/user.gofunc(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.gopackage 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")funcTestEncrypt(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)}funcTestNil(t *testing.T){testTypeAssert(nil)}functestTypeAssert(c any){_, ok := c.(*UserClaims)println(ok)}funcTestUserHandler_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:"[email protected]",
Password:"hello#world123",}).Return(nil)// 注册成功是 return nilreturn usersvc
},
reqBody:`
{
"email": "[email protected]",
"password": "hello#world123",
"confirmPassword": "hello#world123"
}
`,
wantCode: http.StatusOK,
wantBody:"注册成功",},{
name:"参数不对,bind 失败",
mock:func(ctrl *gomock.Controller) service.UserService {
usersvc := svcmocks.NewMockUserService(ctrl)// 注册成功是 return nilreturn usersvc
},
reqBody:`
{
"email": "[email protected]",
"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": "[email protected]",
"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": "[email protected]",
"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:"[email protected]",
Password:"hello#world123",}).Return(service.ErrUserDuplicateEmail)// 注册成功是 return nilreturn usersvc
},
reqBody:`
{
"email": "[email protected]",
"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:"[email protected]",
Password:"hello#world123",}).Return(errors.New("随便一个 error"))// 注册成功是 return nilreturn usersvc
},
reqBody:`
{
"email": "[email protected]",
"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())})}}funcTestMock(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: "[email protected]",//}).Return(errors.New("mock error"))
err := usersvc.SignUp(context.Background(), domain.User{
Email:"[email protected]",})
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
中,打桩通常通过设置期望的行为来实现。
例如,您可以为
myServiceMock
的
DoSomething
方法设置一个期望的行为,并返回一个特定的错误。这可以通过调用
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
版权归原作者 贾维斯Echo 所有, 如有侵权,请联系我们删除。