golang unit test的一些实践
CoderTh 炼气

单元测试

1. 什么是单元测试

单元测试在我们开发中是一个必不可少的一个环节,这里的单元其实指的是应用的最小可测试的部件,例如在过程化编程中,一个单元就是单个的程序、函数、过程等等,在面向对象编程中,最小单元指的就是方法、基类、超类、抽象类等中的方法。单元测试就是软件开发中对最小单位镜像正确性校验的测试工作,这个工作常常由开发人员进行编写与维护。

2.意义

  • 提高代码质量。代码测试都是为了帮助开发人员发现问题从而解决问题,提高代码质量。
  • 尽早发现问题。问题越早发现,解决的难度和成本就越低。
  • 保证重构正确性。随着功能的增加,重构(修改老代码)几乎是无法避免的。很多时候我们不敢重构的原因,就是担心其它模块因为依赖它而不工作。有了单元测试,只要在改完代码后运行一下单测就知道改动对整个系统的影响了,从而可以让我们放心的重构代码。
  • 简化调试过程。单元测试让我们可以轻松地知道是哪一部分代码出了问题。
  • 简化集成过程。由于各个单元已经被测试,在集成过程中进行的后续测试会更加容易。
  • 优化代码设计。编写测试用例会迫使开发人员仔细思考代码的设计和必须完成的工作,有利于开发人员加深对代码功能的理解,从而形成更合理的设计和结构。
  • 单元测试是最好的文档。单元测试覆盖了接口的所有使用方法,是最好的示例代码。而真正的文档包括注释很有可能和代码不同步,并且看不懂。

golang中的一些实践

1. 表格驱动测试

所谓表格驱动测试其实就是将我们的测试用例与测试程序镜像分离,将测试用例放在一个数组中,下面写一个简单的案例

SliceDelStr功能是删除slice中的s item

1
2
3
4
5
6
7
8
9
func SliceDelStr(slice []string, s string) []string {
for i, v := range slice {
if v == s {
slice = append(slice[:i], slice[i+1:]...)
break
}
}
return slice
}

他对应的表格驱动unit test如下

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
func TestSliceDelStr(t *testing.T) {
type args struct {
slice []string
s string
}
tests := []struct {
name string // case的名称(简要描述)
args args // 方法的入参
want []string // 期望的结果
}{
// case 1
// 如果还有其他的case在这个数组中添加即可
{
name: "test slice del",
args: args{
slice: []string{"a"},
s: "a",
},
want: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := SliceDelStr(tt.args.slice, tt.args.s); !reflect.DeepEqual(got, tt.want) {
t.Errorf("SliceDelStr() = %v, want %v", got, tt.want)
}
})
}
}

看起来还是蛮简单的,基本所有的单元测试都可以套用此方法,能够比较清晰的区分不同case。但是这些也是一些比较理想的情况(属于工具类的方法,这类方法不存在外部依赖),但是我们能够发现,通常的代码逻辑中会场出现一些外部依赖(依赖外部服务),当出现这类服务要怎么优雅的编写ut呢?

其实也大概分为两类:

  • 方法内部依赖底层服务,而这个服务是以interface提供出来
  • 依赖外部远程调用服务,比如可能是一些SDK提供远程调用的能力,并且这些SDK没有做很好的接口抽象

对于这两种情况,有两种处理方法

2. 接口mock

服务中如果出现interface相关的依赖可以使用gomock一键生成mock数据,下面是个简单的例子

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
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
package main

import (
"fmt"
"testing"

"github.com/golang/mock/gomock"
)

// UserRepository是一个接口,定义了查询用户数据的方法
type UserRepository interface {
FindUserByID(id int) (*User, error)
}

// User是一个结构体,表示一个用户对象
type User struct {
ID int
Name string
}

// UserService是一个服务,依赖于UserRepository来查询用户数据
type UserService struct {
repo UserRepository
}

// GetUserByID方法通过UserRepository查询用户数据,并返回用户对象
func (s *UserService) GetUserByID(id int) (*User, error) {
user, err := s.repo.FindUserByID(id)
if err != nil {
return nil, err
}
return user, nil
}

func TestUserService_GetUserByID(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

// 创建一个mock UserRepository
mockRepo := NewMockUserRepository(ctrl)

// 定义mock UserRepository的行为
mockUser := &User{ID: 1, Name: "Alice"}
mockRepo.EXPECT().FindUserByID(1).Return(mockUser, nil)

// 创建UserService,并注入mock UserRepository
svc := &UserService{repo: mockRepo}

// 调用GetUserByID方法,并断言结果是否符合预期
user, err := svc.GetUserByID(1)
if err != nil {
t.Errorf("unexpected error: %v", err)
return
}
if user == nil {
t.Error("expected user not to be nil")
return
}
if user.ID != 1 {
t.Errorf("expected user.ID to be 1, got %v", user.ID)
}
if user.Name != "Alice" {
t.Errorf("expected user.Name to be 'Alice', got '%v'", user.Name)
}
}

在上面的例子中,我们使用了gomock的NewController函数来创建一个新的Controller,这是gomock的核心概念之一。Controller用于管理所有mock对象,它可以确保mock对象的行为都按照我们的预期来执行,并在测试结束时检查所有预期是否都被满足了。然后,我们使用NewMockUserRepository函数来创建一个mock UserRepository,它是通过go generate命令生成的。我们可以使用这个mock UserRepository来模拟数据库操作。接着,我们使用EXPECT函数来定义mock UserRepository的行为。在本例中,我们定义了mock UserRepository的FindUserByID方法应该返回一个ID为1、名字为”Alice”的User对象。最后,我们创建了一个UserService,并注入mock UserRepository。然后,我们调用GetUserByID方法,并使用if语句来断言结果是否符合预期。

结合表格驱动测试

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
46
47
48
49
50
51
52
53
54
55
func TestUserService_GetUserByID(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()

// 创建一个mock UserRepository
mockRepo := NewMockUserRepository(ctrl)

// 定义测试用例
testCases := []struct {
name string
userID int
repoResult *User
repoErr error
expected *User
err error
}{
{
name: "user exists",
userID: 1,
repoResult: &User{ID: 1, Name: "Alice"},
repoErr: nil,
expected: &User{ID: 1, Name: "Alice"},
err: nil,
},
{
name: "user not found",
userID: 2,
repoResult: nil,
repoErr: errors.New("user not found"),
expected: nil,
err: errors.New("user not found"),
},
}

// 遍历测试用例
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// 设置mock UserRepository的返回值
mockRepo.EXPECT().FindUserByID(tc.userID).Return(tc.repoResult, tc.repoErr)

// 创建UserService,并注入mock UserRepository
svc := &UserService{repo: mockRepo}

// 调用GetUserByID方法,并断言结果是否符合预期
user, err := svc.GetUserByID(tc.userID)
if !reflect.DeepEqual(user, tc.expected) {
t.Errorf("expected user to be %+v, got %+v", tc.expected, user)
}
if !reflect.DeepEqual(err, tc.err) {
t.Errorf("expected error to be %v, got %v", tc.err, err)
}
})
}
}

 Comments