Go单元测试与集成测试
单元测试概述
Go语言中的单元测试是对程序中最小的功能单元进行正确性验证的一种自动化测试方式。通过引用Go语言中内置的testing包就能编写单元测试了。在Go单元测试中要有以下命名规范:
-
1. 测试文件命名规范:单元测试文件一般命名为xxx_test.go,并且与要测试的源代码放在同一个文件夹下。
-
2. 测试函数命名规范:测试函数命名必须以 Test 开头,后面接上要测试的函数名称。
新建测试文件后就能开始编写测试代码了,写单元测试对每个函数进行测试,并且与预期的结果进行对比。若与预期结果不符,那么表示测试失败。Testing包提供了一些断言函数用于在测试失败时提供反馈(如t.Error()
,t.Fatal()
,assert.NoError()
等方法)。单元测试中还有性能测试、基准测试、测试覆盖率等选择,用于评估代码的性能、稳定性和覆盖率(评估测试是否全面)。单元测试中有一种模式叫Table-Driven,意思是在测试中处理多个输入和预期输出的情况;核心思想是把易变的数据从稳定的处理数据流程里分离,不直接混杂在if-else/switch-case
的多个分支。
go install go.uber.org/mock/mockgen@latest
-source
来指定接口,-package
来指定包名,-destination
来指定生成mock代码的路径。下面这条指令是将service层的user.go指定为生成mock代码的接口,将包名设置为svcmocks
,并指定生成位置与user.go同一层的mocks文件夹下。mockgen完成后要记得执行go mod tidy
整理项目中包的依赖关系。mockgen -source=./internal/service/user.go -package=svcmocks -destination=./internal/service/mocks/user.mock.go
----
go mod tidy
编写单元测试的思路与实现
单元测试简单例子
math_test.go
,将测试函数命名为TestAdd
和TestSubtract
。通过设置输入值和预期值并进行比较,若二者一致表示单元测试通过。-
1. 需要测试的代码
// math.go
func Add(a, b int) int {
return a + b
}
func Subtract(a, b int) int {
return a - b
}
-
2. 新建测试文件和测试函数下面单元测试使用的是
Table Driven
的写法,首先是创建数据表格,如输入值inputA inputB
,预期值want
,然后填充数据,如{1,1,2},{2,3,5}对应上面的表格,最后是执行全部测试用例,对比实际值和预期值。
// math_test.go
func TestAdd(t *testing.T) {
testCases := []struct {
// 输入值
intputA int
intputB int
// 预期值
want int
}{
{1, 1, 2},
{2, 3, 5},
{3, 4, 7},
}
// 执行测试
for _, tc := range testCases {
t.Run("Add", func(t *testing.T) {
if got := Add(tc.intputA, tc.intputB); got != tc.want {
t.Errorf("got %d ,want %d", got, tc.want)
}
})
}
}
func TestSubtract(t *testing.T) {
testCases := []struct {
intputA int
intputB int
want int
}{
{1, 1, 0},
{3, 2, 1},
}
for _, tc := range testCases {
t.Run("Subtract", func(t *testing.T) {
if got := Subtract(tc.intputA, tc.intputB); got != tc.want {
t.Errorf("got %d ,want %d", got, tc.want)
}
})
}
}
编写登录/注册业务的单元测试
Handler
-> service
-> repository
-> DAO
层都先测试一遍,模拟需要构建的对象进行该模块的单元测试。首先从Handler
开始。mock Handler
http
请求的位置,在该层后数据向service
->repository
->DAO
层传递并将执行结果返回给Handler
。对于Handler来说就是处理http
请求,通过service
返回数据再返回给客户端。单元测试是只针对该最小单元进行正确校验的方式,不能将整个项目跑起来测试运行,所以我们需要模拟发送http
请求,模拟service
层返回的数据并与预期值进行比较,通过添加断言判断该模块是否正确。UserHandler
的结构体中有UserService
和CodeService
,一会需要模拟这两个对象。type UserHandler struct {
svc service.UserService
codeSvc service.CodeService
}
BasicProject
玩具项目登录的单元测试中,输入侧用户是通过邮箱和密码登录的,需要模拟http
请求,Mock UserService
和CodeService
来创建UserHandler
,在预期侧可以是返回的状态码。所以数据表格会写成这样子:name string
// 输入
mock func(ctrl *gomock.Controller) (service.UserService, service.CodeService)
email string
password string
reqBuilder func(t *testing.T) *http.Request
// 预期输出
wantCode int
wantBody string
-
3. 使用指令生成mock代码。
mockgen -source=./internal/service/code.go -package=svcmocks -destination=./internal/service/mocks/code.mock.go
mockgen -source=./internal/service/user.go -package=svcmocks -destination=./internal/service/mocks/user.mock.go
go mod tidy
-
4. 定义mock内容,我们测试登录成功的例子,在mock函数中创建 userServce
和codeService
并返回,同时预期模拟的userService
调用Login
函数,第一个参数gomock.Any()
表示不关心具体值,只关心方法的调用,剩余参数传入邮箱和密码,返回一个空的domain.User
结构体和nil
错误。 -
5. 定义 http
请求。使用http.NewRequest
来创建请求,设置好请求方式POST
,请求url路径/v2/users/login
,请求参数email":"1@qq.com","password":"123"}
和请求头"Content-Type", "application/json"
。 -
6. 定义预期值 wantCode
;登录成功预期是http.SatatusOK
。如此一来我们可以将数据填充成这样:
testCases := []struct{
name string
xxxx
xxxx
}{
{
name: "登录成功",
mock: func(ctrl *gomock.Controller) (service.UserService, service.CodeService) {
userSvc := svcmocks.NewMockUserService(ctrl)
userSvc.EXPECT().
Login(gomock.Any(), "1@qq.com", "123").
Return(domain.User{}, nil)
codeSvc := svcmocks.NewMockCodeService(ctrl)
return userSvc, codeSvc
},
reqBuilder: func(t *testing.T) *http.Request {
req, err := http.NewRequest(http.MethodPost, "/v2/users/login", bytes.NewReader(
[]byte(`{"email":"1@qq.com","password":"123"}`)))
req.Header.Set("Content-Type", "application/json")
assert.NoError(t, err)
return req
},
wantCode: http.StatusOK,
}
}
-
7. 执行单元测试。创建一个测试用例的循环,首先创建一个 gomock
控制器管理模拟对象,使用该控制器创建userService
和codeService
,再使用这两个模拟对象创建一个userHandler
。此时还需要新建一个Gin
框架,并将userHandler
注册到路由当中;一起准备妥当后创建http
请求和httptest.NewRecorder
。Recorder是一个响应期,用于捕获http
处理程序返回的响应,可以记录程序是否正确设置了响应状态码、响应头和响应体。最后执行该请求,设置断言判断该测试是否通过。
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// 新建一个控制器,用于管理模拟对象
ctrl := gomock.NewController(t)
// 在函数结束时确保资源被正确释放
defer ctrl.Finish()
userSvc, codeSvc := tc.mock(ctrl) // 用控制器模拟userSvc和codeSvc
hdl := NewUserHandler(userSvc, codeSvc) // 创建一个userHandler
// 注册路由
serve := gin.Default()
hdl.RegisterRoutes(serve)
// 准备Req和记录的recorder
req := tc.reqBuilder(t)
recorder := httptest.NewRecorder()
//执行
serve.ServeHTTP(recorder, req)
//断言
assert.Equal(t, tc.wantCode, recorder.Code)
})
}
mock service
service
层的Login函数是什么样的。函数首先会调用repository
的FindByEmail
查询用户是否存在,随后拿着从数据库找到的加密后的用户密码进行对比,若密码正确则用户登录成功,返回domain.User
。// service
type UserService interface {
Login(ctx context.Context, email string, password string) (domain.User, error)
}
type userService struct {
repo repository.UserRepository
}
// Login函数
func (svc *userService) Login(ctx context.Context, email string, password string) (u domain.User, err error) {
u, err = svc.repo.FindByEmail(ctx, email)
// 用户不存在
if err == repository.ErrUserNotFound {
return domain.User{}, ErrInvalidUserOrPassword
}
// 对比密码
if result := bcrypt.ComparePwd(u.Password, password); result != true {
// 密码错误
//err = errors.New("passwordError")
err = ErrInvalidUserOrPassword
return domain.User{}, err
}
return u, nil
}
repository.UserRepository
,从Login函数看需要提供Context
上下文对象,邮箱和密码,预期返回一个domain.User
和error
。于是数据表格会写成这样子:name string
mock func(ctrl *gomock.Controller) repository.UserRepository
// 预期输入
ctx context.Context
email sql.NullString
password string
//实际值
wantUser domain.User
wantErr error
-
3. 使用mockgen生成mock代码
mockgen -source=./internal/repository/user.go -package=repomocks -destination=./internal/repository/mocks/user.mock.go
mockgen -source=./internal/repository/code.go -package=repomocks -destination=./internal/repository/mocks/code.mock.go
go mod tidy
-
4. 定义mock内容,我们测试登录成功的例子,因此我们期望 repository
返回一个正确的domain.User
对象,包括邮箱、正确经过加密的密码,手机号码。这些数据可以从之前数据库中拿到,我之前注册了邮箱是1@qq.com
,密码是123的用户(加密前),因此Mock时要从数据库中拿到那一长串加密后的密码,与输入值密码进行比对。如果要测试密码错误的例子,将输入侧的password
写错就可以了。一通操作下来测试用例可以写成这样子。
{
name: "登录成功",
mock: func(ctrl *gomock.Controller) repository.UserRepository {
// 使用repomocks包创建一个模拟的UserRepository
repo := repomocks.NewMockUserRepository(ctrl)
// 设置模拟UserRepository的FindByEmail方法的预期行为,期望FindByEmail方法被调用时,传入的邮箱为 "1@qq.com"
// 返回值是一个具有特定字段值的 domain.User对象
repo.EXPECT().
FindByEmail(gomock.Any(), "1@qq.com").
Return(domain.User{
Email: "1@qq.com",
Password: "$2a$10$GFSbeRa5RVX912wj0SGZHuGjeJuDEB3eOlXDv8GZ/yAaP4rKY9Roq",
Phone: "123",
}, nil)
return repo
},
email: sql.NullString{String: "1@qq.com", Valid: true},
password: "123",
wantUser: domain.User{
Email: "1@qq.com",
Password: "$2a$10$GFSbeRa5RVX912wj0SGZHuGjeJuDEB3eOlXDv8GZ/yAaP4rKY9Roq",
Phone: "123",
},
},
{
name: "密码或用户名错误",
mock: func(ctrl *gomock.Controller) repository.UserRepository {
repo := repomocks.NewMockUserRepository(ctrl)
repo.EXPECT().
FindByEmail(gomock.Any(), "1@qq.com").
Return(
domain.User{
Email: "1@qq.com",
Password: "$2a$10$GFSbeRa5RVX912wj0SGZHuGjeJuDEB3eOlXDv8GZ/yAaP4rKY9Roq",
Phone: "123",
}, nil)
return repo
},
email: sql.NullString{String: "1@qq.com", Valid: true},
password: "123123123",
wantErr: ErrInvalidUserOrPassword,
},
-
5. 执行测试。新建 gomock
控制器管理模拟对象,模拟repository
然后创建一个UserService
。执行测试时调用Login
函数,做domian.User
和error
的断言。
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
// 模拟UserRepository 来创建 UserSerivce
repo := tc.mock(ctrl)
svc := NewUserService(repo)
// 调用Login函数
user, err := svc.Login(tc.ctx, tc.email.String, tc.password)
assert.Equal(t, tc.wantUser, user)
assert.Equal(t, tc.wantErr, err)
})
}
mock repository
repository
层测试FindById
函数。因为同时使用了Mysql
和Redis
,所以CacheUserRepository
里有UserDAO
和UserCache
。FindById
要做的事情,就是通过id查找用户时先到redis
看看有没有,没有的话就去Mysql
找找,找到了这个用户就设置到Redis
缓存中。这里就测试查询成功,但未命中缓存的结果。那么我们预期就是UserCache
没有查询到用户,UserDAO
查询到了该用户,然后UserCache
将查询回来的值成功写到缓存中。// repository
var (
ErrDuplicateUser = dao.ErrDuplicateEmail
ErrUserNotFound = dao.ErrRecordNotFound
)
type UserRepository interface {
FindById(ctx context.Context, id string) (domain.User, error)
}
type CacheUserRepository struct {
dao dao.UserDAO
cache cache.UserCache
}
func (repo *CacheUserRepository) FindById(ctx context.Context, id string) (domain.User, error) {
// 首先找一下redis有没有
du, err := repo.cache.Get(ctx, id)
if err == nil {
return du, nil
}
u, err := repo.dao.FindById(ctx, id)
if err != nil {
return domain.User{}, err
}
du = repo.toDomain(u)
if err = repo.cache.Set(ctx, du); err != nil {
log.Println("set cache error", err)
}
return du, nil
}
-
1. 定义数据表格。现在轻车熟路就知道我们遇到Mock一个带有 UserDAO
和UserCache
的控制器,输入侧需要Context
上下文,用户id
,查找成功后我们预期返回一个domain.User
并没有错误。所以可以这样写:
name string
mock func(ctrl *gomock.Controller) (cache.UserCache, dao.UserDAO)
ctx context.Context
id string
wantUser domain.User
wantErr error
-
2. 执行mockgen生成代码
mockgen -source=./internal/repository/cache/user.go -package=cachemocks -destination=./internal/repository/cache/mocks/user.mock.go
mockgen -source=./internal/repository/cache/code.go -package=cachemocks -destination=./internal/repository/cache/mocks/code.mock.go
mockgen -source=./internal/repository/dao/user.go -package=daomocks -destination=./internal/repository/dao/mocks/user.mock.go
go mod tidy
-
3. 定义Mock数据。如前面所说的那样,先创建Mock的 UserDAO
和UserCache
,UserCache
的GET
方法无法再缓存中获取该用户,随后设置预期UserDAO
通过FindById
查到该用户并返回,最后预期UserCache
成功设置缓存设置。因为要测试成功获取查找用户的例子,所以wantUser
和预期FindById
返回的用户数据是要一致的。可以写成下面这样:
{
name: "查询成功 但未命中缓存",
mock: func(ctrl *gomock.Controller) (cache.UserCache, dao.UserDAO) {
id := "123456"
d := daomocks.NewMockUserDAO(ctrl)
c := cachemocks.NewMockUserCache(ctrl)
// 未命中缓存
c.EXPECT().Get(gomock.Any(), id).
Return(domain.User{}, cache.ErrKeyNotExist)
// 查询成功
d.EXPECT().FindById(gomock.Any(), id).
Return(dao.User{
ID: id,
Email: sql.NullString{String: "1@qq.com", Valid: true},
Password: "123",
Phone: sql.NullString{String: "123", Valid: true},
Birthday: 0,
Nickname: "",
Aboutme: "夏天夏天悄悄过去留下小秘密",
}, nil)
// 设置缓存成功
c.EXPECT().Set(gomock.Any(), domain.User{
ID: id,
Email: "1@qq.com",
Password: "123",
Phone: "123",
Aboutme: "夏天夏天悄悄过去留下小秘密",
}).Return(nil)
return c, d
},
id: "123456",
ctx: context.Background(),
wantUser: domain.User{
ID: "123456",
Email: "1@qq.com",
Password: "123",
Phone: "123",
Aboutme: "夏天夏天悄悄过去留下小秘密",
},
wantErr: nil,
},
-
4. 执行测试。跟其他部分都一样,对预期和实际值断言。
ctrl := gomock.NewController(t)
defer ctrl.Finish()
uc, ud := tc.mock(ctrl)
svc := NewCacheUserRepository(ud, uc)
user, err := svc.FindById(tc.ctx, tc.id)
assert.Equal(t, tc.wantErr, err)
assert.Equal(t, tc.wantUser, user)
mock dao
Insert
函数。在这里我们需要用到DATA-DOG
的一个开源的Mock数据库的库。Datadog是一家总部位于美国纽约的SaaS公司,成立于2010年。它提供云监控服务,包括基础架构监视、应用程序性能监视、日志管理、网络性能监视等。Datadog的平台能够集成和自动化客户的整个技术堆栈的监控,以提供实时的观察能力go get github.com/DATA-DOG/go-sqlmock
// DAO
type GORMUserDAO struct {
db *gorm.DB
}
func (dao *GORMUserDAO) Insert(ctx context.Context, u User) (err error) {
err = dao.db.WithContext(ctx).Create(&u).Error
if me, ok := err.(*mysql.MySQLError); ok {
const duplicateErr uint16 = 1062
if me.Number == duplicateErr {
// 用户冲突,邮箱冲突
return ErrDuplicateEmail
}
}
return err
}
-
1. 定义数据表格,输入侧需要提供context上下文,User,Mock sql.DB
。
name string
mock func(t *testing.T) *sql.DB
user User
ctx context.Context
wantErr error
-
2. 定义Mock内容。在该测试中我们需要创建爱你数据库连接模拟对象,并创建模拟操作数据库的结果。
{
name: "写入成功",
mock: func(t *testing.T) *sql.DB {
db, mock, err := sqlmock.New() //创建数据库连接模拟对象
assert.NoError(t, err) // 断言是否有错误
mockRes := sqlmock.NewResult(123, 1) // 创建模拟的数据库操作结果
// 这边要求传入的是 sql 的正则表达式
mock.ExpectExec("INSERT INTO .*").
WillReturnResult(mockRes) // 设置模拟的返回结果
return db
},
ctx: context.Background(),
user: User{Aboutme: "夏天夏天悄悄过去"},
},
-
3. 执行测试。在这里有几点需要注意: -
需要设置跳过初始化版本,尽量减少依赖; -
需要设置禁用自动Ping,GORM 在初始化后会自动执行一次数据库 Ping 操作,以检查数据库的可用性。在单元测试中,我们通常会使用模拟的数据库或内存数据库,而不是真正的 MySQL 数据库,避免不必要的数据库操作。 -
要设置跳过默认事务。默认情况下,GORM 的写操作(创建、更新、删除)会在事务中执行,以确保数据一致性。在单元测试中,我们可能只关心 DAO 层的逻辑,而不需要默认的事务支持。
sqlDB := tc.mock(t)
db, err := gorm.Open(mysql.New(mysql.Config{
Conn: sqlDB,
SkipInitializeWithVersion: true,
}), &gorm.Config{
DisableAutomaticPing: true,
SkipDefaultTransaction: true,
})
assert.NoError(t, err)
dao := NewUserDAO(db)
err = dao.Insert(tc.ctx, tc.user)
assert.Equal(t, tc.wantErr, err)
写在最后
https://github.com/FengZeHe/LearnGolang/tree/main/project/BasicProjectV2
原文始发于微信公众号(ProgrammerHe):Go单元测试笔记
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/273244.html