从上次发文说起
在上次发布的文章《在项目里怎么给 GORM 做单元测试》中对 ORM 的 Update 操作的测试中,因为 ORM 库每次做更新操作时,都会针对updated_at
字段进行自动更新,导致我们在写Mock的时候没办法精致匹配这个字段的值,这个问题也就作为一个未解决的问题在文章中发布了出去。
没想到上一周在CSDN上一位读者的留言给了这个问题解决的思路,因为微信不允许更改发布的文章,只能重开一篇文章把这个遗留问题加以解决,至此也算是一个 ORM Mock 单元测试的完整教程了,对前文有印象的读者朋友,请直接跳到文章后半部分进行观看。
前言
真实的开发场景下我们的项目一般都会使用 ORM ,而不是原生的database/sql
来完成数据库操作。在很多使用ORM工具的场景下,也可以使用go-sqlmock
库 Mock数据库操作进行测试,今天这篇内容我就以 GORM 为例,讲解怎么给项目中的 ORM 数据库操作做单元测试。
项目准备
为了场景足够真实,我用 2020 年我更新的 「Go Web 编程」项目中的例子给大家演示怎么为使用了 GORM 的 DAO 层逻辑做 Mock 测试。
这里使用的GORM版本为 1.x,有可能在2.x版本下不兼容
在这个例子中我们有一个与 users 表
type User struct {
Id int64 `gorm:"column:id;primary_key"`
UserName string `gorm:"column:username"`
Secret string `gorm:"column:secret;type:varchar(1000)"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
func (m *User) TableName() string {
return "users"
}
以及几个使用 User 的 DAO 函数:
var _DB *gorm.DB
func DB() *gorm.DB {
return _DB
}
func init() {
//这里逻辑省略,就是初始化 GORM 的DB对象,
// 设置连接数据库的配置
// 真实代码可以公众号回复【gohttp15】获得
_DB = initDB()
}
func CreateUser(user *table.User) (err error) {
err = DB().Create(user).Error
return
}
func GetUserByNameAndPassword(name, password string) (user *table.User, err error) {
user = new(table.User)
err = DB().Where("username = ? AND secret = ?", name, password).
First(&user).Error
return
}
func UpdateUserNameById(userName string, userId int64) (err error) {
user := new(table.User)
updated := map[string]interface{}{
"username": userName,
}
err = DB().Model(user).Where("id = ?", userId).Updates(updated).Error
return
}
接下来我们就用 go-sqlmock
工具给这几个 DAO 函数做一下 Mock 测试。
初始化测试工作
首先我们需要做一下测试的初始化工作,主要是设置Mock的DB连接,因为要给三个方法做Mock测试,最简单的办法是在三个方法里每次都初始化一遍 Mock 的 DB 连接,不过这么做实在是显得有点蠢,这里给大家再介绍一个小技巧。
Go 的测试支持在包内优先执行一个 TestMain(m *testing.M)
函数,可以在这里为 package 下所有测试做一些初始化的工作。
下面是我们为本次测试做的初始化工作。
// 给公众号「网管叨bi叨」发私信
// gohttp15 获得源码
var (
mock sqlmock.Sqlmock
err error
db *sql.DB
)
// TestMain是在当前package下,最先运行的一个函数,常用于初始化
func TestMain(m *testing.M) {
//把匹配器设置成相等匹配器,不设置默认使用正则匹配
db, mock, err = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
if err != nil {
panic(err)
}
_DB, err = gorm.Open("mysql", db)
// m.Run 是调用包下面各个Test函数的入口
os.Exit(m.Run())
}
在这个初始化函数里我们创建一个
sqlmock
的数据库连接db
和mock
对象,mock
对象管理db
预期要执行的SQL。让sqlmock 使用 QueryMatcherEqual 匹配器,该匹配器把mock.ExpectQuery 和 mock.ExpectExec 的参数作为预期要执行的SQL语句跟实际要执行的SQL进行相等比较。
m.Run 是调用包下面各个Test函数的入口。
准备工作做好了,下面正式对 DAO 操作进行Mock测试。
对Create进行Mock测试
首先对 GORM 的Create 方法进行Mock测试。
// 给公众号「网管叨bi叨」发私信
// gohttp15 获得源码
func TestCreateUserMock(t *testing.T) {
user := &table.User{
UserName: "Kevin",
Secret: "123456",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
mock.ExpectBegin()
mock.ExpectExec("INSERT INTO `users` (`username`,`secret`,`created_at`,`updated_at`) VALUES (?,?,?,?)").
WithArgs(user.UserName, user.Secret, user.CreatedAt, user.UpdatedAt).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
err := CreateUser(user)
assert.Nil(t, err)
}
因为 sqlmock 使用的是 QueryMatcherEqual 匹配器,所以,预期会执行的 SQL 语句必须精确匹配要执行的SQL(包括符号和空格)。
这个SQL怎么获取呢?其实我们先随便写一个SQL,执行一次测试,在报错信息里就会告知CreateUser
操作在写表时 GORM 真正要执行的 SQL 啦, 也可以通过GORM提供的Debug()
方法获取到。
比如运行一下下面这个设置了Debug()
的创建用户操作,GORM就会打印出执行的语句。
func CreateUser(user *table.User) (err error) {
// 打印出要执行的SQL语句 ,记得改回去
err = DB().Debug().Create(user).Error
// err = DB().Create(user).Error
return
}
我们执行下这个测试
go test -v -run TestCreateUserMock
--------
=== RUN TestCreateUserMock
--- PASS: TestCreateUserMock (0.00s)
PASS
ok golang-unit-test-demo/sqlmock_gorm_demo 0.301s
可以看到,测试函数执行成功,我们还可以故意把SQL改成,做一下反向测试,这个就留给你们自己联系啦,结合上表格测试分别做一下正向和反向单元测试。
Get 操作的Mock测试
GORM 的查询操作的Mock测试跟Create类似。
// 给公众号「网管叨bi叨」发私信
// gohttp15 获得源码
func TestGetUserByNameAndPasswordMock(t *testing.T) {
user := &User{
Id: 1,
UserName: "Kevin",
Secret: "123456",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
mock.ExpectQuery("SELECT * FROM `users` WHERE (username = ? AND secret = ?) "+
"ORDER BY `users`.`id` ASC LIMIT 1").
WithArgs(user.UserName, user.Secret).
WillReturnRows(
// 这里要跟结果集包含的列匹配,因为查询是 SELECT * 所以表的字段都要列出来
sqlmock.NewRows([]string{"id", "username", "secret", "created_at", "updated_at"}).
AddRow(1, user.UserName, user.Secret, user.CreatedAt, user.UpdatedAt))
res, err := GetUserByNameAndPassword(user.UserName, user.Secret)
assert.Nil(t, err)
assert.Equal(t, user, res)
}
这里就不在文章里运行演示啦,有兴趣的自己把代码拿下来试一下。
Update 操作的Mock测试
GORM的Update操作的单元测试,一开始是这样写的:
func TestUpdateUserNameByIdMock(t *testing.T) {
newName := "Kev"
var userId int64 = 1
mock.ExpectBegin()
mock.ExpectExec("UPDATE `users` SET `updated_at` = ?, `username` = ? WHERE (id = ?)").
WithArgs(time.Now(), newName, userId).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
err := UpdateUserNameById(newName, userId)
assert.Nil(t, err)
}
运行测试后,会有下面的报错信息:
ExecQuery 'UPDATE `users` SET `updated_at` = ?, `username` = ? WHERE (id = ?)', arguments do not match: argument 0 expected [time.Time - 2022-05-08 18:13:08.23323 +0800 CST m=+0.003082084] does not match actual [time.Time - 2022-05-08 18:13:08.234134 +0800 CST m=+0.003986334]
GORM 在UPDATE 的时候会自动更新updated_at 字段为当前时间,与这里withArgs传递的 time.Now() 参数不一致(毫秒级的差距也不行)。
这种情况可以选择在 Mock 要执行的更新 SQL 时给 updated_at
字段的值设置成sqlmock.AnyArg()
,就能测试通过了,上面的 UPDATE 测试改成下面这样:
mock.ExpectBegin()
mock.ExpectExec("UPDATE `users` SET `updated_at` = ?, `username` = ? WHERE (id = ?)").
WithArgs(sqlmock.AnyArg(), newName, userId).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
这个方法是sqlmock
提供的用来断言匹配任意字段值的一个特殊的类型。在其注释里也有说明,尤其适合time.Time
类型字段的断言。
// AnyArg will return an Argument which can
// match any kind of arguments.
//
// Useful for time.Time or similar kinds of arguments.
func AnyArg() Argument {
return anyArgument{}
}
当然使用sqlmock.AnyArg()
在测试代码的可读性上,以及严谨性上都会差点意思,因为如果真实执行的 SQL 中如果updated_at
字段设置的值不是time.Time
类型的,使用sqlmock.AnyArg()
做断言匹配是无法测出来的。
所以我们也可以选择实现自己定义一个 AnyTime
类型,让它实现sqlmock.Argument
接口,比如下面的示例:
// 定义一个AnyTime 类型,实现 sqlmock.Argument接口
// 参考自:https://qiita.com/isao_e_dev/items/c9da34c6d1f99a112207
type AnyTime struct{}
func (a AnyTime) Match(v driver.Value) bool {
// Match 方法中:判断字段值只要是time.Time 类型,就能验证通过
_, ok := v.(time.Time)
return ok
}
在 AnyTime
类型实现接口定义的Match
方法的逻辑是:判断字段值只要是time.Time 类型,就能验证通过。这种方式比使用 sqlmock.AnyArg()
限制性更强一些,代码可读性也会更好。
mock.ExpectBegin()
mock.ExpectExec("UPDATE `users` SET `updated_at` = ?, `username` = ? WHERE (id = ?)").
WithArgs(AnyTime{}, newName, userId).
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
总结
这篇内容我们把ORM的 Mock 测试做了一个讲解,覆盖了 ORM 的所有操作,对了这里没有讲关于 DELETE 操作的讲解,这块有需要的自己研究一下吧。还有一点重要的原因是,在公司里做项目,使用的数据库账号一般不授予 DELETE 权限,为什么不让做 DELETE 操作?领导故意难为你出一堆规范?其实是为了避免出线上事故,还有因为不是 DELETE 就一定能释放 MySQL 磁盘空间的,大家可以在评论区里发挥一下,看自己的 MySQL 的知识储备咋样。
因为文章中的示例,是以我以前Go Web 编程教程里的项目里做的测试,本文源码我也重新打包更新到了Go Web 编程的项目中啦,公众号私信 gohttp15 就能获得。
如果你觉得有用,可以点赞、在看、分享给更多人,谢谢各位的支持,后面会与时俱进再搞一篇 Go 1.18 Fuzing 测试的使用介绍。
相关阅读
资料下载
点击下方卡片关注公众号,发送特定关键字获取对应精品资料!
回复「电子书」,获取入门、进阶 Go 语言必看书籍。
回复「视频」,获取价值 5000 大洋的视频资料,内含实战项目(不外传)!
回复「路线」,获取最新版 Go 知识图谱及学习、成长路线图。
回复「面试题」,获取四哥精编的 Go 语言面试题,含解析。
回复「后台」,获取后台开发必看 10 本书籍。
对了,看完文章,记得点击下方的卡片。关注我哦~ 👇👇👇
如果您的朋友也在学习 Go 语言,相信这篇文章对 TA 有帮助,欢迎转发分享给 TA,非常感谢!