前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >优雅解决外部依赖的UT问题Testcontainer

优雅解决外部依赖的UT问题Testcontainer

原创
作者头像
COY_fenfei
修改2024-06-27 17:06:38
2640
修改2024-06-27 17:06:38
举报
文章被收录于专栏:COYCOY

在我们微服务日常开发中,无法避免的会使用到很多三方依赖Service,最典型的就是MySQL,除此,还有其他的 ZK,Redis,Mongo,MQ, Consul, ES 等等。 众多中间件的使用,对测试过程也带来一定的复杂度。假如我想让我的产品UT覆盖率达到要求 >90%, 那么依赖组件的UT是非常麻烦的一件事情。大多数情况下我们都会使用跳过的方式,把对中间件的依赖测试全量透出到集成测试环节,期望能通过对产品功能的测试覆盖到中间件使用的测试。当然在不要求UT覆盖的的情况下,面向依赖的UT也应该是有价值的,是研发流程不可或缺的部分,不针对于中间件测试也会给我们代码留下足够多隐患。

为什么需要做依赖UT,Mock(绕过)不可以吗?

在没有合适的中间价UT方法,在UT环节我们大部分会使用Mock 方式对DAO层对gorm的使用进行绕过, 以MySQL为例我们做一个简单的demo。完整代码可通过github访问获取。

代码语言:go
复制
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 分别用于创建数据和查询某个条件的数据。

代码语言:go
复制
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 包。

代码语言:go
复制
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个字段,需要实现基于每个字段的筛选和排序。实现该功能的代码可能是如下

代码语言:go
复制
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无非以下几种场景

  • Insert mock return "err is nil"
  • Update mock return "err is nil"
  • Delete mock return "err is nil"
  • Select Mock return "err is nil and data is mock_data"

除了select mock data 外,其他是不看起来毫无意义,实际也毫无意义。因为, 如上面案例执行SQL不总是Success,Error也是存在的。比如常见的语法错误,字段拼写错误,数据格式,时间格式错误等等。 那么这些Error只能在集成测试环节发现。在逻辑不复杂的功能点上,部署测试环节并进行FT能够发现问题。但是,在业务开发中总会有些复杂逻辑FT环节是黑盒测试,怎么能确保每个if都能测试到。其次,即使在FT环节发现问题,也需要人力返工fix,然后再部署, 再测试,又失败,再fix ........ (即使云原生环境支持快速部署但也让开发者心态奔溃)

那怎么解决依赖测试呢?

比如上面说的MySQL ,最简单的方式是我们可以在本地部署一个MySQL,然后连接进行 Test,但是有几个问题:

  • 用例无法复用,A写的用例B因为缺少环境无法执行;
  • 部署的CI/CD环境也同样需要安装MySQL,依赖过重;
  • 如果还依赖其他,如ZK, Redis,ES等,每个组件都需要本地开发环境安装一遍,成本代价大
  • 环境长期运行多个依赖资源占用多,如果实时拉起耗时长;

而今天介绍的神器Testcontainer 完美解决了这一系列问题。

Testcontainer工具介绍

Testcontainers 是一个开源的用于支持单元测试的三方依赖库, 提供了简单且轻量级的 API,用于使用以 Docker 容器包装的真实服务来启动本地开发和测试依赖项的依赖中间件。通过使用 Testcontainers,您可以编写依赖于与生产环境相同的服务的测试,而无需使用模拟对象或内存中的服务。

简单说,它仅仅是一个依赖库lib,而不是一个服务。第二,通过Docker容器快速创建你需要的依赖Server并提供使用。一切可容器化的外部依赖它都可以支持,并且支持多种常见的编程语言和几乎所有常见使用的中间件。 完备的容器创建和自动回收机制,使用中无需关注容器的回收问题。

想要详细了解的同学可以访问官网了解。 testcontainers官网

使用TestContainer的优势

  • 按需隔离基础设施配置: 您不需要预先配置集成测试基础设施。测试容器将在运行测试之前提供所需的服务。即使多个构建管道并行运行,也不会出现测试数据污染,因为每个管道都运行一组隔离的服务。
  • 在本地和 CI 环境中获得一致的体验: 您可以直接从 IDE 运行集成测试,就像运行单元测试一样。无需推送更改并等待 CI 管道完成。
  • 使用等待策略的可靠测试设置: 在测试中使用 Docker 容器之前,需要启动并完全初始化它们。 Testcontainers 库提供了几种开箱即用的等待策略实现,以确保容器(以及其中的应用程序)完全初始化。 Testcontainers 模块已经实现了给定技术的相关等待策略,并且您始终可以根据需要实现自己的策略或创建复合策略。
  • 高级网络功能: 测试容器库将容器的端口映射到主机上可用的随机端口,以便您的测试可靠地连接到这些服务。您甚至可以创建一个 (Docker) 网络并将多个容器连接在一起,以便它们通过静态 Docker 网络别名相互通信。
  • 自动清理: 测试执行完成后,Testcontainers 库会使用 Ryuk sidecar 容器自动删除任何创建的资源(容器、卷、网络等)。在启动所需的容器时,Testcontainers 会将一组标签附加到创建的资源(容器、卷、网络等),并且 Ryuk 通过匹配这些标签自动执行资源清理。即使测试进程异常退出(例如发送SIGKILL),它也能可靠地工作。

实践DEMO

基于上面的测试代码,我们在其基础上创建使用TestContainer进行单元测试

载入Testcontainer依赖库
代码语言:javascript
复制
##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 

创建用于UT的Container

创建testhelper.go文件,用于编写依赖容器创建代码

代码语言:javascript
复制

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引入路径即可。其他团队开发者后期并不需要关注容器的创建。

使用TestContainer编写UT

代码语言:go
复制
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),它也能可靠地工作。

TestContainer运行时容器情况
TestContainer运行时容器情况
完成后全部已自动回收
完成后全部已自动回收

但是如果同时测试很多个中间件可以做好编排尽量避免容器同时拉起,会对资源有一定的损耗。如果大家有更好的见解或疑问欢迎评论区留言。(原创不易,请勿转载)

demo完整代码

下载地址: https://github.com/fengfeihack/testcontiner_demo

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 为什么需要做依赖UT,Mock(绕过)不可以吗?
  • 那怎么解决依赖测试呢?
  • Testcontainer工具介绍
  • 使用TestContainer的优势
  • 实践DEMO
    • 载入Testcontainer依赖库
      • 创建用于UT的Container
      • 使用TestContainer编写UT
        • 运行结果
        • 其他问题
        • demo完整代码
        相关产品与服务
        容器服务
        腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档