Go項目實戰(zhàn)--數(shù)據(jù)Dao層代碼的單元測試實戰(zhàn)
Dao的單元測試
講到數(shù)據(jù)庫的單元測試,一般有那么幾個流派
- 專門準備一個獨立的數(shù)據(jù)庫,單元測試時讓所有測試用例讀寫這個獨立的數(shù)據(jù)庫,它的優(yōu)點是單測真的去讀寫數(shù)據(jù)庫啦,缺點嘛也顯而易見,一個項目的數(shù)據(jù)庫不是光有表就行,還得準備測試數(shù)據(jù),這個搞起來就有點麻煩,尤其是關(guān)聯(lián)性強的數(shù)據(jù),造起來更麻煩。
- 讓項目在單元測試時訪問內(nèi)存數(shù)據(jù)庫,它的優(yōu)缺點其實跟上個差不多。
- 采用sqlmock類的工具,對Dao要執(zhí)行的SQL作出預(yù)期匹配,同時Mock SQL查詢要返回的數(shù)據(jù),保證Dao方法內(nèi)部的邏輯正常執(zhí)行。
我們這里采用的是第三個流派,用 sqlmock 方式來做數(shù)據(jù)庫Dao的單元測試,本節(jié)的內(nèi)容大綱主要如下:
圖片
這里我們會用到DataDog家開發(fā)的go-sqlmock這個工具,先來安裝一下它:
github.com/DATA-DOG/go-sqlmock
安裝過程如下:
圖片
單元測試入口TestMain的設(shè)置
我們計劃在 UserDao 和 OrderDao 中找?guī)讉€典型的方法來做單元測試的實戰(zhàn),這里我們先在新建test/dao/user_test.go,創(chuàng)建完之后還不能馬上開始寫測試用例,我們再來做一下dao層單元測試的基礎(chǔ)工作。
在TestMain方法中初始化go-sqlmock ,這樣整個dao下的測試用例就都能使用它了,TestMain是在當(dāng)前package下最先運行的一個函數(shù),無論你運行哪個測試用例TestMain都會先被Go調(diào)用,所以它常用于測試基礎(chǔ)組件的初始化。
我們的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())
}
這里我們創(chuàng)建一個 go-sqlmock 的數(shù)據(jù)庫連接 和 mock對象,mock對象管理 db 預(yù)期要執(zhí)行的SQL,具體初始化中各個參數(shù)的作用,直接看我上面代碼里的注視吧。
因我我們項目里Dao使用的數(shù)據(jù)庫連接在包外不可訪問,所以我在這里給項目dao層里加了 SetDBMasterConn,SetDBSlaveConn兩個方法把我們原本的數(shù)據(jù)庫連接替換成了sqlmock的數(shù)據(jù)庫連接。
基礎(chǔ)設(shè)置完成后,接下來我們分別找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會用到的數(shù)據(jù)userInfo和passwordHash,然后使用 ExpectExec 指定預(yù)期要執(zhí)行的SQL以及預(yù)期返回的結(jié)果。
這里我來說明一下sqlmock 默認使用 sqlmock.QueryMatcherRegex 作為默認的SQL匹配器,該匹配器使用mock.ExpectQuery 和 mock.ExpectExec 的參數(shù)作為正則表達式與真正執(zhí)行的SQL語句進行匹配,如果使用QueryMatcherEqual 作為匹配器的話,那么我們寫預(yù)期SQL時就要寫完整的SQL了。
我推薦用默認的匹配器就行,因為接下來的WithArgs中我們還要給SQL的 ? 占位符提供參數(shù)值,這個參數(shù)值如果數(shù)量或者類型匹配不上的話,單測依然是無法通過的。
WillReturnResult(sqlmock.NewResult(1, 1)) 這行的意思是SQL執(zhí)行后返回的 lastInsertId 是 1, 受影響行數(shù)也是 1。
拿到結(jié)果之后我們再做assert斷言,判斷結(jié)果是否符合預(yù)期。符合預(yù)期則通過,不符合的話測試用例會失敗。大家可以自己嘗試修改一下這個用例看它執(zhí)行失敗的效果。
Select 查詢的單元測試
關(guān)于SQL查詢的單元測試,和上面的區(qū)別是我們會Mock返回的結(jié)果集,這里我們拿的是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 指定了兩個預(yù)期要執(zhí)行的SQL是為什么呢?因為GetUserOrders方法即返回了用戶訂單列表還返回了數(shù)據(jù)分頁用的totalRaws變量,大家可以試試把它刪掉看看這個單元測試能不能執(zhí)行成功,這里我可以告訴你結(jié)果會成功但又沒完全成功,會有一條Warning警告,報告出有一個執(zhí)行的SQL沒有做預(yù)期匹配。
執(zhí)行單元測試時可以用上面我教的命令,也可以用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完全匹配模式時才必須用它,因為參數(shù)提供的Time.Now()做為UpdatedAt的時間,這與SQL執(zhí)行時真正的UpdateAt時間是有很小的差異的,這個時候我們可以提供AnyTime做為更新時間,這樣sqlmock在做預(yù)期SQL和實際SQL的匹配時,遇到了AnyTime類型的預(yù)期值,就會按照這里指定的規(guī)則,判斷字段值只要是time.Time 類型就能驗證通過。
總結(jié)
本節(jié)代碼版本為c19.1
git fetch --tags
git checkout tags/c19.1
訪問 https://github.com/go-study-lab/go-mall/compare/c18...c19.1 可在線查看詳細的代碼更新。