单元测试结合web后台项目实战(重构和mock)

背景介绍

经过上一节 对单元测试框架的基本学习,我们已经掌握了 单元测试的基本写法

但是 对一个web 后台项目来说,往往需要依赖一些基础服务(数据库、缓存等),实际生产环境当然是要连接这些基础服务的,但是测试环境不一定能连通这些服务(测试环境最好是要能),因此编写mock 也是一个比较必要的过程。

如何让测试代码写得更好,结合业务代码更优雅,也让后人能够更方便地开发,是一个需要探索的过程。这篇文章将会通过我的一个测试项目的重构过程,介绍如何更好地从单测框架的选用,到项目的重构,再到最后单测代码(数据库mock)的编写,一个完整的流程

我自己的项目git 地址
跟重构相关的提交

测试框架选用

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(&currentStudent).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(&currentStudent).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 层可以做针对用户级别的流量控制,等等。

通过这次对自己测试项目的重构,我更加意识到了:任何需求都是要经过详细的设计,要思考很多可扩展性、可测试性,之后再实现,才能够真正对“需求”本身负责,写出来的代码和功能才能够真正“可维护”。

只是为了实现需求的目标而写代码,最终写出来的代码对自己、对后人都将会是灾难性的后果。

posted @ 2024-12-18 23:14  頭がいい天才  阅读(10)  评论(0编辑  收藏  举报