tsx81429 发表于 2024-5-17 17:11:53

Go 单元测试之mock接口测试

目次

[*]一、gomock 工具介绍
[*]二、安装
[*]三、使用

[*]

[*]3.1 指定三个参数
[*]3.2 使用命令为接口生成 mock 实现
[*]3.3 使用make 命令封装处理mock


[*]四、接口单元测试步骤
[*]三、小黄书Service层单元测试
[*]四、flags
[*]五、打桩(stub)

[*]

[*]参数


[*]六、总结

[*]6.1 测试用例界说
[*]6.2 设计测试用例
[*]6.3 实行测试用例代码
[*]6.4 运行测试用例
[*]6.5 不是全部的场景都很好测试


一、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.go3.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.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 中,打桩通常通过设置期望的行为来实现。
比方,您可以为 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状态。单元测试里面常见,集成测试一样平常没有。
[*]数据准备:每一个测试用例需要的数据。集成测试里常见。
[*]数据清理:每一个测试用例在实行完毕之后,需要实行一些数据清理动作。集成测试里常见。
如果你要测试的方法很简单,那么你用不上全部字段。
https://img2024.cnblogs.com/other/2153830/202404/2153830-20240418122329365-18763888.png
6.2 设计测试用例

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

[*]如果是单元测试:看代码,最起码做到分支覆盖。
[*]如果是集成测试:至少测完业务层面的主要正常流程和主要非常流程。
单元测试覆盖率做到80%以上,在这个要求之下,只有少少数的非常分支没有测试。其它测试就不是我们研发要思量的了,让测试团队去搞。
https://img2024.cnblogs.com/other/2153830/202404/2153830-20240418122334532-634392687.png
6.3 实行测试用例代码

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

[*]初始化 mock 控制器,每个测试用例都有独立的 mock 控制器。
[*]使用控制器 ctrl 调用 tc.mock,拿到 mock 的 UserService 和 CodeService。
[*]使用 mock 的服务初始化 UserHandler,并且注册路由。
[*]构造 HTTP 请求和相应 Recorder。
[*]发起调用 ServeHTTP。
https://img2024.cnblogs.com/other/2153830/202404/2153830-20240418122339298-822049700.png
6.4 运行测试用例

测试里面的testCases是一个匿名结构体的切片,所以运行的时间就是直接遍历。
那么针对每一个测试用例:

[*]首先调用mock部门,或者实行before。
[*]实行测试的方法。
[*]比较预期结果。
[*]调用after方法。
注意运行的时间,先调用了t.Run,并且传入了测试用例的名字。
https://img2024.cnblogs.com/other/2153830/202404/2153830-20240418122341507-663585409.png
6.5 不是全部的场景都很好测试

即便你的代码写得非常好,但是有一些场景根本上不可能测试到。如图中的error分支,就是属于很难测试的。
由于bcrypt包你控制不住,Generate这个方法只有在超时的时间才会返回error。那么你不测试也是可以的,代码review可以确保这边正确处理了error。
记住:没有测试到的代码,一定要认真review。
https://img2024.cnblogs.com/other/2153830/202404/2153830-20240418122344163-1717670183.png
小黄书单元测试代码:https://github.com/tao-xiaoxin/demo/tree/main/gotest/gmock/webook/backend

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