在我们微服务日常开发中,无法避免的会使用到很多三方依赖Service,最典型的就是MySQL,除此,还有其他的 ZK,Redis,Mongo,MQ, Consul, ES 等等。 众多中间件的使用,对测试过程也带来一定的复杂度。假如我想让我的产品UT覆盖率达到要求 >90%, 那么依赖组件的UT是非常麻烦的一件事情。大多数情况下我们都会使用跳过的方式,把对中间件的依赖测试全量透出到集成测试环节,期望能通过对产品功能的测试覆盖到中间件使用的测试。当然在不要求UT覆盖的的情况下,面向依赖的UT也应该是有价值的,是研发流程不可或缺的部分,不针对于中间件测试也会给我们代码留下足够多隐患。
在没有合适的中间价UT方法,在UT环节我们大部分会使用Mock 方式对DAO层对gorm的使用进行绕过, 以MySQL为例我们做一个简单的demo。完整代码可通过github访问获取。
var DB *gorm.DB
type Product struct {
Code string
Price int
}
type Repository struct {
}
func NewRepository() *Repository {
return &Repository{}
}
func OpenDB(dbUrl string) (*gorm.DB, error) {
return gorm.Open(mysql.Open(dbUrl), &gorm.Config{})
}
func (r *Repository) Select() (Product, error) {
var product Product
err := DB.First(&product, "code = ?", "D42").Error // 查找 code 字段值为 D42 的记录
return product, err
}
func (r *Repository) Create(product Product) error {
return DB.Create(&product).Error
}
DAO层使用gorm 定义了一个公共变量DB *gorm.DB用来做全局MySQL连接。OpenDB(dbUrl string) 用来根据地址获取连接
Create和Select 分别用于创建数据和查询某个条件的数据。
func init() {
db, err := dao.OpenDB("")
if err != nil {
panic(err)
}
dao.DB = db
}
func QueryData() (*dao.Product, error) {
r := dao.NewRepository()
product, err := r.Select()
if err != nil {
return nil, err
}
err = DoSomethingUseProduct(product)
return &product, err
}
func DoSomethingUseProduct(product dao.Product) error {
//todo
fmt.Println(product)
return nil
}
我们通过init方法在程序运行前创建DB连接初始化到公共连接中,QueryData 通过Select查询数据和Dosomething完成一些业务逻辑。
现在我们开始对QueryData编写一个UT,大概应该是这个样子,这里使用字节开源的 github.com/bytedance/mockey 包。
func TestQueryData(t *testing.T) {
mockey.PatchConvey("22", t, func() {
mockey.Mock((*dao.Repository).Select).Return(dao.Product{
Price: 1,
}, nil).Build()
defer mockey.UnPatchAll()
mockey.Mock(DoSomethingUseProduct).Return(nil).Build()
product, err := QueryData()
assert.Nil(t, err)
assert.Equal(t, 1, product.Price)
dao.DB = nil
})
}
无法连接本地连接数据库,我们会优先考虑mock绕过gorm 层真实的执行,而让UT继续下去。 *dao.Repository).Select
方法的执行是ut无法覆盖到的。到这里就会有老铁有几个疑问。
————————————————————————————————————————————————————————
Q1 那如果本地创建一个mysql,导入表结构不就可以解决了
A: 一般业务项目都是多人合作完成,如果A在代码中增加了需要本地部署环境的单元测试代码,那么在B,C, D等等大家需要执行ut都需要部署一遍环境甚至初始化同样的数据。 如果项目需要在CI环境执行,也同样需要部署环境。代码可读性差,复用度低,如果项目还依赖了其他中间件,每个都需要部署一套的代价有点大。
Q2 DAO层只是一些简单的SQL 增删改查逻辑无需要通过ut来测试
A: 引入中间件,是因为业务逻辑必须依赖。换句话说,MySQL等中间件即然你使用一定是强依赖,当执行出现错误的时候就意味着业务逻辑出现了问题。 如果是简单的增删改查功能在产品功能验收时可能会覆盖掉,但是一些复杂的产品功能是基于复杂的数据组合来完成的。举个简单例子,一个列表页有10个字段,需要实现基于每个字段的筛选和排序。实现该功能的代码可能是如下
func Query(condition *QueryCondition) []*Resp {
db := dao.GetDB().Select("*")
if condition.Field1 != nil {
db = db.where("Field1 = ?", condition.Field1)
}
if condition.Field2 != nil {
db = db.where("Field2 = ?", condition.Field2)
}
......(其他的if)
if condition.Field10 != nil {
db = db.where("Field10 = ?", condition.Field10)
}
.......(其他分页排序逻辑)
}
基于这个例子,因为Query方法属于底层方法,在上层可能又有f1,f2, f3等一系列的调用,最终构成复杂逻辑网络。
通过产品功能验收可能无法覆盖到所有的组合场景,假设其中一个条件编写时字段错误或者语法错误,在产品功能测试时刚好未覆盖到。上线后被用户使用中再发现,那时候已经太晚了。(根据真实案例描述,产品上线后发现SQL语法错误,最终导致产品严重收入损失)
————————————————————————————————————————————————————————
这里我们回到主题
对mysql gorm层mock无非以下几种场景
除了select mock data 外,其他是不看起来毫无意义,实际也毫无意义。因为, 如上面案例执行SQL不总是Success,Error也是存在的。比如常见的语法错误,字段拼写错误,数据格式,时间格式错误等等。 那么这些Error只能在集成测试环节发现。在逻辑不复杂的功能点上,部署测试环节并进行FT能够发现问题。但是,在业务开发中总会有些复杂逻辑FT环节是黑盒测试,怎么能确保每个if都能测试到。其次,即使在FT环节发现问题,也需要人力返工fix,然后再部署, 再测试,又失败,再fix ........ (即使云原生环境支持快速部署但也让开发者心态奔溃)
比如上面说的MySQL ,最简单的方式是我们可以在本地部署一个MySQL,然后连接进行 Test,但是有几个问题:
而今天介绍的神器Testcontainer 完美解决了这一系列问题。
Testcontainers 是一个开源的用于支持单元测试的三方依赖库, 提供了简单且轻量级的 API,用于使用以 Docker 容器包装的真实服务来启动本地开发和测试依赖项的依赖中间件。通过使用 Testcontainers,您可以编写依赖于与生产环境相同的服务的测试,而无需使用模拟对象或内存中的服务。
简单说,它仅仅是一个依赖库lib,而不是一个服务。第二,通过Docker容器快速创建你需要的依赖Server并提供使用。一切可容器化的外部依赖它都可以支持,并且支持多种常见的编程语言和几乎所有常见使用的中间件。 完备的容器创建和自动回收机制,使用中无需关注容器的回收问题。
想要详细了解的同学可以访问官网了解。 testcontainers官网
基于上面的测试代码,我们在其基础上创建使用TestContainer进行单元测试
##demo go version是go_1.19, 对应的版本号是v0.20
##根据需要测试的对象选择modules包,其他的可以去代码仓库Tag找
##https://github.com/pingcap/tidb/tree/master
go get github.com/testcontainers/testcontainers-go@v0.20.0
go get github.com/testcontainers/testcontainers-go/modules/mysql@v0.20.0
##如果需要其他组件
go get github.com/testcontainers/testcontainers-go/modules/postgres@v0.20.0
创建testhelper.go文件,用于编写依赖容器创建代码
func init() {
if dao.DB != nil {
return
}
err, mysqlTestUrl := CreateTestMySQLContainer(context.Background())
if err != nil {
panic(err)
}
dao.DB, err = dao.OpenDB(mysqlTestUrl)
if err != nil {
panic(err)
}
}
func CreateTestMySQLContainer(ctx context.Context) (error, string) {
container, err := mysql.RunContainer(ctx,
testcontainers.WithImage("mysql:8.0"),
mysql.WithDatabase("test_db"),
mysql.WithUsername("root"),
mysql.WithPassword("root@123"),
//也可以使用sql脚本初始化数据库
//mysql.WithScripts(filepath.Join("..", "testdata", "init-db.sql")
)
if err != nil {
return err, ""
}
//获取访问连接
str, err := container.ConnectionString(ctx)
if err != nil {
return err, ""
}
//打印连接,可以通过连接在本地环境登录构建mysql
log.Printf("can use this connecting string to login in db:%s", str)
return nil, str
}
//需要其他依赖容器可以类似创建
//func CreateTestRedisContainer(ctx context.Context) error {}
//func CreateTestZKContainer(ctx context.Context) error {}
我们知道go的import加载机制是先执行import 引入依赖中的init()方法,再执行自己包中的init,然后执行调用代码。
这里我们通过init方法创建用于ut初始的mysql docker容器,并初始化全局DB连接。UT需要测试dao层时在import引入路径即可。其他团队开发者后期并不需要关注容器的创建。
func TestQueryDataUseContainer(t *testing.T) {
mockey.PatchConvey("23", t, func() {
//初始化需要测试的表,需要测试哪些表就初始化哪些
err := dao.DB.AutoMigrate(dao.Product{})
assert.Nil(t, err)
r := dao.NewRepository()
//写入临时测试数据
err = r.Create(dao.Product{
Code: "D42",
Price: 1,
})
assert.Nil(t, err)
//执行测试
mockey.Mock(DoSomethingUseProduct).Return(nil).Build()
product, err := QueryData()
assert.Nil(t, err)
assert.Equal(t, 1, product.Price)
})
}
可以看到在ut执行过程中确实进行了mysql的相关真实操作,这样我们的代码就不再需要部署到专门的环境就可以完成一定覆盖率的测试。 比如还有Redis, MQ, Kakfa, ES等中间件依赖可以以同样的方式进行测试。
Q: 引入TestContainer创建测试测试容器,会不会占用资源或者导致我们UT耗时很长?
经过测试,MAC本地研发环境下MySQL容器拉起 time < 20s,在纯净的CI/CD环境我相信会有更好的表现
资源占用倒也不用关注,容器拉起占用极少资源,比本地安装MySQL肯定少很多,并且在使用完成后会进行回收。
Q: 是否需要进行容器的管理,比如使用完关闭释放资源,避免资源泄露
不需要,测试执行完成后,Testcontainers 库会使用 Ryuk sidecar 容器自动删除任何创建的资源(容器、卷、网络等),即使测试进程异常退出(例如发送SIGKILL),它也能可靠地工作。
但是如果同时测试很多个中间件可以做好编排尽量避免容器同时拉起,会对资源有一定的损耗。如果大家有更好的见解或疑问欢迎评论区留言。(原创不易,请勿转载)
下载地址: https://github.com/fengfeihack/testcontiner_demo
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。