Go项目实战--数据Dao层代码的单元测试实战
Dao的单元测试
讲到数据库的单元测试,一般有那么几个流派
- 专门准备一个独立的数据库,单元测试时让所有测试用例读写这个独立的数据库,它的优点是单测真的去读写数据库啦,缺点嘛也显而易见,一个项目的数据库不是光有表就行,还得准备测试数据,这个搞起来就有点麻烦,尤其是关联性强的数据,造起来更麻烦。
- 让项目在单元测试时访问内存数据库,它的优缺点其实跟上个差不多。
- 采用sqlmock类的工具,对Dao要执行的SQL作出预期匹配,同时Mock SQL查询要返回的数据,保证Dao方法内部的逻辑正常执行。
我们这里采用的是第三个流派,用 sqlmock 方式来做数据库Dao的单元测试,本节的内容大纲主要如下:
图片
这里我们会用到DataDog家开发的go-sqlmock这个工具,先来安装一下它:
github.com/DATA-DOG/go-sqlmock
安装过程如下:
图片
单元测试入口TestMain的设置
我们计划在 UserDao 和 OrderDao 中找几个典型的方法来做单元测试的实战,这里我们先在新建test/dao/user_test.go,创建完之后还不能马上开始写测试用例,我们再来做一下dao层单元测试的基础工作。
在TestMain方法中初始化go-sqlmock ,这样整个dao下的测试用例就都能使用它了,TestMain是在当前package下最先运行的一个函数,无论你运行哪个测试用例TestMain都会先被Go调用,所以它常用于测试基础组件的初始化。
我们的TestMain的代码如下:
var (
mock sqlmock.Sqlmock
err error
db *sql.DB
)
func TestMain(m *testing.M) {
db, mock, err = sqlmock.New()
if err != nil {
panic(err)
}
// 把项目使用的DB连接换成sqlmock的DB连接
dbMasterConn, _ := gorm.Open(mysql.New(mysql.Config{
Conn: db,
SkipInitializeWithVersion: true,
DefaultStringSize: 0,
}))
dbSlaveConn, _ := gorm.Open(mysql.New(mysql.Config{
Conn: db,
SkipInitializeWithVersion: true,
DefaultStringSize: 0,
}))
dao2.SetDBMasterConn(dbMasterConn)
dao2.SetDBSlaveConn(dbSlaveConn)
os.Exit(m.Run())
}
这里我们创建一个 go-sqlmock 的数据库连接 和 mock对象,mock对象管理 db 预期要执行的SQL,具体初始化中各个参数的作用,直接看我上面代码里的注视吧。
因我我们项目里Dao使用的数据库连接在包外不可访问,所以我在这里给项目dao层里加了 SetDBMasterConn,SetDBSlaveConn两个方法把我们原本的数据库连接替换成了sqlmock的数据库连接。
基础设置完成后,接下来我们分别找Dao的Insert、Update、Select操作来展示怎么给他们做单元测试。
Insert 操作的单元测试
首先给UserDao的CreateUser方法做单元测试,它是用户注册接口的逻辑中会用到的Dao方法,其定义如下:
func (ud *UserDao) CreateUser(userInfo *do.UserBaseInfo, userPasswordHash string) (*model.User, error) {
userModel := new(model.User)
err := util.CopyProperties(userModel, userInfo)
if err != nil {
err = errcode.Wrap("UserDaoCreateUserError", err)
returnnil, err
}
userModel.Password = userPasswordHash
err = DBMaster().WithContext(ud.ctx).Create(userModel).Error
if err != nil {
err = errcode.Wrap("UserDaoCreateUserError", err)
returnnil, err
}
return userModel, nil
}
这里就不再对CreateUser这个方法里都是什么做展开了,大家直接看项目代码吧,它的单元测试如下:
func TestUserDao_CreateUser(t *testing.T) {
userInfo := &do.UserBaseInfo{
Nickname: "Slang",
LoginName: "slang@go-mall.com",
Verified: 0,
Avatar: "",
Slogan: "happy!",
IsBlocked: 0,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
passwordHash, _ := util.BcryptPassword("123456")
userIsDel := 0
ud := dao2.NewUserDao(context.TODO())
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO `users`")).
WithArgs(userInfo.Nickname, userInfo.LoginName, passwordHash, userInfo.Verified, userInfo.Avatar,
userInfo.Slogan, userIsDel, userInfo.IsBlocked, userInfo.CreatedAt, userInfo.UpdatedAt).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
userObj, err := ud.CreateUser(userInfo, passwordHash)
assert.Nil(t, err)
assert.Equal(t, userInfo.LoginName, userObj.LoginName)
}
这里我们首先自己初始化了一个CreateUser会用到的数据userInfo和passwordHash,然后使用 ExpectExec 指定预期要执行的SQL以及预期返回的结果。
这里我来说明一下sqlmock 默认使用 sqlmock.QueryMatcherRegex 作为默认的SQL匹配器,该匹配器使用mock.ExpectQuery 和 mock.ExpectExec 的参数作为正则表达式与真正执行的SQL语句进行匹配,如果使用QueryMatcherEqual 作为匹配器的话,那么我们写预期SQL时就要写完整的SQL了。
我推荐用默认的匹配器就行,因为接下来的WithArgs中我们还要给SQL的 ? 占位符提供参数值,这个参数值如果数量或者类型匹配不上的话,单测依然是无法通过的。
WillReturnResult(sqlmock.NewResult(1, 1)) 这行的意思是SQL执行后返回的 lastInsertId 是 1, 受影响行数也是 1。
拿到结果之后我们再做assert断言,判断结果是否符合预期。符合预期则通过,不符合的话测试用例会失败。大家可以自己尝试修改一下这个用例看它执行失败的效果。
Select 查询的单元测试
关于SQL查询的单元测试,和上面的区别是我们会Mock返回的结果集,这里我们拿的是OrderDao的GetUserOrders做的单元测试,代码如下。
func TestOrderDao_GetUserOrders(t *testing.T) {
orderDel := soft_delete.DeletedAt(0)
now := time.Now()
emptyPayTime := time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
orders := []*model.Order{
{1, "12345675555", "", 1, 1, 100, 100, 0, 0, emptyPayTime, orderDel, now, now},
{2, "12345675556", "", 1, 1, 100, 100, 0, 0, emptyPayTime, orderDel, now, now},
}
od := dao2.NewOrderDao(context.TODO())
var userId int64 = 1
offset := 10
limit := 50
mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM `orders`")).WithArgs(userId, orderDel, limit, offset).
WillReturnRows(
sqlmock.NewRows([]string{"id", "order_no", "pay_trans_id", "pay_type", "user_id", "bill_money", "pay_money",
"pay_state", "order_status", "paid_at", "is_del", "created_at", "updated_at"}).
AddRow(
orders[0].ID, orders[0].OrderNo, orders[0].PayTransId, orders[0].PayType, orders[0].UserId, orders[0].BillMoney, orders[0].PayMoney,
orders[0].PayState, orders[0].OrderStatus, orders[0].PaidAt, orders[0].IsDel, orders[0].CreatedAt, orders[0].UpdatedAt,
).AddRow(
orders[1].ID, orders[1].OrderNo, orders[1].PayTransId, orders[1].PayType, orders[1].UserId, orders[1].BillMoney, orders[1].PayMoney,
orders[1].PayState, orders[1].OrderStatus, orders[1].PaidAt, orders[1].IsDel, orders[1].CreatedAt, orders[1].UpdatedAt,
),
)
mock.ExpectQuery(regexp.QuoteMeta("SELECT count(*) FROM `orders`")).WithArgs(userId, orderDel).
WillReturnRows(sqlmock.NewRows([]string{"COUNT(*)"}).AddRow(2))
gotOrders, totalRow, err := od.GetUserOrders(userId, offset, limit)
assert.Nil(t, err)
assert.Equal(t, orders, gotOrders)
assert.Equal(t, totalRow, int64(2))
}
这里我用 ExpectQuery 指定了两个预期要执行的SQL是为什么呢?因为GetUserOrders方法即返回了用户订单列表还返回了数据分页用的totalRaws变量,大家可以试试把它删掉看看这个单元测试能不能执行成功,这里我可以告诉你结果会成功但又没完全成功,会有一条Warning警告,报告出有一个执行的SQL没有做预期匹配。
执行单元测试时可以用上面我教的命令,也可以用IDE自带的测试按钮跑来跑这个测试用例。
Update操作的单元测试
Update操作的单元测试于Insert操作的类似,我们选用OrderDao的UpdateOrderStatus 方法来做单元测试。
func TestOrderDao_UpdateOrderStatus(t *testing.T) {
orderNewStatus := 1
var orderId int64 = 1
orderDel := 0
mock.ExpectBegin()
mock.ExpectExec(regexp.QuoteMeta("UPDATE `orders` SET")).
WithArgs(orderNewStatus, AnyTime{}, orderId, orderDel).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
od := dao2.NewOrderDao(context.TODO())
err := od.UpdateOrderStatus(orderId, orderNewStatus)
assert.Nil(t, err)
}
这里的AnyTime是咱们自定义的一个类型
type AnyTime struct{}
func (a AnyTime) Match(v driver.Value) bool {
// Match 方法中:判断字段值只要是time.Time 类型,就能验证通过
_, ok := v.(time.Time)
return ok
}
其实在使用SQL完全匹配模式时才必须用它,因为参数提供的Time.Now()做为UpdatedAt的时间,这与SQL执行时真正的UpdateAt时间是有很小的差异的,这个时候我们可以提供AnyTime做为更新时间,这样sqlmock在做预期SQL和实际SQL的匹配时,遇到了AnyTime类型的预期值,就会按照这里指定的规则,判断字段值只要是time.Time 类型就能验证通过。
总结
本节代码版本为c19.1
git fetch --tags
git checkout tags/c19.1
访问 https://github.com/go-study-lab/go-mall/compare/c18...c19.1 可在线查看详细的代码更新。