GoMock 使用体会

前言

本篇文章分享,分享关于gomock的使用技巧和心得。

为什么要进行单元测试?

:fox_face: 我们在日常软件开发过程中总会无意间创造一些神奇的BUG,而这些BUG的产生往往在初期编写代码的时候是看不出来的!为了尽量避免这类问题,我认为编写测试单元能够减少这类问题的出现。

:fox_face: gomock 能在测试中让你避开如需要 *db 等需要初始化操作,减少大量准备工作.

优点

  1. 提高代码质量:单元测试可以及早发现和修复代码中的错误,从而减少整体测试的时间和成本,提高代码的质量和稳定性。
  2. 减少集成错误:通过单元测试,可以将各个模块分开测试,减少模块之间的依赖性,避免在集成时出现错误。
  3. 提高代码可维护性:单元测试可以帮助开发人员快速定位和修复代码中的问题,同时也可以提供代码的文档和示例。
  4. 增加代码信心:通过编写单元测试,可以增加开发人员对代码的信心,因为他们知道他们的代码在各种情况下都能正常工作。
  5. 减少回归错误:单元测试可以在代码更改后自动运行,确保新添加的功能不会影响现有功能,从而减少回归错误。
  6. 提高可测试性:单元测试可以帮助开发人员编写更易于测试的代码,提高代码的可测试性。
  7. 增加代码覆盖率:通过单元测试,可以覆盖代码的各种情况,包括边界条件和异常处理,从而提高代码的覆盖率。

缺点

  1. 需要额外的工作量:编写单元测试需要额外的工作量,这会增加开发成本和时间。
  2. 测试环境与实际环境存在差异:单元测试通常在测试环境中进行,与实际环境可能存在差异,这可能导致测试结果不准确或不可靠。
  3. 覆盖范围有限:单元测试只能测试代码的表面问题,无法覆盖所有可能的情况,因此可能存在一些潜在的问题。
  4. 测试的稳定性和可靠性要求高:单元测试需要保证测试的稳定性和可靠性,否则测试结果可能会误导开发人员,导致错误的决策。
  5. 需要具备一定的测试技巧:编写单元测试往往需要有效的用例覆盖如:边界覆盖、异常覆盖等。

如何抉择

其实对比上面的优缺点之后,最大的问题应该在需要额外的工作量上面,因为在严格意义的软件工程生命周期中,编码和单元测试 (20%-25% )综合测试占比更是达到(30%-40%)左右,但是我们所处的工作环境大部分都是敏捷开发,小公司往往更是测试部门都没有,所给的时间也是非常紧迫,这其实对整个软件的生态都不友好,因为后续的BUG维护会占用更长时间。

总的来说:

​ 如果部门预留时间充分并且有绩效反馈的编写单元测试再好不过。

​ 如果部门着急赶项目加班连轴转的这种还是老老实实写功能吧,不然功能延期会对后面的计划造成更大的影响。

如何使用Gomock

gomock 主要由两部分组成,一个是测试库,一个是mockgen.我们需要先对待测代码使用mockgen生成待测文件,再调用gomock对待测文件进行测试.

安装

1
2
3
4
// 测试库
go get github.com/golang/mock/gomock
// mock代码生成器
go get github.com/golang/mock/mockgen

使用示例

1
2
3
4
5
6
7
# 目录结构
--- repository
--- dao
user.go // 用户操作Mysql
user.go // 例:Repository暴露接口(待测文件)
--- service
user.go // 例:用户Service层操作

repository/dao/user.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type UserDao interface{
// 例如我们定义一个 Insert 新增操作
Insert(ctx context.Context, user model.User) (uint64, error)
}

func NewUserDao(db *gorm.DB) UserDao {
return &userGORM{db: db}
}

type userGORM struct {
db *gorm.DB
}

func (u *userGORM) Insert(ctx context.Context, user model.User) (uint64, error) {
err := u.db.WithContext(ctx).Create(&user).Error
return user.ID, err
}

repository/user.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 待测文件
type UserRepo interface {
// Create 注册
Create(ctx context.Context, user domain.User) (uint64, error)
}

type userRepo struct {
dao dao.UserDao
cache cache.UserCache
}

func NewUserRepo(dao dao.UserDao, cache cache.UserCache) UserRepo {
return &userRepo{dao: dao, cache: cache}
}

func (u *userRepo) Create(ctx context.Context, user domain.User) (uint64, error) {
now := utils.GetTimeMilli()
// 创建用户
if user.ID != 0 {
return 0, errors.New("用户已存在无法创建")
}
entity := model.User{
ID: user.ID,
UserName: user.UserName,
Password: user.Password,
NickName: user.NickName,
Email: user.Email,
Phone: user.Phone,
Avatar: user.Avatar,
Ctime: now,
Utime: now,
}
return u.dao.Insert(ctx, entity)
}

service/user.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
type UserService interface {
// Register 注册
Register(ctx context.Context, user domain.User) (domain.User, error)
}

type userService struct {
repo repository.UserRepo
}

// NewUserService 注意这里的 入参是repo 实际测试过程中我们需要大量的初始化工作来拿到 repo ,但是我们用 gockgen 生成待测文件,因此可以通过生成的文件接口拿到该入参。
func NewUserService(repo repository.UserRepo) UserService {
return &userService{repo: repo}
}

func (u *userService) Register(ctx context.Context, user domain.User) (domain.User, error) {
// 账户已存在校验

// 昵称重复校验

// 对用户密码加盐Hash
hashPwd := utils.MD5V(user.Password, "salt", 10)
user.Password = hashPwd
// 创建用户
uid, err := u.repo.Create(ctx, user)
if err != nil {
return domain.User{}, err
}
user.ID = uid
return user, nil
}
  1. 首先使用mockgen生成待测文件
1
2
3
// 1.我们先切换到 /repository/
// source 指定源文件 destination 指定生成文件 package 指定生成文件包名称
mockgen -source=user.go -destination=mock_user.repo.go -package=repository
  1. 创建测试文件user_test.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// 编写测试用例
func TestUserService(t *testing.T) {
// 这里的hashPWd 是在Service 层做的。
hashPwd := utils.MD5V("yyz", "salt", 10)
testCases := []struct {
name string
mock func(ctrl *gomock.Controller) repository.UserRepo
inputUser domain.User

wantUser domain.User
wantErr error
}{
{
name: "注册成功案例",
inputUser: domain.User{UserName: "xxn", Password: "yyz", NickName: "小仙女郁郁症"},
mock: func(ctrl *gomock.Controller) repository.UserRepo {
// 这里使用生成的测试文件接口直接获得 repo 返回值,可作为Newservice的入参使用。
repo := repository.NewMockUserRepo(ctrl)
repo.EXPECT().Create(gomock.Any(), domain.User{UserName: "xxn", Password: hashPwd, NickName: "小仙女郁郁症"}).Return(uint64(1), nil)
return repo
},

wantUser: domain.User{ID: 1, UserName: "xxn", Password: hashPwd, NickName: "小仙女郁郁症"},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// 通过Mock 获得Repo构造对象
repo := tc.mock(ctrl)
svc := NewUserService(repo)
// 下边进行 Service层接口调试
user, err := svc.Register(context.Background(), tc.inputUser)
// 错误一致
assert.Equal(t, tc.wantErr, err)
if err != nil {
return
}
// 数据一致
assert.Equal(t, tc.wantUser, user)
})
}
}

总结

其实在使用 gomock 的时候我们需要清晰 那一层是需要待测的? 为什么这一层需要 gomock 的引入(因为不需要gomock我们也能测试),我们在上述的例子中对repository层user.go进行 mockgen生成待测文件就是为了绕开 dao操作中需要传入的GormDB,不然仅仅因为测试就需要大量的初始化工作这是不合理的.而这也是使用gomock的便利之处.

当然gomock还有更多的功能等待大家挖掘,以上是我的使用心得体会.


GoMock 使用体会
http://yoursite.com/2023/07/04/GoMock-使用体会/
作者
Meng-Xin
发布于
2023年7月4日
许可协议