当前位置 博文首页 > Alice菌的博客:万字长文带你快速了解并上手Testcontainers

    Alice菌的博客:万字长文带你快速了解并上手Testcontainers

    作者:[db:作者] 时间:2021-09-11 19:59

    前言

    ????????前段时间,我负责在所属的一个团队内部去推动一项叫做“Testcontainers”的技术。于是在调研并打磨了数天之后,就诞生下文。希望看完本篇文章的你,能够有所收获,感谢阅读!
    ????????
    在这里插入图片描述
    本文首发:https://www.ebaina.com/articles/140000005317
    作者:Alice菌


    1. 技术的演进

    1.1 传统的测试

    ????????我们的项目上线之前,一定会经过大量的测试。早期,如果一个项目所依赖的外部配置比较繁多,那么每次测试,我们都需要将项目所依赖的环境服务启动。如果测试人员的电脑没有对应的开发环境,则还需要花时间在环境搭建上。就算搭建好了, 各种版本的迭代之后,不同版本环境的兼容 , 也有可能导致测试失败,这些都是测试人员应该考虑的问题,这个时候,测试工作的效率往往就显得很低

    在这里插入图片描述
    ????????

    1.2 Embedded

    ????????后来,出现了 “ In-memory Embedded Database ” 这样的方式,能够让我们在程序本地进行基于内存的嵌入式测试 ,而无需手动去启动环境,方便我们在本地编写、运行、调试 。但由于使用不同的服务,需要依赖于不同的第三方的类库,显得十分繁琐,并且很多 “In-memory Embedded Database” 只提供一个特定版本的实现 ,如果其提供的数据库版本与我们实际应用中的版本不一致, 那么就有可能导致很多新的数据库功能在测试里根本覆盖不了。 有些 In-memory Embedded Database 甚至没有实现100%的接口兼容,或者不一样的实现方式,这意味着就算你的测试过了,线上的代码还是可能会出错。这就是典型的生产环境和测试环境不一致性问题。 另外该项目维护不利, 大量缺陷未修复 ,并且缺少更新,导致用户的使用体验也越来越差

    在这里插入图片描述

    1.3 Docker

    ????????随着时代的不断发展,以 Docker 为代表的虚拟化容器技术出现了。 Docker 是一个开源的应用容器引擎 , 它可以让开发者打包他们的应用以及依赖包到一个轻量级、可移植的容器中,然后发布到任何流行的 Linux 机器上,也可以实现虚拟化。另外,容器是完全使用沙箱机制,相互之间不会有任何接口(类似 iPhone 的 app),更重要的是容器性能开销极低
    在这里插入图片描述
    ????????此后,我们的测试工作所需要的环境就可以通过在Linux服务器上启动Docker中的容器来实现。但是docker虽然解决了环境的配置问题,但是我们每次测试,所需要的环境都需要到Linux服务器上通过命令手动启动容器,一旦外部依赖过多,启动容器这个过程所花费的时间也是笔不小的开销。那我们可能就会想,有没有什么方法能够实现通过编程语言远程启动docker中的容器,来代替我们人为的操作呢?

    在这里插入图片描述

    1.4 Testcontainers

    ????????为了解决这个问题,在社区成员的努力下,一个叫做 Testcontainers 的开源项目就诞生了。
    在这里插入图片描述

    TestContainers是一个开源项目,它提供可以在Docker容器中运行的任何东西的轻量级,一次性的实例。它具有Java,Python,Rust,Go,Scala和许多其他语言的绑定。其主要针对测试领域、背靠Docker实现环境百宝箱功能。

    ????????简单理解就是,testcontainers 能够让你实现通过编程语言去启动Docker容器,并在程序测试结束后,自动关闭容器。这基本上能解决我们大部分的需求。

    ????????使用 TestContainers 这种解决方案 还有以下几个优点:

    • 每个Test Group都能像写单元测试那样细粒度地写集成测试,保证每个集成单元的高测试覆盖率。
    • Test Group间是做到依赖隔离的,也就是说它们不共享任何一个Docker容器;假如两个Test Group都要用到Mongo 4.0,会创建两个容器供它们单独使用 。
    • 保证了生产环境和测试环境的一致性,代码部署到线上时不会遇到因为依赖服务接口不兼容而导致的bug 。
    • Test Group可以并行化运行,减少整体测试运行时间。相比较有些 in-memory 的依赖服务实现没有实现很好的资源隔离,比如端口,一旦并行化运行就会出现端口冲突 。
    • 得益于Docker,所有测试都可以在本地环境和 CI/CD环境中运行,测试代码调试和编写就如同写单元测试。

    ????????另外,TestContainers使以下类型的测试更加容易:

    • 数据访问层集成测试 :

    ????????使用MySQL,PostgreSQL或Oracle数据库的容器化实例测试您的数据访问层代码是否具有完全兼容性,但无需在开发人员的计算机上进行复杂的设置,并且无需担心测试始终以已知的数据库状态。 也可以使用任何其他可以容器化的数据库类型。

    • 应用程序集成测试 :

    ????????用于在具有数据库,消息队列或Web服务器等依赖项的短期测试模式下运行应用程序。

    • UI /验收测试 :

    ????????使用与Selenium兼容的容器化Web浏览器进行自动UI测试。 每个测试都可以获取浏览器的新实例,而无需担心浏览器状态,插件版本或浏览器自动升级。 您将获得每个测试会话或测试失败的每个会话的视频记录。

    • 更多

    ????????我们可以在官网查看其他人贡献的模块,也可以自己基于 GenericContainer ,创建自己的自定义容器类。

    注意:

    • test-containers 基于 Docker,所以使用 test-container 前需要安装 Docker环境
    • test-containers 提供的环境不能应用于生产环境、只能用于测试环境等场景

    2.Testcontainers所提供的模块

    ????????Testcontainers 提供了多种现成的与测试关联的应用程序容器。

    1606981638286
    ????????其中Databases就支持这么多:

    1606981669456

    3. 不同语言版本的Testcontainers

    ????????Testcontainers 在GitHub上支持包含 java,go,python 等多种语言版本,基于我们项目的实际情况,下面的示例以testcontainers-scala为主。

    1606982178838

    4. Testcontainers连接策略和要求

    ?????因为 java 和 scala 运行都要基于 JVM,所以 testcontainers-scala 运行的环境首先需要满足:

    • JDK >= 1.8

    ?????又 test-container 基于 Docker,所以使用test-container前需要安装 Docker环境。

    ?????其中,关于Docker的版本,需要满足以下条件:

    http://style.iis7.com/uploads/2021/09/19304561099.png
    ?????Testcontainers在运行时将会尝试按如下顺序使用以下策略连接到 Docker 守护程序:

    • 环境变量:
      – DOCKER_HOST
      – DOCKER_TLS_VERIFY
      – DOCKER_CERT_PATH

    ?????每个变量的作用:

    Use DOCKER_HOST to set the url to the docker server.
    Use DOCKER_CERT_PATH to load the tls certificates from.
    Use DOCKER_TLS_VERIFY to enable or disable TLS verification.

    • 默认值
      – DOCKER_HOST=https://localhost:2376
      – DOCKER_TLS_VERIFY=1
      – DOCKER_CERT_PATH=~/.docker

    • 我们可以在程序中显式设置系统变量代替默认值,例如:

    System.setProperty("DOCKER_HOST","tcp://10.16.2.103:2375")
    

    ????????这样我们在运行测试时,testcontainers 就会去连接指定节点的Docker环境

    5. Testcontainers-scala入门需知

    ????????ScalaTest 有两种感知特质:

    • ForEachTestContainer : 在每个测试用例之前启动一个新容器,然后停止并删除它。
    • ForAllTestContainer : 对于规范内的所有测试用例,仅启动和停止一次容器 。

    ????????我们要开始使用 ScalaTest,只需要扩展这些特质之一,并 重写 container的val值。

    import com.dimafeng.testcontainers.{ForAllTestContainer, MySQLContainer}
    
    class MysqlSpec extends FlatSpec with ForAllTestContainer {
       
      override val container = MySQLContainer()
      
      it should "do something" in {
        Class.forName(container.driverClassName)
        val connection = DriverManager.getConnection(container.jdbcUrl, container.username, container.password)
        ...
      }
    }
    

    ????????对于存在多个测试用例的情况,可以参考下方 MySQL 的测试实例:

    import org.testcontainers.containers.MySQLContainer
    
    class MysqlSpec extends FlatSpec with ForAllTestContainer {
    
        override val container = MySQLContainer()
    
        it should "do something" in {
          ...
        }
    
        it should "do something 2" in {
          ...
        }
    }
    

    ????????此规范启动一个容器,两个测试共享容器的状态。

    ????????大多数可用的容器类都允许您提供自定义镜像名称或版本,而不是库中的默认镜像名称或版本。

    ????????为了提供自定义镜像名称,您需要传递 DockerImageName 对象 。

    override val container = MongoDBContainer(DockerImageName.parse("mongo:4.0.10"))
    

    ????????从 testcontainers-java 1.15.0 容器类开始,在初始化期间执行镜像兼容性检查(有关更多详细信息,请参阅此拉取请求)。如果要使用与所选容器类实现兼容的自定义镜像,则必须显式标记为与默认镜像兼容。

    override val container = MongoDBContainer(DockerImageName.parse("myregistry/mongo:4.0.10").asCompatibleSubstituteFor("mongo"))
    

    6. 容器类型

    注意:在 testcontainers 的测试中,有时候我们往往不需要通过输出结果来判断是否测试成功,我们可以通过assert(condition: Boolean)函数来进行断言,测试其逻辑。如果condition返回true,则接收返回值,继续执行,否则会抛出TestFailedException异常

    6.1 单个容器

    6.1.1 Generic Container

    ????????这是最灵活但不太方便的容器类型 , 此容器允许使用自定义配置启动任何 Docker 镜像。

    class GenericContainerSpec extends FlatSpec with ForAllTestContainer {
      override val container = GenericContainer("nginx:latest",
        exposedPorts = Seq(80),
        waitStrategy = Wait.forHttp("/")
      )
    
      "GenericContainer" should "start nginx and expose 80 port" in {
        assert(Source.fromInputStream(
          new URL(s"http://${container.containerIpAddress}:${container.mappedPort(80)}/").openConnection().getInputStream
        ).mkString.contains("If you see this page, the nginx web server is successfully installed"))
      }
    }
    

    6.1.2 Docker Compose

    ????????与通用容器支持类似,我们也可以运行定制的服务集 在 指定 docker-compose.yml 文件中。Compose file 是一个 YAML 文件,用于定义 servicesnetworksvolumes ,我们可以在 texttainers 中通过DockerComposeContainer类中传入该文件的路径,实现启动容器的自定义配置。

    class ComposeSpec extends FlatSpec with ForAllTestContainer {
      override val container = DockerComposeContainer(new File("src/test/resources/docker-compose.yml"), exposedServices = Seq(ExposedService("redis_1", 6379)))
       
      "DockerComposeContainer" should "retrieve non-0 port for any of services" in {
        assert(container.getServicePort("redis_1", 6379) > 0)
      }
    }
    

    6.1.3 Selenium

    class SeleniumSpec extends FlatSpec with SeleniumTestContainerSuite with WebBrowser {
      override def desiredCapabilities = DesiredCapabilities.chrome()
    
      "Browser" should "show google" in {
          go to "http://google.com"
      }
    }
    

    ????????在这种情况下,您将在容器中获取一个浏览器实例(Firefox/chrome),测试将通过远程驱动程序连接到该实例。

    6.1.4 MySQL

    class MysqlSpec extends FlatSpec with ForAllTestContainer {
    
      override val container = MySQLContainer()
    
      "Mysql container" should "be started" in {
        Class.forName(container.driverClassName)
        val connection = DriverManager.getConnection(container.jdbcUrl, container.username, container.password)
          ...
      }
    }
    

    ????????容器也可以使用构造函数参数定制,这个代码段将使用特定的模式名和特定的用户名/密码从特定的docker镜像初始化docker容器。

    class MysqlSpec extends FlatSpec with ForAllTestContainer {
    
      override val container = MySQLContainer(mysqlImageVersion = DockerImageName.parse("mysql:5.7.18"),
                                              databaseName = "testcontainer-scala",
                                              username = "scala",
                                              password = "scala")
    
      "Mysql container" should "be started" in {
        Class.forName(container.driverClassName)
        val connection = DriverManager.getConnection(container.jdbcUrl, container.username, container.password)
          ...
      }
    }
    

    6.1.5 PostgresQL

    class PostgresqlSpec extends FlatSpec with ForAllTestContainer  {
    
      override val container = PostgreSQLContainer()
    
      "PostgreSQL container" should "be started" in {
        Class.forName(container.driverClassName)
        val connection = DriverManager.getConnection(container.jdbcUrl, container.username, container.password)
          ...
      }
    }
    

    补充说明,还有很多与MySQL,PostgresQL使用方式类似的镜像类型,这里就不一一列举了,更多 model 的使用示例请参考官网:https://www.testcontainers.org/

    6.2 多个容器

    ????????如果需要在测试中测试多个容器 , 只需定义容器并传递给构造函数: MultipleContainers()

    val mySqlContainer = MySQLContainer()
    val genericContainer = GenericContainer(...)
    
    override val container = MultipleContainers(mySqlContainer, genericContainer)
    

    6.3 依赖容器

    ????????如果一个容器的配置依赖于另一个容器的运行时状态,则应将容器定义为 :lazy

    lazy val container1 = Container1()
    lazy val container2 = Container2(container1.port)
    
    override val container = MultipleContainers(container1, container2)
    

    6.4 固定主机端口容器

    ????????此容器将允许您将容器端口映射到 Docker 主机上的静态定义端口。

    ...
    val container = FixedHostPortGenericContainer("nginx:latest",
        waitStrategy = Wait.forHttp("/"),
        exposedHostPort = 8090,
        exposedContainerPort = 80
      )
    

    6.5 内部容器的自定义配置

    ????????所有容器类型都具有通用参数的构造函数方法。如果您缺少一些自定义选项,请提供一种优雅的方式来调整嵌套容器。不建议通过下面这种方式,直接访问内部容器:

    override val container = MySQLContainer().configure { c =>
        c.withNetwork(...)
        c.withStartupAttempts(...)
      }
    

    6.6 启动/停止 挂钩

    ????????如果要在容器启动后或容器停止之前执行代码,可以重写 afterStart()beforeStop() 方法 。

    class MysqlSpec