Go 单元测试之mock接口测试

文章目录

    • [一、gomock 工具介绍](#一、gomock 工具介绍)
    • 二、安装
    • 三、使用
        • [3.1 指定三个参数](#3.1 指定三个参数)
        • [3.2 使用命令为接口生成 mock 实现](#3.2 使用命令为接口生成 mock 实现)
        • [3.3 使用make 命令封装处理mock](#3.3 使用make 命令封装处理mock)
    • 四、接口单元测试步骤
    • 三、小黄书Service层单元测试
    • 四、flags
    • 五、打桩(stub)
    • 六、总结
      • [6.1 测试用例定义](#6.1 测试用例定义)
      • [6.2 设计测试用例](#6.2 设计测试用例)
      • [6.3 执行测试用例代码](#6.3 执行测试用例代码)
      • [6.4 运行测试用例](#6.4 运行测试用例)
      • [6.5 不是所有的场景都很好测试](#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

bash 复制代码
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 文件中,你可以使用以下命令来生成模拟对象:

bash 复制代码
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 复制代码
# 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层单元测试

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

go 复制代码
// 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文件:

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

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

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

相关推荐
煎鱼eddycjy14 小时前
新提案:由迭代器启发的 Go 错误函数处理
go
煎鱼eddycjy15 小时前
Go 语言十五周年!权力交接、回顾与展望
go
不爱说话郭德纲1 天前
聚焦 Go 语言框架,探索创新实践过程
go·编程语言
0x派大星2 天前
【Golang】——Gin 框架中的 API 请求处理与 JSON 数据绑定
开发语言·后端·golang·go·json·gin
IT书架3 天前
golang高频面试真题
面试·go
郝同学的测开笔记3 天前
云原生探索系列(十四):Go 语言panic、defer以及recover函数
后端·云原生·go
秋落风声3 天前
【滑动窗口入门篇】
java·算法·leetcode·go·哈希表
0x派大星5 天前
【Golang】——Gin 框架中的模板渲染详解
开发语言·后端·golang·go·gin
0x派大星6 天前
【Golang】——Gin 框架中的表单处理与数据绑定
开发语言·后端·golang·go·gin
三里清风_7 天前
如何使用Casbin设计后台权限管理系统
golang·go·casbin