ToB企服应用市场:ToB评测及商务社交产业平台

标题: Go 单元测试之mock接口测试 [打印本页]

作者: tsx81429    时间: 2024-5-17 17:11
标题: Go 单元测试之mock接口测试
目次

一、gomock 工具介绍

gomock 是一个 Go 语言的测试框架,在现实项目中,需要进行单元测试的时间。却往往发现有一大堆依赖项。这时间就是 Gomock 大显技艺的时间了,用于编写单元测试时模拟和测试依赖于外部服务的代码。它允许你创建模拟对象(Mock Objects),这些对象可以预设期望的行为,以便在测试时模拟外部依赖,通常使用它对代码中的那些接口类型进行mock。
原来 Go 团队提供了一个 mock 工具 https://github.com/golang/mock,但在今年放弃维护了,改用 https://github.com/uber-go/mock。
二、安装

要安装 gomock,你可以使用 Go 包管理器 go get:
  1. go install go.uber.org/mock/mockgen@latest
复制代码
三、使用

首先确保你已经安装了gomock ,并且在项目中实行了go mod tidy
3.1 指定三个参数

在使用 mockgen 生成模拟对象(Mock Objects)时,通常需要指定三个主要参数:
3.2 使用命令为接口生成 mock 实现

一旦你指定了上述参数,mockgen 就会为你提供的接口生成模拟实现。生成的模拟实现将包含一个 EXPECT 方法,用于设置预期的行为,以及一些方法实现,这些实现将返回默认值或调用真实的实现。
比方,如果你的接口界说在 ./webook/internal/service/user.go 文件中,你可以使用以下命令来生成模拟对象:
  1. 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 的调用。比方:
  1. # Makefile 示例
  2. # mock 目标 ,可以直接使用 make mock命令
  3. .PHONY: mock
  4. # 生成模拟对象
  5. mock:
  6.         @mockgen -source=internal/service/user.go -package=svcmocks -destination=internal/service/mocks/user.mock.go
  7.         @mockgen -package=redismocks -destination=internal/repository/cache/redismocks/cmdable.mock.go github.com/redis/go-redis/v9 Cmdable
  8.         @go mod tidy
复制代码
最后,只要我们实行make mock 命令,就会生成mock文件。
四、接口单元测试步骤

三、小黄书Service层单元测试

这里我们已注册接口为例子,代码如下:
  1. // gmock/webook/backend/internal/web/user.go
  2. func (u *UserHandler) SignUp(ctx *gin.Context) {
  3.         type SignUpReq struct {
  4.                 Email           string `json:"email"`
  5.                 ConfirmPassword string `json:"confirmPassword"`
  6.                 Password        string `json:"password"`
  7.         }
  8.         var req SignUpReq
  9.         // Bind 方法会根据 Content-Type 来解析你的数据到 req 里面
  10.         // 解析错了,就会直接写回一个 400 的错误
  11.         if err := ctx.Bind(&req); err != nil {
  12.                 return
  13.         }
  14.         ok, err := u.emailExp.MatchString(req.Email)
  15.         if err != nil {
  16.                 ctx.String(http.StatusOK, "系统错误")
  17.                 return
  18.         }
  19.         if !ok {
  20.                 ctx.String(http.StatusOK, "你的邮箱格式不对")
  21.                 return
  22.         }
  23.         if req.ConfirmPassword != req.Password {
  24.                 ctx.String(http.StatusOK, "两次输入的密码不一致")
  25.                 return
  26.         }
  27.         ok, err = u.passwordExp.MatchString(req.Password)
  28.         if err != nil {
  29.                 // 记录日志
  30.                 ctx.String(http.StatusOK, "系统错误")
  31.                 return
  32.         }
  33.         if !ok {
  34.                 ctx.String(http.StatusOK, "密码必须大于8位,包含数字、特殊字符")
  35.                 return
  36.         }
  37.         // 调用一下 svc 的方法
  38.         err = u.svc.SignUp(ctx, domain.User{
  39.                 Email:    req.Email,
  40.                 Password: req.Password,
  41.         })
  42.         if err == service.ErrUserDuplicateEmail {
  43.                 ctx.String(http.StatusOK, "邮箱冲突")
  44.                 return
  45.         }
  46.         if err != nil {
  47.                 ctx.String(http.StatusOK, "系统异常")
  48.                 return
  49.         }
  50.         ctx.String(http.StatusOK, "注册成功")
  51. }
复制代码
实行命令,生成mock文件:
  1. mockgen -source=./webook/internal/service/user.go -package=svcmocks destination=./webook/internal/service/mocks/user.mock.go
复制代码
接着我们编写单元测试,代码如下:
  1. // gmock/webook/backend/internal/web/user_test.go
  2. package web
  3. import (
  4.         "bytes"
  5.         "context"
  6.         "errors"
  7.         "github.com/gin-gonic/gin"
  8.         "github.com/stretchr/testify/assert"
  9.         "github.com/stretchr/testify/require"
  10.         "go.uber.org/mock/gomock"
  11.         "golang.org/x/crypto/bcrypt"
  12.         "net/http"
  13.         "net/http/httptest"
  14.         "testing"
  15.         "webook/internal/domain"
  16.         "webook/internal/service"
  17.         svcmocks "webook/internal/service/mocks"
  18. )
  19. func TestEncrypt(t *testing.T) {
  20.         _ = NewUserHandler(nil, nil)
  21.         password := "hello#world123"
  22.         encrypted, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
  23.         if err != nil {
  24.                 t.Fatal(err)
  25.         }
  26.         err = bcrypt.CompareHashAndPassword(encrypted, []byte(password))
  27.         assert.NoError(t, err)
  28. }
  29. func TestNil(t *testing.T) {
  30.         testTypeAssert(nil)
  31. }
  32. func testTypeAssert(c any) {
  33.         _, ok := c.(*UserClaims)
  34.         println(ok)
  35. }
  36. func TestUserHandler_SignUp(t *testing.T) {
  37.         testCases := []struct {
  38.                 name string
  39.                 mock func(ctrl *gomock.Controller) service.UserService
  40.                 reqBody string
  41.                 wantCode int
  42.                 wantBody string
  43.         }{
  44.                 {
  45.                         name: "注册成功",
  46.                         mock: func(ctrl *gomock.Controller) service.UserService {
  47.                                 usersvc := svcmocks.NewMockUserService(ctrl)
  48.                                 usersvc.EXPECT().SignUp(gomock.Any(), domain.User{
  49.                                         Email:    "123@qq.com",
  50.                                         Password: "hello#world123",
  51.                                 }).Return(nil)
  52.                                 // 注册成功是 return nil
  53.                                 return usersvc
  54.                         },
  55.                         reqBody: `
  56. {
  57.         "email": "123@qq.com",
  58.         "password": "hello#world123",
  59.         "confirmPassword": "hello#world123"
  60. }
  61. `,
  62.                         wantCode: http.StatusOK,
  63.                         wantBody: "注册成功",
  64.                 },
  65.                 {
  66.                         name: "参数不对,bind 失败",
  67.                         mock: func(ctrl *gomock.Controller) service.UserService {
  68.                                 usersvc := svcmocks.NewMockUserService(ctrl)
  69.                                 // 注册成功是 return nil
  70.                                 return usersvc
  71.                         },
  72.                         reqBody: `
  73. {
  74.         "email": "123@qq.com",
  75.         "password": "hello#world123"
  76. `,
  77.                         wantCode: http.StatusBadRequest,
  78.                 },
  79.                 {
  80.                         name: "邮箱格式不对",
  81.                         mock: func(ctrl *gomock.Controller) service.UserService {
  82.                                 usersvc := svcmocks.NewMockUserService(ctrl)
  83.                                 return usersvc
  84.                         },
  85.                         reqBody: `
  86. {
  87.         "email": "123@q",
  88.         "password": "hello#world123",
  89.         "confirmPassword": "hello#world123"
  90. }
  91. `,
  92.                         wantCode: http.StatusOK,
  93.                         wantBody: "你的邮箱格式不对",
  94.                 },
  95.                 {
  96.                         name: "两次输入密码不匹配",
  97.                         mock: func(ctrl *gomock.Controller) service.UserService {
  98.                                 usersvc := svcmocks.NewMockUserService(ctrl)
  99.                                 return usersvc
  100.                         },
  101.                         reqBody: `
  102. {
  103.         "email": "123@qq.com",
  104.         "password": "hello#world1234",
  105.         "confirmPassword": "hello#world123"
  106. }
  107. `,
  108.                         wantCode: http.StatusOK,
  109.                         wantBody: "两次输入的密码不一致",
  110.                 },
  111.                 {
  112.                         name: "密码格式不对",
  113.                         mock: func(ctrl *gomock.Controller) service.UserService {
  114.                                 usersvc := svcmocks.NewMockUserService(ctrl)
  115.                                 return usersvc
  116.                         },
  117.                         reqBody: `
  118. {
  119.         "email": "123@qq.com",
  120.         "password": "hello123",
  121.         "confirmPassword": "hello123"
  122. }
  123. `,
  124.                         wantCode: http.StatusOK,
  125.                         wantBody: "密码必须大于8位,包含数字、特殊字符",
  126.                 },
  127.                 {
  128.                         name: "邮箱冲突",
  129.                         mock: func(ctrl *gomock.Controller) service.UserService {
  130.                                 usersvc := svcmocks.NewMockUserService(ctrl)
  131.                                 usersvc.EXPECT().SignUp(gomock.Any(), domain.User{
  132.                                         Email:    "123@qq.com",
  133.                                         Password: "hello#world123",
  134.                                 }).Return(service.ErrUserDuplicateEmail)
  135.                                 // 注册成功是 return nil
  136.                                 return usersvc
  137.                         },
  138.                         reqBody: `
  139. {
  140.         "email": "123@qq.com",
  141.         "password": "hello#world123",
  142.         "confirmPassword": "hello#world123"
  143. }
  144. `,
  145.                         wantCode: http.StatusOK,
  146.                         wantBody: "邮箱冲突",
  147.                 },
  148.                 {
  149.                         name: "系统异常",
  150.                         mock: func(ctrl *gomock.Controller) service.UserService {
  151.                                 usersvc := svcmocks.NewMockUserService(ctrl)
  152.                                 usersvc.EXPECT().SignUp(gomock.Any(), domain.User{
  153.                                         Email:    "123@qq.com",
  154.                                         Password: "hello#world123",
  155.                                 }).Return(errors.New("随便一个 error"))
  156.                                 // 注册成功是 return nil
  157.                                 return usersvc
  158.                         },
  159.                         reqBody: `
  160. {
  161.         "email": "123@qq.com",
  162.         "password": "hello#world123",
  163.         "confirmPassword": "hello#world123"
  164. }
  165. `,
  166.                         wantCode: http.StatusOK,
  167.                         wantBody: "系统异常",
  168.                 },
  169.         }
  170.         for _, tc := range testCases {
  171.                 t.Run(tc.name, func(t *testing.T) {
  172.                         ctrl := gomock.NewController(t)
  173.                         defer ctrl.Finish()
  174.                         server := gin.Default()
  175.                         // 用不上 codeSvc
  176.                         h := NewUserHandler(tc.mock(ctrl), nil)
  177.                         h.RegisterRoutes(server)
  178.                         req, err := http.NewRequest(http.MethodPost,
  179.                                 "/users/signup", bytes.NewBuffer([]byte(tc.reqBody)))
  180.                         require.NoError(t, err)
  181.                         // 数据是 JSON 格式
  182.                         req.Header.Set("Content-Type", "application/json")
  183.                         // 这里你就可以继续使用 req
  184.                         resp := httptest.NewRecorder()
  185.                         // 这就是 HTTP 请求进去 GIN 框架的入口。
  186.                         // 当你这样调用的时候,GIN 就会处理这个请求
  187.                         // 响应写回到 resp 里
  188.                         server.ServeHTTP(resp, req)
  189.                         assert.Equal(t, tc.wantCode, resp.Code)
  190.                         assert.Equal(t, tc.wantBody, resp.Body.String())
  191.                 })
  192.         }
  193. }
  194. func TestMock(t *testing.T) {
  195.         ctrl := gomock.NewController(t)
  196.         defer ctrl.Finish()
  197.         usersvc := svcmocks.NewMockUserService(ctrl)
  198.         usersvc.EXPECT().SignUp(gomock.Any(), gomock.Any()).
  199.                 Return(errors.New("mock error"))
  200.         //usersvc.EXPECT().SignUp(gomock.Any(), domain.User{
  201.         //        Email: "124@qq.com",
  202.         //}).Return(errors.New("mock error"))
  203.         err := usersvc.SignUp(context.Background(), domain.User{
  204.                 Email: "123@qq.com",
  205.         })
  206.         t.Log(err)
  207. }
复制代码
四、flags

gomock 有一些命令行标志,可以资助你控制生成过程。这些标志通常在 gomock 工具的资助下使用,比方 gomock generate。
mockgen 命令用来为给定一个包含要mock的接口的Go源文件,生成mock类源代码。它支持以下标志:
五、打桩(stub)

在测试中,打桩是一种测试术语,用于为函数或方法设置一个预设的返回值,而不是调用真实的实现。在 gomock 中,打桩通常通过设置期望的行为来实现。
比方,您可以为 myServiceMock 的 DoSomething 方法设置一个期望的行为,并返回一个特定的错误。这可以通过调用 myServiceMock.EXPECT().DoSomething().Return(error) 来实现。
在单元测试中,使用 gomock 可以资助你更有用地模拟外部依赖,从而编写更可靠和更高效的测试。通常用来屏蔽或补齐业务逻辑中的关键代码方便进行单元测试。
屏蔽:不想在单元测试用引入数据库连接等重资源
补齐:依赖的上下游函数或方法还未实现
gomock支持针对参数、返回值、调用次数、调用顺序等进行打桩操作。
参数

参数相关的用法有:
六、总结

6.1 测试用例界说

测试用例界说,最完备的环境下应该包含:
如果你要测试的方法很简单,那么你用不上全部字段。

6.2 设计测试用例

测试用例界说和运行测试用例都是很模板化的东西。测试用例就是要根据具体的方法来设计。
单元测试覆盖率做到80%以上,在这个要求之下,只有少少数的非常分支没有测试。其它测试就不是我们研发要思量的了,让测试团队去搞。

6.3 实行测试用例代码

测试用例界说出来之后,怎么实行这些用例,就已经呼之欲出了。
这里分成几个部门:

6.4 运行测试用例

测试里面的testCases是一个匿名结构体的切片,所以运行的时间就是直接遍历。
那么针对每一个测试用例:
注意运行的时间,先调用了t.Run,并且传入了测试用例的名字。

6.5 不是全部的场景都很好测试

即便你的代码写得非常好,但是有一些场景根本上不可能测试到。如图中的error分支,就是属于很难测试的。
由于bcrypt包你控制不住,Generate这个方法只有在超时的时间才会返回error。那么你不测试也是可以的,代码review可以确保这边正确处理了error。
记住:没有测试到的代码,一定要认真review。

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

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。




欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/) Powered by Discuz! X3.4