單元測試結合web后臺項目實戰(zhàn)(重構和mock)
背景介紹
經過上一節(jié) 對單元測試框架的基本學習,我們已經掌握了 單元測試的基本寫法
但是 對一個web 后臺項目來說,往往需要依賴一些基礎服務(數(shù)據(jù)庫、緩存等),實際生產環(huán)境當然是要連接這些基礎服務的,但是測試環(huán)境不一定能連通這些服務(測試環(huán)境最好是要能),因此編寫mock 也是一個比較必要的過程。
如何讓測試代碼寫得更好,結合業(yè)務代碼更優(yōu)雅,也讓后人能夠更方便地開發(fā),是一個需要探索的過程。這篇文章將會通過我的一個測試項目的重構過程,介紹如何更好地從單測框架的選用,到項目的重構,再到最后單測代碼(數(shù)據(jù)庫mock)的編寫,一個完整的流程
測試框架選用
sqlmock(不夠通用)
數(shù)據(jù)庫操作這塊,有一個開源的mock 框架,sqlmock

但是這限于 使用 原生sql.DB 類,使用起來才方便,如果業(yè)務用到了其他 orm 框架(如:gorm)就不方便了
業(yè)務代碼抽象 + testify + gomonkey 結合
最終選用了這種方式
測試框架為什么選擇 testify + gomonkey 前一篇博客有介紹:使用更加方便,和業(yè)務代碼耦合性也不強
業(yè)務代碼需要抽象成對象的原因:原來的業(yè)務方法都是直接放到業(yè)務的公共方法中,層級感不強
重構的基本思路就是 將數(shù)據(jù)庫連接池管理、業(yè)務操作管理 都抽象成對象
最終當所有資源層、service 層和 controller 層都封裝成對象的時候,就不會再有這三層中的對象,在var 中直接初始化業(yè)務對象的情況了(最多初始化鎖),這樣寫起mock 來才會更方便
實際實現(xiàn)
框架設計

業(yè)務代碼重構具體實現(xiàn)
首先項目架構還是比較經典的 controller - service - 數(shù)據(jù)層架構,下面從這三層分別介紹重構方式
DB 層
原實現(xiàn):所有的DB 操作方法,都放到mysql 這一個package 的公共方法中,也沒有封裝成對象的方法
service 和 controller 層其實都是這么實現(xiàn)的,因此三層的重構思路都是類似的:將package 的公共方法 抽象到對象的方法中
// https://gitee.com/atamagaii/mygoproject/blob/307b555ed5d6969169f27e05dc08aa3bf20ffdd2/src/database/mysql/schooldata.go
package mysql
......
func GetStudentSimpleInfoById(dbSchool *gorm.DB, studentId string) *model.Student {
currentStudent := new(model.Student)
err := dbSchool.Where("id = ?", studentId).Select("id, name, sex").Offset(0).Limit(10).
First(¤tStudent).Error
if nil != err {
log.Printf("[GetStudentSimpleInfoById] 查詢DB錯誤,請檢查: %s", err.Error())
}
return currentStudent
}
現(xiàn)實現(xiàn):按上圖的設計,首先會有一個DB 資源管理對象,另外具體業(yè)務的數(shù)據(jù),也會有一個獨立的管理類,如下:
package resource
......
var (
// 數(shù)據(jù)庫連接管理 單例
dbControllerInstance DBControllerInterface
// 數(shù)據(jù)庫連接管理單例 獲取鎖
dbControllerInstanceLock = sync.Once{}
)
// 獲取數(shù)據(jù)庫連接管理單例
func NewDBController() DBControllerInterface {
dbControllerInstanceLock.Do(func() {
dbControllerInstance = new(GormController)
dbControllerInstance.InitResource()
})
return dbControllerInstance
}
// 數(shù)據(jù)庫連接管理 接口定義
type DBControllerInterface interface {
InitResource() error
GetStudentController() studentsql.StudentDataInterface
}
// Gorm 連接資源管理 實際實現(xiàn)類
type GormController struct {
// 學生數(shù)據(jù)處理controller
studentController studentsql.StudentDataInterface
}
// gorm 控制類: 獲取學校數(shù)據(jù)業(yè)務操作對象
func (controller *GormController) GetStudentController() studentsql.StudentDataInterface {
return controller.studentController
}
// Gorm 連接初始化
// 包含兩部分初始化:gorm 連接初始化、數(shù)據(jù)操作初始化(將初始化成功的gorm 連接池綁定過去)
func (controller *GormController) InitResource() error {
dbSchool := config.GetDBConnection(mysql.DB_NAME_LOCAL)
controller.studentController = new(studentsql.StudentDataController)
controller.studentController.SetDBResource(dbSchool)
return nil
}
// Gorm 連接資源管理,mock 類
type GormMockController struct {
studentController studentsql.StudentDataInterface
}
// gorm mock 類 資源初始化
func (controller *GormMockController) InitResource() error {
controller.studentController = new(studentsql.StudentDataMockController)
controller.studentController.SetDBResource(nil)
return nil
}
......
DB 資源管理類:分別定義了 DBControllerInterface、GormController 和 GormMockController ,參考前面的架構圖示例,分別表示 DB 控制器接口定義、實際實現(xiàn)類 和 mock 類。
package studentsql
......
// 學生數(shù)據(jù)處理 接口
type StudentDataInterface interface {
SetDBResource(*gorm.DB)
GetStudentSimpleInfoById(string) *model.Student
GetStudentSimpleInfoByName(string) *model.Student
UpdateStudentGrade(*model.Student) int64
GetGradeSummaryByGradeId(string) []*model.GradeSummary
GetAllStudentName() []string
}
// 學生信息處理 實際實現(xiàn)類
type StudentDataController struct {
// gorm 連接池
dbSchool *gorm.DB
}
// 設置gorm 連接池,初始化的時候要執(zhí)行
func (controller *StudentDataController) SetDBResource(db *gorm.DB) {
controller.dbSchool = db
}
// 通過學生id獲取學生的基本信息
func (controller *StudentDataController) GetStudentSimpleInfoById(studentId string) *model.Student {
currentStudent := new(model.Student)
err := controller.dbSchool.Where("id = ?", studentId).Select("id, name, sex").Offset(0).Limit(10).
First(¤tStudent).Error
if nil != err {
log.Printf("[GetStudentSimpleInfoById] 查詢DB錯誤,請檢查: %s", err.Error())
}
return currentStudent
}
......
// 學生信息處理 mock 類
type StudentDataMockController struct{}
// mock: 設置gorm 連接池
func (controller *StudentDataMockController) SetDBResource(db *gorm.DB) {
log.Printf("[mock] mock db init, will not actually init!")
}
......
學生數(shù)據(jù)處理類:同樣定義了三個類:StudentDataInterface、StudentDataController和 StudentDataMockController,關系和 DB 資源控制類 是類似的。
InitResource 方法的實現(xiàn)原理:因為 DBController 是要給 上層(service層)用的,因此 DBController 和 StudentDataController 本身的對應關系要自己確認清楚,比如 GormMockController 初始化 其StudentDataController 對象的時候,就要用 StudentDataMockController ,也就是都要用mock 類。
tips:單例模式鎖這里用到了 sync.Once 來實現(xiàn),參考博客
service
service 層主要是對SchoolService 對象的抽象,原實現(xiàn):
package schoolservice
......
func GetStudentById(id string) *model.Student {
log.Printf("[GetStudentById] 獲取學生信息,學生id: %s", id)
return mysql.GetStudentSimpleInfoById(mysql.DBSchool, id)
}
......
現(xiàn)實現(xiàn):
package schoolservice
......
// 學校業(yè)務處理類
type SchoolService struct {
dbController dbresource.DBControllerInterface
}
var (
// 學校業(yè)務處理單例對象
schoolServiceInstance *SchoolService
// 獲取學校 業(yè)務處理單例對象鎖
schoolServiceInstanceLock = sync.Once{}
)
// 獲取學校業(yè)務處理對象
func NewSchoolService() *SchoolService {
schoolServiceInstanceLock.Do(func() {
schoolServiceInstance = new(SchoolService)
schoolServiceInstance.dbController = dbresource.NewDBController()
})
return schoolServiceInstance
}
// 根據(jù)學生id 獲取學生信息
func (service *SchoolService) GetStudentById(id string) *model.Student {
log.Printf("[GetStudentById] 獲取學生信息,學生id: %s", id)
return service.dbController.GetStudentController().GetStudentSimpleInfoById(id)
}
......
controller
重構思路同上,現(xiàn)實現(xiàn):
package schoolcontroller
......
// 學校業(yè)務邏輯控制層
type SchoolController struct {
schoolService *schoolservice.SchoolService
}
var (
// 學校controller 單例
schoolControllerInstance *SchoolController
// 學校controller 單例鎖
schoolControllerInstanceLock = sync.Once{}
)
// 獲取學校controller 對象 單例
func NewSchoolController() *SchoolController {
schoolControllerInstanceLock.Do(func() {
schoolControllerInstance = new(SchoolController)
schoolControllerInstance.initSchoolController()
})
return schoolControllerInstance
}
// 初始化學校controller
func (controller *SchoolController) initSchoolController() {
controller.schoolService = schoolservice.NewSchoolService()
}
// 通過學生id 獲取學生信息(只查DB)
func (controller *SchoolController) GetStudentById(context *gin.Context) {
studentId := context.Query("id")
student := controller.schoolService.GetStudentById(studentId)
context.JSON(200, student)
}
......
測試代碼實現(xiàn)
通過上面重構的過程,我們了解到 DB 層是有專門的mock 方法的,因此只要將 實際DB controller 的初始化方法,mock 成 mock類的初始化方法就可以了:
func TestGetStudentById(t *testing.T) {
currentMock := gomonkey.ApplyFunc(dbresource.NewDBController, dbresource.NewDBMockController)
defer currentMock.Reset()
schoolService := schoolservice.NewSchoolService()
student := schoolService.GetStudentById("1")
assert.NotEqual(t, "", student.Name)
}
關鍵就是第一步:將 dbresource.NewDBController mock 成 dbresource.NewDBMockController
后面照常執(zhí)行業(yè)務邏輯就行,由于DB controller 已經被mock 了,最終DB 層邏輯也會走mock 類的邏輯。至于你是想讓mock 類查測試DB,還是只是 返回一個虛擬的查詢結果,就看實際測試的需求了
建議 修改操作 還是要連接測試DB ,真正跑數(shù)據(jù)庫方法執(zhí)行(修改操作影響比較大,最好有一個實際驗證的過程),查詢操作返回一個虛擬結果就行了。
總結
-
從可測試性來看:只有把對象封裝好,對象的初始化都改成主動調用初始化方法,而不是使用 init 方法,或者直接在 var 中初始化,后續(xù)所有的方法、對象才能更方便地寫mock。
如上面介紹,如果后續(xù)業(yè)務要新加一個接口,只需要在 業(yè)務實際實現(xiàn)類(StudentDataController) 和 mock 類(StudentDataMockController) 中定義新接口就可以了。
同樣,如果開發(fā)想要給一個查詢接口,新加一種查詢場景,也是只要修改實際的業(yè)務方法(StudentDataController.GetStudentSimpleInfoById),和在mock 類的方法(StudentDataMockController.GetStudentSimpleInfoById) 中,新加對應場景的邏輯就可以了,結構非常清楚。 -
從資源初始化來看:初始化操作都改成主動之后,只有第一次“被用到”的資源才會被初始化,服務剛啟動,還沒有流量進來的時候,是不會做任何初始化動作的,避免在服務依賴外部資源的時候,啟動需要花費非常長的時間。
不過,這當然也要求了服務在資源初始化的時候,需要做更多的異常檢測和提醒機制。 -
從服務運營能力來看:每一層都抽象成對象之后,基本就不存在什么執(zhí)行業(yè)務邏輯 還要用到全局變量的情況了,所有的資源都是通過一個“管理員對象” 來操作的。這樣封裝好之后,后續(xù)還可以做更多事情:比如DB層可以做 可用連接數(shù)的自監(jiān)控,service 層可以做常用方法統(tǒng)計,controller 層可以做針對用戶級別的流量控制,等等。
通過這次對自己測試項目的重構,我更加意識到了:任何需求都是要經過詳細的設計,要思考很多可擴展性、可測試性,之后再實現(xiàn),才能夠真正對“需求”本身負責,寫出來的代碼和功能才能夠真正“可維護”。
只是為了實現(xiàn)需求的目標而寫代碼,最終寫出來的代碼對自己、對后人都將會是災難性的后果。

浙公網安備 33010602011771號