Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Go 单元测试 -- ORM 测试 #80

Open
kevinyan815 opened this issue May 14, 2022 · 2 comments
Open

Go 单元测试 -- ORM 测试 #80

kevinyan815 opened this issue May 14, 2022 · 2 comments

Comments

@kevinyan815
Copy link
Owner

kevinyan815 commented May 14, 2022

在 Go 单元测试这个系列的第二部分 数据库的Mock测试 中我们介绍了用 go-sqlmock 给数据库的 CRUD 操作做Mock 测试的方法,不过里面只是讲解了一下怎么对原生的database/sql执行的 SQL 进行 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 的数据库连接 dbmock对象,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 时给 update_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 测试做了一个讲解,这个也是我在学习 Go 单元测试时自己的思考,希望学习到的这些技能能在项目中真实用到。

因为文章中的示例,是以我之前的Go Web 编程教程里的项目里做的测试,源码我也打包更新到了Go Web 编程的项目中啦,公众号私信 gohttp15 就能获得。

如果你觉得有用,可以点赞、在看、分享给更多人,谢谢各位的支持,后面会与时俱进再搞一篇 Go 1.18 Fuzing 测试的使用介绍。

相关阅读

Go 单元测试--数据库的Mock测试

@ifnk
Copy link

ifnk commented Jun 19, 2022

使用gormv2 , update 方法跑不起来

这是我 的代码
gorm-mock.go

package main

import (
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
)

var _DB *gorm.DB

// gorm 模拟
func main() {

}

func InitDB() {
	dsn := "root:123456@tcp(127.0.0.1:3306)/test?charset=utf8&parseTime=True&loc=Local"
	db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
	if err != nil {
		panic(err)
	}
	_DB = db
}

type User struct {
	Name   string `gorm:"size:255"`
	Secret string `gorm:"size:255"`
	gorm.Model
}

func CreateUser(user *User) error {
	return _DB.Create(user).Error
}

func GetUserByNameAndPassword(name, password string) (*User, error) {
	var user User
	err := _DB.Where("name = ? and secret = ?", name, password).First(&user).Error
	if err != nil {
		return nil, err
	}
	return &user, nil
}

func UpdateUserNameById(id int, name string) error {
	return _DB.Model(&User{}).Where("id = ?", id).Update("name", name).Error
}

gorm-mock_test.go

package main

import (
	"database/sql"
	"database/sql/driver"
	"github.com/DATA-DOG/go-sqlmock"
	"github.com/stretchr/testify/assert"
	"gorm.io/driver/mysql"
	"gorm.io/gorm"
	"os"
	"testing"
	"time"
)

var (
	db   *sql.DB
	mock sqlmock.Sqlmock
	err  error
)

// TestMain 是当前 package下,最先运行的函数 常用于 初始化
func TestMain(m *testing.M) {
	// 把匹配器设置成相等匹配器 ,不设置默认使用 正则 匹配
	db, mock, err = sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
	if err != nil {
		panic(err)
	}
	open, err := gorm.Open(mysql.New(mysql.Config{SkipInitializeWithVersion: true, Conn: db}), &gorm.Config{})
	if err != nil {
		panic(err)
	}
	_DB = open
	// m.Run 是调用包下面各个Test 函数的入口
	os.Exit(m.Run())
}

func TestCreateUser(t *testing.T) {
	type args struct {
		entity *User
		sqlStr string
	}

	tests := []struct {
		name   string
		args   args
		except string
	}{
		{
			name: "测试创建用户",
			args: args{
				entity: &User{
					Name:   "",
					Secret: "",
					Model: gorm.Model{
						ID:        1,
						CreatedAt: time.Now(),
						UpdatedAt: time.Now(),
						DeletedAt: gorm.DeletedAt{},
					},
				},
				sqlStr: "INSERT INTO `users` (`name`,`secret`,`created_at`,`updated_at`,`deleted_at`,`id`) VALUES (?,?,?,?,?,?)",
			},
		},
		{
			name: "测试创建用户2",
			args: args{
				entity: &User{
					Name:   "",
					Secret: "",
					Model: gorm.Model{
						ID:        1,
						CreatedAt: time.Now(),
						UpdatedAt: time.Now(),
					},
				},
				sqlStr: "INSERT INTO `users` (`name`,`secret`,`created_at`,`updated_at`,`deleted_at`,`id`) VALUES (?,?,?,?,?,?)",
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mock.ExpectBegin()
			mock.ExpectExec(tt.args.sqlStr).
				WithArgs(tt.args.entity.Name, tt.args.entity.Secret, tt.args.entity.CreatedAt, tt.args.entity.UpdatedAt, tt.args.entity.DeletedAt, tt.args.entity.ID).WillReturnResult(sqlmock.NewResult(1, 1))
			mock.ExpectCommit()
			err := CreateUser(tt.args.entity)
			assert.Nil(t, err)

		})

	}

}

func TestGetUserByNameAndPassword(t *testing.T) {
	type args struct {
		entity *User
		sqlStr string
	}
	tests := []struct {
		name   string
		args   args
		except string
	}{
		{
			name: "测试获取用户",
			args: args{
				entity: &User{
					Name:   "ifnk",
					Secret: "ifnk",
					Model: gorm.Model{
						ID:        1,
						CreatedAt: time.Now(),
						UpdatedAt: time.Now(),
						DeletedAt: gorm.DeletedAt{},
					},
				},
				sqlStr: "SELECT * FROM `users` WHERE (name = ? and secret = ?) AND `users`.`deleted_at` IS NULL ORDER BY `users`.`id` LIMIT 1",
			},
		},
		{
			name: "测试获取用户",
			args: args{
				entity: &User{
					Name:   "ddd",
					Secret: "ddd",
					Model: gorm.Model{
						ID:        1,
						CreatedAt: time.Now(),
						UpdatedAt: time.Now(),
					},
				},
				sqlStr: "SELECT * FROM `users` WHERE (name = ? and secret = ?) AND `users`.`deleted_at` IS NULL ORDER BY `users`.`id` LIMIT 1",
			},
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mock.ExpectQuery(tt.args.sqlStr).
				WithArgs(tt.args.entity.Name, tt.args.entity.Secret).
				WillReturnRows(
					// 这里要跟结果集包含的列匹配,因为查询是 SELECT * 所以表的字段都要列出来
					sqlmock.NewRows([]string{"id", "name", "secret", "created_at", "updated_at"}).
						AddRow(1, tt.args.entity.Name, tt.args.entity.Secret, tt.args.entity.CreatedAt, tt.args.entity.UpdatedAt))

			res, err := GetUserByNameAndPassword(tt.args.entity.Name, tt.args.entity.Secret)
			assert.Nil(t, err)
			assert.Equal(t, tt.args.entity.Name, res.Name)
		})
	}
}
func TestUpdateUserNameById(t *testing.T) {
	type args struct {
		id   int
		name string
	}
	tests := []struct {
		name   string
		args   args
		except string
	}{
		{name: "测试更新用户", args: args{id: 1, name: "ifnk"}, except: "UPDATE `users` SET `name`=?,`updated_at`=? WHERE id = ? AND `users`.`deleted_at` IS NULL"},
		{name: "测试更新用户2", args: args{id: 2, name: "dudu"}, except: "UPDATE `users` SET `name`=?,`updated_at`=? WHERE id = ? AND `users`.`deleted_at` IS NULL"},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			mock.ExpectBegin()
			mock.ExpectExec(tt.except).
				WithArgs(time.Now(), tt.args.name, tt.args.id).
				WillReturnResult(sqlmock.NewResult(1, 1))
			mock.ExpectCommit()
			err := UpdateUserNameById(tt.args.id, tt.args.name)
			assert.Nil(t, err)
		})
	}
}

// 定义一个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 类型,就能验证通过
	// 这种方式比使用 sqlmock.AnyArg() 限制性更强一些,代码可读性也会更好
	_, ok := v.(time.Time)
	return ok
}

单元测试 UpdateUserNameById 过不去

报的错为

=== RUN   TestUpdateUserNameById/测试更新用户

2022/06/19 15:15:49 /home/ifnk/proj/new_go_stu/go-unit-test/gorm-mock/gorm-mock.go:44 ExecQuery 'UPDATE `users` SET `name`=?,`updated_at`=? WHERE id = ? AND `users`.`deleted_at` IS NULL', arguments do not match: argument 0 expected [time.Time - 2022-06-19 15:15:49.902647511 +0800 CST m=+0.002770607] does not match actual [string - ifnk]; call to Rollback transaction, was not expected, next expectation is: ExpectedExec => expecting Exec or ExecContext which:
  - matches sql: 'UPDATE `users` SET `name`=?,`updated_at`=? WHERE id = ? AND `users`.`deleted_at` IS NULL'
  - is with arguments:
    0 - 2022-06-19 15:15:49.902647511 +0800 CST m=+0.002770607
    1 - ifnk
    2 - 1
  - should return Result having:
      LastInsertId: 1
      RowsAffected: 1
[0.112ms] [rows:0] UPDATE `users` SET `name`='ifnk',`updated_at`='2022-06-19 15:15:49.903' WHERE id = 1 AND `users`.`deleted_at` IS NULL
    gorm-mock_test.go:173: 
        	Error Trace:	gorm-mock_test.go:173
        	Error:      	Expected nil, but got: &fmt.wrapError{msg:"ExecQuery 'UPDATE `users` SET `name`=?,`updated_at`=? WHERE id = ? AND `users`.`deleted_at` IS NULL', arguments do not match: argument 0 expected [time.Time - 2022-06-19 15:15:49.902647511 +0800 CST m=+0.002770607] does not match actual [string - ifnk]; call to Rollback transaction, was not expected, next expectation is: ExpectedExec => expecting Exec or ExecContext which:\n  - matches sql: 'UPDATE `users` SET `name`=?,`updated_at`=? WHERE id = ? AND `users`.`deleted_at` IS NULL'\n  - is with arguments:\n    0 - 2022-06-19 15:15:49.902647511 +0800 CST m=+0.002770607\n    1 - ifnk\n    2 - 1\n  - should return Result having:\n      LastInsertId: 1\n      RowsAffected: 1", err:(*errors.errorString)(0xc000233b20)}
        	Test:       	TestUpdateUserNameById/测试更新用户
    --- FAIL: TestUpdateUserNameById/测试更新用户 (0.00s)


是我哪里写错了呢?

@kevinyan815
Copy link
Owner Author

kevinyan815 commented Jun 27, 2022 via email

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants