上节课我给大家介绍了怎么给Go项目做单元测试的规划,当然这里仅限于跟咱们课程里的实战项目一样分层架构设计做的还可以的项目哦,要是所有逻辑都耦合在Controller里,那这个规划就不适用了。。。,所有逻辑都耦合在Controller里还做个锤子的单元测试,直接上线让用户给你测(手机系统都能这么干的。。。你们怕啥)
不好意思聊劈叉了,接下来正式进入开展我们专栏单元测试的内容,本节给大家介绍Dao层的单元测试技能。
Dao的单元测试
讲到数据库的单元测试,一般有那么几个流派
专门准备一个独立的数据库,单元测试时让所有测试用例读写这个独立的数据库,它的优点是单测真的去读写数据库啦,缺点嘛也显而易见,一个项目的数据库不是光有表就行,还得准备测试数据,这个搞起来就有点麻烦,尤其是关联性强的数据,造起来更麻烦。
让项目在单元测试时访问内存数据库,它的优缺点其实跟上个差不多。
采用sqlmock类的工具,对Dao要执行的SQL作出预期匹配,同时Mock SQL查询要返回的数据,保证Dao方法内部的逻辑正常执行。
我们这里采用的是第三个流派,用 sqlmock 方式来做数据库Dao的单元测试,本节的内容大纲主要如下:
这里我们会用到DataDog家开发的go-sqlmock这个工具,先来安装一下它:
github.com/DATA-DOG/go-sqlmock
安装过程如下:

本文节选自我的专栏《Go项目搭建和整洁开发实战》欢迎扫码订阅解锁更多内容。

订阅后除了加入实战项目外,还可加入专属读者群一起学习。
单元测试入口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 可在线查看详细的代码更新。
下节课我们一起看看,针对程序里的API调用,该怎么做单元测试。本文实战项目和更全面的教程请扫下方二维码订阅专栏后联系我进项目

加入专栏后除了能获得完整版教程,更有配套的实战项目和专属读者群,我在其中用git tag版本编排好了每节教程对应的代码更新,让你能更容易地追踪项目开发过程中的每一处代码变更。
《Go项目搭建和整洁开发实战》专栏分为五大部分,重点章节如下

第一部分介绍让框架变得好用的诸多实战技巧,比如通过自定义日志门面让项目日志更简单易用、支持自动记录请求的追踪信息和程序位置信息、通过自定义Error在实现Go error接口的同时支持给给错误添加错误链,方便追溯错误源头。
第二部分:讲解项目分层架构的设计和划分业务模块的方法和标准,让你以后无论遇到什么项目都能按这套标准自己划分出模块和逻辑分层。后面几个部分均是该部分所讲内容的实践。
第三部分:设计实现一个套支持多平台登录,Token泄露检测、同平台多设备登录互踢功能的用户认证体系,这套用户认证体系既可以在你未来开发产品时直接应用
第四部分:商城app C端接口功能的实现,强化分层架构实现的讲解,这里还会讲解用责任链、策略和模版等设计模式去解决订单结算促销、支付方式支付场景等多种多样的实际问题。
第五部分:单元测试、项目Docker镜像、K8s部署和服务保障相关的一些基础内容和注意事项。
扫描上方二维码或者访问 https://xiaobot.net/p/golang 即刻订阅
此外想更详细地了解专栏内容,咨询专栏优惠,都可以添加下面我的微信