Go项目实战–数据Dao层代码的单元测试实战

Dao的单元测试

讲到数据库的单元测试,一般有那么几个流派

专门准备一个独立的数据库,单元测试时让所有测试用例读写这个独立的数据库,它的优点是单测真的去读写数据库啦,缺点嘛也显而易见,一个项目的数据库不是光有表就行,还得准备测试数据,这个搞起来就有点麻烦,尤其是关联性强的数据,造起来更麻烦。让项目在单元测试时访问内存数据库,它的优缺点其实跟上个差不多。采用sqlmock类的工具,对Dao要执行的SQL作出预期匹配,同时Mock SQL查询要返回的数据,保证Dao方法内部的逻辑正常执行。

我们这里采用的是第三个流派,用 sqlmock 方式来做数据库Dao的单元测试,本节的内容大纲主要如下:

图片

这里我们会用到DataDog家开发的go-sqlmock这个工具,先来安装一下它:

复制
github.com/DATA-DOG/go-sqlmock1.

安装过程如下:

图片

单元测试入口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()) }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.

这里我们创建一个 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 }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.

这里就不再对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) }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.

这里我们首先自己初始化了一个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)) }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.

这里我用 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) }1.2.3.4.5.6.7.8.9.10.11.12.13.

这里的AnyTime是咱们自定义的一个类型

复制
type AnyTime struct{} func (a AnyTime) Match(v driver.Value) bool { // Match 方法中:判断字段值只要是time.Time 类型,就能验证通过 _, ok := v.(time.Time) return ok }1.2.3.4.5.6.7.

其实在使用SQL完全匹配模式时才必须用它,因为参数提供的Time.Now()做为UpdatedAt的时间,这与SQL执行时真正的UpdateAt时间是有很小的差异的,这个时候我们可以提供AnyTime做为更新时间,这样sqlmock在做预期SQL和实际SQL的匹配时,遇到了AnyTime类型的预期值,就会按照这里指定的规则,判断字段值只要是time.Time 类型就能验证通过。

总结

本节代码版本为c19.1

复制
git fetch --tags git checkout tags/c19.11.2.

访问 https://github.com/go-study-lab/go-mall/compare/c18...c19.1 可在线查看详细的代码更新。

阅读剩余
THE END