为了账号安全,请及时绑定邮箱和手机立即绑定

使用Testcontainers模拟真实依赖进行集成测试

请点击这里阅读原文 点击这里阅读原文请访问 packagemain.tech

集成测试是什么?

集成测试主要是为了确保不同的软件组件、子系统或应用程序在组合后能够很好地配合得当。

这在测试金字塔中是一个非常重要的步骤,可以帮助识别组件组合时可能出现的问题,比如兼容性问题、数据一致性问题和通信方面的问题。

在本文中,我们将集成测试定义为我们的后端应用程序与外部系统(如数据库和缓存)之间的通信测试。

集成测试的几种运行方法

此图仅展示了三种类型的测试,但实际上还有很多其他的测试类型,例如组件测试、系统测试、负载测试等等。

单元测试运行起来非常简单(你只需像平时运行代码那样运行测试),而集成测试通常需要搭建一些测试环境。在我工作过的几家公司里,我见到过解决集成测试环境问题的方法。

选项 1. 使用一次性数据库和其他依赖关系,在集成测试开始前必须准备好这些依赖关系,并在测试完成后进行销毁。根据你的应用程序的复杂程度,这个选项可能需要较大的工作量,因为你必须确保基础设施的正常运行,并且数据需预先配置为特定的所需状态。

选项 2. 利用现有的共享数据库及其他依赖项。可以为集成测试创建独立环境,或直接使用现有环境(如预发布环境)。不过这种方法存在不少缺点,因此我并不建议采用。由于这是个共享环境,多个测试可并行运行并同时修改数据,因此你可能会遇到数据状态不一致的情况。

选项3. 使用所需服务的内存版本或嵌入式版本进行集成测试。虽然这是一个不错的方法,但并非所有的依赖项都有内存版本,即使有,这些实现可能也不具备你的生产数据库的功能。

选项 4. 使用Testcontainers在测试代码内部启动和管理测试所需的依赖项。这确保了测试运行之间的完全隔离、可重复性和更好的CI体验。接下来我们将深入探讨这个话题。

我们的测试服务:非常简单的短链接服务

为了演示我们准备的测试,我们编写了一个非常简单的用 Go 语言编写的 URL 缩短器 API,它使用 MongoDB 作为数据存储,并利用 Redis 作为读取穿透缓存。我们将测试该 API 的两个端点,这两个端点是:

  • /create?url= 为给定的 URL 生成哈希并将其存入数据库。
  • /get?key= 通过给定的键返回原始 URL。

我们不会过于深入探讨端点的细节,你可以在该GitHub仓库中找到完整的代码如下。不过,我们来看看我们是如何定义“server”结构体的:

类型 Server struct {  
  DB DB  
  Cache Cache  
}  

func NewServer(db DB, cache Cache) (*Server, error) {  
  if err := db.Init(); err != nil {  
    return nil, err  
  }  
  if err := cache.Init(); err != nil {  
    return nil, err  
  }  
  返回 &Server{DB: db, Cache: cache}, nil  
}

NewServer 函数允许我们初始化一个服务器,该服务器具有数据库和缓存实例,这些实例实现了 DB 和 Cache 接口。

    type DB interface { // 数据库接口
      Init() error // 初始化
      StoreURL(url string, key string) error // 存储URL
      GetURL(key string) (string, error) // 获取URL
    }  

    type Cache interface { // 缓存接口
      Init() error // 初始化
      Set(key string, val string) error // 设置
      Get(key string) (string, bool) // 获取
    }
带有模拟依赖的单元测试

因为我们把所有的依赖都定义为接口,我们可以轻松地为它们生成模拟,并在我们的单元测试中使用它们。

    mockery --all --with-expecter  
    go test -v ./... (这将运行所有测试用例并详细输出)

借助单元测试,我们可以很好地覆盖我们应用程序的低级组件,如接口、哈希键的处理逻辑等。因此,我们只需要模拟数据库和缓存的功能调用。

_unittest.go

    func TestServerWithMocks(t *testing.T) {  
      mockDB := mocks.NewDB(t)  
      mockCache := mocks.NewCache(t)  

      mockDB.EXPECT().Init().Return(nil)  
      mockDB.EXPECT().StoreURL(mock.Anything, mock.Anything).Return(nil)  
      mockDB.EXPECT().GetURL(mock.Anything).Return("url", nil)  

      mockCache.EXPECT().Init().Return(nil)  
      mockCache.EXPECT().Get(mock.Anything).Return("url", true)  
      mockCache.EXPECT().Set(mock.Anything, mock.Anything).Return(nil)  

      s, err := NewServer(mockDB, mockCache)  
      assert.NoError(t, err)  

      srv := httptest.NewServer(s)  
      defer srv.Close()  

      // 实际测试在此执行,详情请参阅仓库中的代码  
      testServer(srv, t)  
    }

mocks.NewDB(t)mocks.NewCache(t) 是由 mockery 自动生成的,我们使用 EXPECT() 来模拟这些函数的行为。需要注意的是,我们创建了一个单独的函数 testServer(srv, t),稍后在其他测试中也会用到这个函数,但会传入不同的服务器结构体。

正如你可能已经知道的那样,这些单元测试并没有检查我们的应用与数据库/缓存之间的通信状况,所以我们可能会轻易地忽略一些非常关键的 bug。为了对我们的应用更加有信心,我们应该同时编写集成测试和单元测试,以确保我们的应用功能全面且正常运行。

带有真实依赖的集成测试

如上所述的选项1和选项2,我们可以提前准备好依赖项,并针对这些实例进行测试。一种选择是使用包含MongoDB和Redis的Docker Compose配置,在测试开始前启动,测试结束后关闭。种子数据可以是此配置的一部分,或者单独准备。

一个文件名 compose.yaml

服务:
  mongodb:
  镜像: mongodb/mongodb-community-server:7.0-ubi8
  重启: 总是
  端口:
    - "27017:27017"

redis:
  镜像: redis:7.4-alpine
  重启: 总是
  端口:
    - "6379:6379"

文件名 '_realdepstest.go'

// 构建指令:使用真实依赖项
// +build realdeps  

package main  

// 此函数用于测试带有真实依赖项的服务器
func TestServerWithRealDependencies(t *testing.T) {  
  // 设置环境变量以指向本地的 MongoDB 和 Redis 实例
  os.Setenv("MONGO_URI", "mongodb://localhost:27017")  
  os.Setenv("REDIS_URI", "redis://localhost:6379")  

  // 创建一个新的服务器实例,使用 MongoDB 和 Redis 依赖项
  s, err := NewServer(&MongoDB{}, &Redis{})  
  assert.NoError(t, err)  

  // 启动一个新的 HTTP 服务器并将其存储在 srv 中
  srv := httptest.NewServer(s)  
  // 延迟关闭服务器,确保测试结束后服务器关闭
  defer srv.Close()  

  // 测试服务器功能
  testServer(srv, t)  
}

现在的测试不使用模拟,而是直接连接到已准备好的数据库和缓存。提示:我们添加了一个“realdeps”构建标签,所以需要明确指定这个标签来运行这些测试。

启动服务:

docker-compose up -d  

运行测试:

go test -tags=realdeps -v ./...  

停止服务:

docker-compose down
使用 Testcontainers 进行集成测试(Integration Tests)

不过,使用Docker Compose来创建可靠的服务依赖关系需要对Docker内部工作原理有深入理解,以及如何在容器中最佳地运行特定技术。比如,创建动态集成测试环境时,可能会遇到端口冲突,以及容器未能完全启动和可用的问题。

现在我们可以使用Testcontainers来做同样的事情,这意味着我们能够更好地控制这些临时依赖,并确保每个测试运行时它们都是隔离的。只要容器运行时兼容Docker API,你几乎可以在Testcontainers中运行任何东西。

integration_test.go

     //go:build integration  
    // +build integration  

    package main  

    import (  
      "context"  
      "net/http/httptest"  
      "os"  
      "testing"  
      "github.com/stretchr/testify/assert"  
      "github.com/testcontainers/testcontainers-go/modules/mongodb"  
      "github.com/testcontainers/testcontainers-go/modules/redis"  
    )  

    // 测试带有测试容器的服务器的功能
    func TestServerWithTestcontainers(t *testing.T) {  
      // 初始化上下文
      ctx := context.Background()  

      // 启动mongodb容器
      mongodbContainer, err := mongodb.Run(ctx, "docker.io/mongodb/mongodb-community-server:7.0-ubi8")  
      assert.NoError(t, err)  
      // 关闭mongodb容器
      defer mongodbContainer.Terminate(ctx)  

      // 启动redis容器
      redisContainer, err := redis.Run(ctx, "docker.io/redis:7.4-alpine")  
      assert.NoError(t, err)  
      // 关闭redis容器
      defer redisContainer.Terminate(ctx)  

      // 获取mongodb端点
      mongodbEndpoint, _ := mongodbContainer.Endpoint(ctx, "")  
      // 获取redis端点
      redisEndpoint, _ := redisContainer.Endpoint(ctx, "")  

      // 设置mongodb的环境变量
      os.Setenv("MONGO_URI", "mongodb://"+mongodbEndpoint)  
      // 设置redis的环境变量
      os.Setenv("REDIS_URI", "redis://"+redisEndpoint)  

      // 创建一个新的服务器实例
      s, err := NewServer(&MongoDB{}, &Redis{})  
      assert.NoError(t, err)  

      // 创建一个新的测试服务器
      srv := httptest.NewServer(s)  
      // 关闭测试服务器
      defer srv.Close()  

      // 调用测试服务器的测试函数
      testServer(srv, t)  
    }

这与之前的测试很相似,我们在测试开始时初始化了两个容器。

第一次启动可能需要一些时间来下载图片。但后续的启动几乎是瞬间的。

测试容器的工作原理

要使用 Testcontainers 进行测试,你需要一个与 Docker API 兼容的容器运行环境,或者在本地安装 Docker。如果你尝试停止 Docker 引擎,测试将无法进行。不过,对于大多数开发人员来说,这通常不是问题,因为在 CI/CD 流水线或本地拥有一个 Docker 运行环境已经是很普遍的做法了。例如,你可以在 Github Actions 中轻松配置这样一个环境。

说到支持的语言,Testcontainers 支持多种流行的编程语言和平台,例如 Java、.NET、Go、Node.js、Python、Rust 和 Haskell。

也有一个不断增长的预配置实施列表(称为模块),你可以在这里找到这些模块:here。然而,如前所述,你可以运行任何 Docker 镜像。在 Go 中,你可以使用如下代码来部署 Redis,而不是使用预配置的模块:

    // 使用现有模块  
    redis容器, err := redis.Run(ctx, "redis:latest")  

    // 或者使用 GenericContainer  
    req := testcontainers.ContainerRequest{  
      Image:        "redis:latest",  
      ExposedPorts: []string{"6379/tcp"},  
      WaitingFor:   wait.ForLog("Ready to accept connections"),  
    }  

    redis容器实例, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{  
      ContainerRequest: req,  
      Started:          true,  
    })

结论

虽然集成测试的开发和维护需要投入大量精力,但它们是必不可少的,确保组件、子系统或应用程序能够良好地协同运作,是SDLC中的关键组成部分。

使用 Testcontainers,我们可以简化测试中一次性依赖项的创建和销毁,使测试运行完全隔离且更加可预测。

资源

请点击这里阅读原文 使用 Testcontainers 进行集成测试

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消