单元测试结合web后台项目实战(重构和mock)
背景介绍
经过上一节 对单元测试框架的基本学习,我们已经掌握了 单元测试的基本写法
但是 对一个web 后台项目来说,往往需要依赖一些基础服务(数据库、缓存等),实际生产环境当然是要连接这些基础服务的,但是测试环境不一定能连通这些服务(测试环境最好是要能),因此编写mock 也是一个比较必要的过程。
如何让测试代码写得更好,结合业务代码更优雅,也让后人能够更方便地开发,是一个需要探索的过程。这篇文章将会通过我的一个测试项目的重构过程,介绍如何更好地从单测框架的选用,到项目的重构,再到最后单测代码(数据库mock)的编写,一个完整的流程
测试框架选用
sqlmock(不够通用)
数据库操作这块,有一个开源的mock 框架,sqlmock
但是这限于 使用 原生sql.DB 类,使用起来才方便,如果业务用到了其他 orm 框架(如:gorm)就不方便了
业务代码抽象 + testify + gomonkey 结合
最终选用了这种方式
测试框架为什么选择 testify + gomonkey 前一篇博客有介绍:使用更加方便,和业务代码耦合性也不强
业务代码需要抽象成对象的原因:原来的业务方法都是直接放到业务的公共方法中,层级感不强
重构的基本思路就是 将数据库连接池管理、业务操作管理 都抽象成对象
最终当所有资源层、service 层和 controller 层都封装成对象的时候,就不会再有这三层中的对象,在var 中直接初始化业务对象的情况了(最多初始化锁),这样写起mock 来才会更方便
实际实现
框架设计
业务代码重构具体实现
首先项目架构还是比较经典的 controller - service - 数据层架构,下面从这三层分别介绍重构方式
DB 层
原实现:所有的DB 操作方法,都放到mysql 这一个package 的公共方法中,也没有封装成对象的方法
service 和 controller 层其实都是这么实现的,因此三层的重构思路都是类似的:将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
}
现实现:按上图的设计,首先会有一个DB 资源管理对象,另外具体业务的数据,也会有一个独立的管理类,如下:
package resource
......
var (
// 数据库连接管理 单例
dbControllerInstance DBControllerInterface
// 数据库连接管理单例 获取锁
dbControllerInstanceLock = sync.Once{}
)
// 获取数据库连接管理单例
func NewDBController() DBControllerInterface {
dbControllerInstanceLock.Do(func() {
dbControllerInstance = new(GormController)
dbControllerInstance.InitResource()
})
return dbControllerInstance
}
// 数据库连接管理 接口定义
type DBControllerInterface interface {
InitResource() error
GetStudentController() studentsql.StudentDataInterface
}
// Gorm 连接资源管理 实际实现类
type GormController struct {
// 学生数据处理controller
studentController studentsql.StudentDataInterface
}
// gorm 控制类: 获取学校数据业务操作对象
func (controller *GormController) GetStudentController() studentsql.StudentDataInterface {
return controller.studentController
}
// Gorm 连接初始化
// 包含两部分初始化:gorm 连接初始化、数据操作初始化(将初始化成功的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 控制器接口定义、实际实现类 和 mock 类。
package studentsql
......
// 学生数据处理 接口
type StudentDataInterface interface {
SetDBResource(*gorm.DB)
GetStudentSimpleInfoById(string) *model.Student
GetStudentSimpleInfoByName(string) *model.Student
UpdateStudentGrade(*model.Student) int64
GetGradeSummaryByGradeId(string) []*model.GradeSummary
GetAllStudentName() []string
}
// 学生信息处理 实际实现类
type StudentDataController struct {
// gorm 连接池
dbSchool *gorm.DB
}
// 设置gorm 连接池,初始化的时候要执行
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!")
}
......
学生数据处理类:同样定义了三个类:StudentDataInterface、StudentDataController和 StudentDataMockController,关系和 DB 资源控制类 是类似的。
InitResource 方法的实现原理:因为 DBController 是要给 上层(service层)用的,因此 DBController 和 StudentDataController 本身的对应关系要自己确认清楚,比如 GormMockController 初始化 其StudentDataController 对象的时候,就要用 StudentDataMockController ,也就是都要用mock 类。
tips:单例模式锁这里用到了 sync.Once 来实现,参考博客
service
service 层主要是对SchoolService 对象的抽象,原实现:
package schoolservice
......
func GetStudentById(id string) *model.Student {
log.Printf("[GetStudentById] 获取学生信息,学生id: %s", id)
return mysql.GetStudentSimpleInfoById(mysql.DBSchool, id)
}
......
现实现:
package schoolservice
......
// 学校业务处理类
type SchoolService struct {
dbController dbresource.DBControllerInterface
}
var (
// 学校业务处理单例对象
schoolServiceInstance *SchoolService
// 获取学校 业务处理单例对象锁
schoolServiceInstanceLock = sync.Once{}
)
// 获取学校业务处理对象
func NewSchoolService() *SchoolService {
schoolServiceInstanceLock.Do(func() {
schoolServiceInstance = new(SchoolService)
schoolServiceInstance.dbController = dbresource.NewDBController()
})
return schoolServiceInstance
}
// 根据学生id 获取学生信息
func (service *SchoolService) GetStudentById(id string) *model.Student {
log.Printf("[GetStudentById] 获取学生信息,学生id: %s", id)
return service.dbController.GetStudentController().GetStudentSimpleInfoById(id)
}
......
controller
重构思路同上,现实现:
package schoolcontroller
......
// 学校业务逻辑控制层
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)
}
......
测试代码实现
通过上面重构的过程,我们了解到 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
后面照常执行业务逻辑就行,由于DB controller 已经被mock 了,最终DB 层逻辑也会走mock 类的逻辑。至于你是想让mock 类查测试DB,还是只是 返回一个虚拟的查询结果,就看实际测试的需求了
建议 修改操作 还是要连接测试DB ,真正跑数据库方法执行(修改操作影响比较大,最好有一个实际验证的过程),查询操作返回一个虚拟结果就行了。
总结
-
从可测试性来看:只有把对象封装好,对象的初始化都改成主动调用初始化方法,而不是使用 init 方法,或者直接在 var 中初始化,后续所有的方法、对象才能更方便地写mock。
如上面介绍,如果后续业务要新加一个接口,只需要在 业务实际实现类(StudentDataController) 和 mock 类(StudentDataMockController) 中定义新接口就可以了。
同样,如果开发想要给一个查询接口,新加一种查询场景,也是只要修改实际的业务方法(StudentDataController.GetStudentSimpleInfoById),和在mock 类的方法(StudentDataMockController.GetStudentSimpleInfoById) 中,新加对应场景的逻辑就可以了,结构非常清楚。 -
从资源初始化来看:初始化操作都改成主动之后,只有第一次“被用到”的资源才会被初始化,服务刚启动,还没有流量进来的时候,是不会做任何初始化动作的,避免在服务依赖外部资源的时候,启动需要花费非常长的时间。
不过,这当然也要求了服务在资源初始化的时候,需要做更多的异常检测和提醒机制。 -
从服务运营能力来看:每一层都抽象成对象之后,基本就不存在什么执行业务逻辑 还要用到全局变量的情况了,所有的资源都是通过一个“管理员对象” 来操作的。这样封装好之后,后续还可以做更多事情:比如DB层可以做 可用连接数的自监控,service 层可以做常用方法统计,controller 层可以做针对用户级别的流量控制,等等。
通过这次对自己测试项目的重构,我更加意识到了:任何需求都是要经过详细的设计,要思考很多可扩展性、可测试性,之后再实现,才能够真正对“需求”本身负责,写出来的代码和功能才能够真正“可维护”。
只是为了实现需求的目标而写代码,最终写出来的代码对自己、对后人都将会是灾难性的后果。