searchusermenu
  • 发布文章
  • 消息中心
点赞
收藏
评论
分享
原创

TestContainers

2023-03-30 08:53:41
76
0

背景

当前DTS项目使用pytest测试框架运行自动化测试用例,但由于测试环境的资源限制,只为自动化测试项目部署了一个源数据库和一个目标数据库,这导致有一些测试用例无法开发。比如MYSQL->MYSQL的版本检查、源库binlog存在性检查、源库binlog是否开启检查、源库binlog影像类型检查、源库用户权限检查、源库连通性检查、MySQL参数lower_case_table_names一致性检查等等。

上面列举的测试用例只是开发了正向用例,因为只有一套数据库可用,无法做反向用例,如果通过更改数据库配置来满足反向用例的条件:

1、手工执行重启命令并指定启动参数,繁琐而且这也不是自动化测试了;

2、需要的时间长,拖慢自动化测试的进度;

3、更改重要的参数再改回来可能会影响其他一些测例的执行。

4、数据库版本检查的测例只能通过部署多套不同版本的数据库来完成。

综合以上的问题,目前DTS的自动化测试用例还是缺少很多,只能由测试人员手工执行,这消耗了测试人员的很多时间。

 

解决方案

容器以轻量、方便而著称,但是测试环境资源有限,测试用例多,启动多个容器实例所需的资源无法满足,手工管理也很麻烦。

如果通过编程语言远程启动docker容器来代替人为操作,需要时启动容器,用完及时释放资源,就可以大大节省人力和硬件资源。直接使用python的docker依赖来进行容器操作是一种可能的实施方案,如下启动一个MySQL容器。

    client = docker.from_env()
   # 启动MySQL容器
   container: Container = client.containers.run(
       'mysql:5.7',
       command='--lower_case_table_names=1',
       detach=True,
       name='mysql-container',
       ports={'3306/tcp': 13306},
       environment={
           'MYSQL_ROOT_PASSWORD': 'password',
           'MYSQL_USER': 'user',
           'MYSQL_PASSWORD': 'password',
           'MYSQL_DATABASE': 'mydb'
      }
  )
  # 删除容器
  container.remove()

不过开源框架testcontainers已经封装提供了更为方便的实现。

 

TestContainers

官网地址:https://www.testcontainers.org

TestContainers是一个开源项目,它提供诸多可以在Docker容器中运行的组件,非常轻量、方便,需要做的只是安装docker服务,无需其他配置。它支持Java,Python,Rust,Go,.net等多种语言,可以提供测试所需的多种环境。

testcontainers支持众多常用的主流组件,以Java为例,支持如下组件

 

image-20230327111058154

其中支持的Databases:

image-20230327111246510

不是所用语言的库都支持如此多的组件,go、node.js的testcontainers库只支持寥寥几种组件。

 

 

使用介绍

使用前提:test-containers 基于 Docker,所以使用 test-container 前需要安装 Docker环境。

不同版本testcontainers-python的API差异很大,这里使用的相关依赖的版本如下

SQLAlchemy~=1.4.46
testcontainers~=3.7.0
urllib3~=1.25.11

 

官方示例

testcontainers-python文档地址:https://testcontainers-python.readthedocs.io/en/latest/README.html

testcontainers-python给出的官方示例代码都很短小

def test_docker_run_mysql():
   config = MySqlContainer('mysql:5.7.17')
   with config as mysql:
       engine = sqlalchemy.create_engine(mysql.get_connection_url())
       with engine.begin() as connection:
           result = connection.execute(sqlalchemy.text("select version()"))
           for row in result:
               assert row[0].startswith('5.7.17')

以上python代码启动了5.7.17版本的容器实例,并且使用python的ORM框架sqlalchemy去连接数据库,获取数据库连接后执行SQL语句并返回结果。

 

继承关系

事实上,所有的特性Container都直接或间接派生自DockerContainer。以下是继承关系:

DockerContainer
|-KafkaContainer
|-ElasticSearchContainer
|-NginxContainer
|-DbContainer
|-MySqlContainer
|-SqlServerContainer
|-OracleDbContainer

DockerContainer封装了容器的通用操作,DbContainer封装了数据库的通用操作。而DockerContainer底层使用python的docker依赖来进行容器操作的。

 

DockerContainer

这是最灵活也是不太方便的容器类型, 此容器允许使用启动任何Docker镜像。

 

DockerContainer的构造函数

    def __init__(self, image, docker_client_kw: dict = None, **kwargs):
       self.env = {}
       self.ports = {}
       self.volumes = {}
       self.image = image
       self._docker = DockerClient(**(docker_client_kw or {}))
       self._container = None
       self._command = None
       self._name = None
       self._kwargs = kwargs

with_env方法用于设置容器的环境变量

    def with_env(self, key: str, value: str) -> 'DockerContainer':
       self.env[key] = value
       return self

with_command方法指定容器启动时的参数

    def with_command(self, command: str) -> 'DockerContainer':
       self._command = command
       return self

with_bind_ports用于设置一对绑定的端口

    def with_bind_ports(self, container: int,
                       host: int = None) -> 'DockerContainer':
       self.ports[container] = host
       return self

with_exposed_ports方法用于暴露一个内部端口,绑定的宿主机端口是随机的

    def with_exposed_ports(self, *ports) -> 'DockerContainer':
       for port in list(ports):
           self.ports[port] = None
       return self

get_exposed_port方法用来获取指定的内部端口所绑定的宿主机端口

    @wait_container_is_ready()
   def get_exposed_port(self, port) -> str:
       mapped_port = self.get_docker_client().port(self._container.id, port)
       if inside_container():
           gateway_ip = self.get_docker_client().gateway_ip(self._container.id)
           host = self.get_docker_client().host()

           if gateway_ip == host:
               return port
       return mapped_port

with_volume_mapping方法用于挂载数据卷

    def with_volume_mapping(self, host: str, container: str,
                           mode: str = 'ro') -> 'DockerContainer':
       # '/home/user1/': {'bind': '/mnt/vol2', 'mode': 'rw'}
       mapping = {'bind': container, 'mode': mode}
       self.volumes[host] = mapping
       return self

exec方法用于在容器内部执行指令

    def exec(self, command):
       if not self._container:
           raise ContainerStartException("Container should be started before")
       return self.get_wrapped_container().exec_run(command)

get_wrapped_container方法返回container对象,这才真正对应着一个里面有所有的容器信息

    def get_wrapped_container(self) -> Container:
       return self._container

 

可以使用DockerContainer启动任何类型的容器,比如启动一个MySQL容器

    container = DockerContainer("mysql:5.7")
   container.with_env("MYSQL_ROOT_PASSWORD", "afcer554KCJ5")
   container.with_command("--lower_case_table_names=1")
   container.with_exposed_ports(3306)
   container.start()

事实上,MysqlContainer与DockerContainer差异很小,MysqlContainer只是多做了一点事情(设置环境变量、检查容器是否启动就绪),自动化测试直接使用DockerContainer也能满足需求,当然使用MysqlContainer会更加方便一些。

 

MysqlContainer

MySqlContainer继承自DbContainer,DbContainer继承自DockerContainer。

MysqlContainer构造函数

    def __init__(self,
                image="mysql:latest",
                MYSQL_USER=None,
                MYSQL_ROOT_PASSWORD=None,
                MYSQL_PASSWORD=None,
                MYSQL_DATABASE=None,
                **kwargs):
       super(MySqlContainer, self).__init__(image, **kwargs)
       self.port_to_expose = 3306
       self.with_exposed_ports(self.port_to_expose)
       self.MYSQL_USER = MYSQL_USER or environ.get('MYSQL_USER', 'test')
       self.MYSQL_ROOT_PASSWORD = MYSQL_ROOT_PASSWORD or environ.get('MYSQL_ROOT_PASSWORD', 'test')
       self.MYSQL_PASSWORD = MYSQL_PASSWORD or environ.get('MYSQL_PASSWORD', 'test')
       self.MYSQL_DATABASE = MYSQL_DATABASE or environ.get('MYSQL_DATABASE', 'test')

       if self.MYSQL_USER == 'root':
           self.MYSQL_ROOT_PASSWORD = self.MYSQL_PASSWORD

镜像默认是latest,最新版。

 

启动容器

创建一个MysqlContainer对象后,只需简单调用start方法即可启动容器

    # 启动容器
  mysqlContainer.start()

start方法的定义

class DbContainer(DockerContainer):

   @wait_container_is_ready(*ADDITIONAL_TRANSIENT_ERRORS)
   def _connect(self):
       import sqlalchemy
       engine = sqlalchemy.create_engine(self.get_connection_url())
       engine.connect()

   def start(self):
  # 配置环境变量
       self._configure()
       # 这一步就是把容器启动起来
       super().start()
       # 验证容器服务是否可用
       self._connect()
       return self

在start方法里的最后调用_connect()方法是为了验证容器服务是否可用。_connect方法使用SQLAlchemy来连接数据库,SQLAlchemy通过容器的get_connection_url()方法获取数据库地址进行连接。_connect方法添加了@wait_container_is_ready注解,会一直等待直到数据库连接成功,超时时间120s。

SQLAlchemy是一个开源的Python ORM(对象关系映射)框架。

 

在windows上进行测试时,MysqlContainer的get_connection_url方法返回如下的URL对象

mysql+pymysql://test_asd:afrvte54657hnngf@localnpipe:65303/test

PostgresContainer的get_connection_url方法返回如下的URL对象

postgresql+psycopg2://postgres:***@localnpipe:64211/postgres

这里显然是通过named pipe来连接数据库,然而连接失败次数达到指定值,容器启动失败。

Windows命名管道只能用于Windows主机上的进程间通信,WSL上运行的Docker容器被视为独立的进程空间,因此无法通过命名管道进行通信。与WSL2中运行的Docker容器进行通信,需要使用网络通信协议,如TCP协议。

即使我在Windows本地启动MySQL服务,使用SQLAlchemy连接以上的URL对象也是失败。

 

阅读了testcontainers-python源码,通过继承MySqlContainer重写其get_connection_url()来解决。

from testcontainers.mysql import MySqlContainer


class CustomMysqlContainer(MySqlContainer):
   def get_connection_url(self):
       return 'mysql+pymysql://{0}:{1}@127.0.0.1:{2}/{3}'.format('root',
                                                                 self.MYSQL_ROOT_PASSWORD,
                                                                 self.get_exposed_port(3306),
                                                                 self.MYSQL_DATABASE)

将host写死为127.0.0.1,这样即可解决。

 

而testcontainers-java则是使用localhost连接的:

19:09:15.663 [main] INFO 🐳 [mysql:5.7.34] - Container mysql:5.7.34 is starting: 18f8abf99e470a467d86d5b37c09be2b7cfd94e5b270d7e633b001fbb3db2ae2
19:09:16.095 [main] INFO 🐳 [mysql:5.7.34] - Waiting for database connection to become available at jdbc:mysql://localhost:64513/test using query 'SELECT 1'
19:09:24.280 [main] INFO 🐳 [mysql:5.7.34] - Container is started (JDBC URL: jdbc:mysql://localhost:64513/test)
19:09:24.281 [main] INFO 🐳 [mysql:5.7.34] - Container mysql:5.7.34 started in PT8.7804708S

 

 

 

创建mysql账号

MysqlContainer构造函数中,可以指定参数MYSQL_USER、MYSQL_PASSWORD、MYSQL_ROOT_PASSWORD、MYSQL_DATABASE来创建账户相关信息。

    mysqlContainer = MysqlContainer(image='mysql:5.7',
                             MYSQL_USER='test_asd',
                             MYSQL_ROOT_PASSWORD='afrvte54657hnngf',
                             MYSQL_PASSWORD='afrvte54657hnngf',
                             MYSQL_DATABASE='test')

在容器启动过程中看到如下日志

[Warning] root@localhost is created with an empty password ! Please consider switching off the --initialize-insecure option.

MySQL初始化时root@localhost用户是空密码。如果设置了MYSQL_ROOT_PASSWORD,容器会在MySQL服务运行后立即设置root@localhost用户密码,并创建一个新用户root@%,两者密码一致。

 

指定端口

MySQL容器内的MySQL服务默认运行在3306号端口,绑定的宿主机端口是随机的。

可以在容器启动前将其绑定到指定的宿主机端口,例如

    mysqlContainer = MysqlContainer(image='mysql:5.7',
                             MYSQL_USER='test_asd',
                             MYSQL_ROOT_PASSWORD='afrvte54657hnngf',
                             MYSQL_PASSWORD='afrvte54657hnngf',
                             MYSQL_DATABASE='test')
   mysqlContainer.with_bind_ports(3306, 26788)

而with_exposed_ports方法也用于暴露指定的内部端口,但绑定的宿主机端口是随机的。

使用with_bind_ports方法绑定指定的宿主机端口需要确保宿主机该端口空闲,而with_exposed_ports会自动寻找宿主机上空闲的一个可用端口。

 

指定启动参数

如果想要指定MySQL的运行参数,可以在容器启动前使用with_command方法来指定MySQL参数,方法的参数格式如下

    mysqlContainer = MysqlContainer(image='mysql:5.7',
                             MYSQL_USER='test_asd',
                             MYSQL_ROOT_PASSWORD='afrvte54657hnngf',
                             MYSQL_PASSWORD='afrvte54657hnngf',
                             MYSQL_DATABASE='test')
   mysqlContainer.with_command('--lower-case-table-names=1')
   # 将把上面设置的command覆盖
   mysqlContainer.with_command('--character_set_server=utf8mb4')
   # 想要同时设置多个MySQL服务启动参数,按如下格式传递参数
   mysqlContainer.with_command('--lower-case-table-names=1 --character_set_server=utf8mb4')

进入容器查看

image-20230301183941451

 

 

容器操作

DockerContainer提供了start和stop方法,每次调用start方法就会启动一个新容器实例并返回,stop方法不是停止容器而是删除容器。

    def start(self):
       logger.info("Pulling image %s", self.image)
       docker_client = self.get_docker_client()
       self._container = docker_client.run(self.image,
                                           command=self._command,
                                           detach=True,
                                           environment=self.env,
                                           ports=self.ports,
                                           name=self._name,
                                           volumes=self.volumes,
                                           **self._kwargs
                                          )
       logger.info("Container started: %s", self._container.short_id)
       return self

   def stop(self, force=True, delete_volume=True):
       self.get_wrapped_container().remove(force=force, v=delete_volume)

为了避免容器一直运行,容器使用完后一定要调用DockerContainer的stop方法删除容器实例。

DockerContainer封装了具体的容器实例,提供了一些工具方法如with_env、with_command,它的实例变量_container才对应着一个具体的容器实例。

_container是Container类型的对象,是python的docker依赖提供的。

class Container(Model):
   """ Local representation of a container object. Detailed configuration may
      be accessed through the :py:attr:`attrs` attribute. Note that local
      attributes are cached; users may call :py:meth:`reload` to
      query the Docker daemon for the current properties, causing
      :py:attr:`attrs` to be refreshed.
  """
   
   //...
   
   def pause(self):
       """
      Pauses all processes within this container..
      """
       return self.client.api.pause(self.id)

   
   def remove(self, **kwargs):
       """
      Remove this container. Similar to the ``docker rm`` command.
      """
       return self.client.api.remove_container(self.id, **kwargs)
   
   
   def start(self, **kwargs):
       """
      Start this container. Similar to the ``docker start`` command, but
      """
       return self.client.api.start(self.id, **kwargs)
   
   
   def stop(self, **kwargs):
       """
      Stops a container. Similar to the ``docker stop`` command.
      """
       return self.client.api.stop(self.id, **kwargs)
   
   
   def unpause(self):
       """
      Unpause all processes within the container.
      """
       return self.client.api.unpause(self.id)

想要对容器实例进行其他操作,可以通过DockerContainer的get_wrapped_container方法获取_container实例变量。

    container = mysqlContainer.get_wrapped_container()
   #停止活动
   container.pause()
   #继续活动
   container.unpause()

我们可以调用pause方法来模拟数据库不可用状态。

 

性能表现和资源占用

测试环境:Windows WSL2+Windows docker desktop

启动容器的start方法是同步的,在等待容器完全启动后才会返回,这里的容器启动时间7.7s

image-20230301180134161

内存占用

image-20230301183539867

使用testcontainers-python创建容器时,推荐使用官方的Docker镜像,是经过优化和配置的,因此占用的内存资源是相对较少的。

第一次启动容器需要拉取镜像耗时会比较久。

 

 

控制容器启动超时时间

在start方法里,testcontainers通过连接数据库来判断服务是否可用。testcontainers默认设置的最大连接重试次数为120次,间隔1s。

os.environ.setdefault("TC_MAX_TRIES", '120')

调试阶段可以适当调小此值。

虽然单个容器启动速度很快,测试发现,如果连续启动4个MySQL容器,容器的启动时间超过甚至会超过120s导致测试失败(偶尔)。

 

 

PostgresContainer

指定参数

postgresContainer.with_command("-c wal_level=logical -c max_connections=134 -c shared_buffers=2GB")

资源占用

image-20230307093509025

 

 

docker-compose

testcontainers也支持使用docker-compose。

 

创建docker-compose.yml文件,启动一个MySQL和一个postgres。

version: '3'
services:
db:
  image: mysql:5.7
  environment:
    MYSQL_ROOT_PASSWORD: root
    MYSQL_DATABASE: test_db
    MYSQL_USER: test_user
    MYSQL_PASSWORD: test_password
  ports:
    - "13306:3306"
postgres:
  image: postgres:13
  environment:
    POSTGRES_DB: test_db
    POSTGRES_USER: test_user
    POSTGRES_PASSWORD: test_password
  ports:
    - "15432:5432"

创建DockerCompose,指定docker-compose文件在当前目录下,启动DockerCompose

from testcontainers.compose import DockerCompose


def test_compose():
   compose = DockerCompose('.')
   compose.start()

   mysql_host = compose.get_service_host('db', 3306)
   print(mysql_host)
   mysql_port = compose.get_service_port('db', 3306)
   print(mysql_port)

   '''
      Returns tuple[str, str, int] stdout, stderr, return code
  '''
   return_tuple = compose.exec_in_container('db', ['ls'])
   print(return_tuple)

   time.sleep(1)

   compose.stop()

 

 

 

testcontainers-java

@Testcontainers: 用于启用 Testcontainers 支持。它可以用在类或方法级别,让 JUnit 在测试执行前启动 Docker 容器。例如:

@Testcontainers
public class MyTestClass {
// ...
}

@Container: 用于将 Docker 容器作为测试类的静态字段启动。这个注解可以与 DockerComposeContainerGenericContainer 和其他扩展类一起使用。例如:

@Container
public static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer();

 

 

@Testcontainers
public class MyTest {
   
@Container
   private static final MySQLContainer mySQLContainer = (MySQLContainer) new MySQLContainer("mysql:5.7.34")
          .withDatabaseName("test")
          .withUsername("dds")
          .withPassword("asfrer54765dsc")
          .withInitScript("mysqlScript.sql")
      .withEnv("name","value")
          .withCommand("--lower-case-table-names=1 --character_set_server=utf8mb4");
   
   @Test
   public void testMysql() throws SQLException {
       System.out.println(mySQLContainer.getMappedPort(3306));
       try (Connection conn = DriverManager.getConnection(mySQLContainer.getJdbcUrl(), mySQLContainer.getUsername(), mySQLContainer.getPassword())) {
           String sql = "SELECT 2 + 2";
           try (PreparedStatement stmt = conn.prepareStatement(sql)) {
               try (ResultSet rs = stmt.executeQuery()) {
                   rs.next();
                   int result = rs.getInt(1);
                   Assertions.assertEquals(4, result);
              }
          }
      }
  }

withPassword方法设置的密码也是root用户的密码;

withInitScript可指定MySQL的初始化脚本;

withCommand可指定MySQL参数;

withEnv设置容器环境变量;

getJdbcUrl返回JDBC连接urljdbc:mysql://localhost:51319/test

getMappedPort方法返回容器内部端口所绑定的宿主机端口。

 

 

 

0条评论
0 / 1000
don
5文章数
0粉丝数
don
5 文章 | 0 粉丝
原创

TestContainers

2023-03-30 08:53:41
76
0

背景

当前DTS项目使用pytest测试框架运行自动化测试用例,但由于测试环境的资源限制,只为自动化测试项目部署了一个源数据库和一个目标数据库,这导致有一些测试用例无法开发。比如MYSQL->MYSQL的版本检查、源库binlog存在性检查、源库binlog是否开启检查、源库binlog影像类型检查、源库用户权限检查、源库连通性检查、MySQL参数lower_case_table_names一致性检查等等。

上面列举的测试用例只是开发了正向用例,因为只有一套数据库可用,无法做反向用例,如果通过更改数据库配置来满足反向用例的条件:

1、手工执行重启命令并指定启动参数,繁琐而且这也不是自动化测试了;

2、需要的时间长,拖慢自动化测试的进度;

3、更改重要的参数再改回来可能会影响其他一些测例的执行。

4、数据库版本检查的测例只能通过部署多套不同版本的数据库来完成。

综合以上的问题,目前DTS的自动化测试用例还是缺少很多,只能由测试人员手工执行,这消耗了测试人员的很多时间。

 

解决方案

容器以轻量、方便而著称,但是测试环境资源有限,测试用例多,启动多个容器实例所需的资源无法满足,手工管理也很麻烦。

如果通过编程语言远程启动docker容器来代替人为操作,需要时启动容器,用完及时释放资源,就可以大大节省人力和硬件资源。直接使用python的docker依赖来进行容器操作是一种可能的实施方案,如下启动一个MySQL容器。

    client = docker.from_env()
   # 启动MySQL容器
   container: Container = client.containers.run(
       'mysql:5.7',
       command='--lower_case_table_names=1',
       detach=True,
       name='mysql-container',
       ports={'3306/tcp': 13306},
       environment={
           'MYSQL_ROOT_PASSWORD': 'password',
           'MYSQL_USER': 'user',
           'MYSQL_PASSWORD': 'password',
           'MYSQL_DATABASE': 'mydb'
      }
  )
  # 删除容器
  container.remove()

不过开源框架testcontainers已经封装提供了更为方便的实现。

 

TestContainers

官网地址:https://www.testcontainers.org

TestContainers是一个开源项目,它提供诸多可以在Docker容器中运行的组件,非常轻量、方便,需要做的只是安装docker服务,无需其他配置。它支持Java,Python,Rust,Go,.net等多种语言,可以提供测试所需的多种环境。

testcontainers支持众多常用的主流组件,以Java为例,支持如下组件

 

image-20230327111058154

其中支持的Databases:

image-20230327111246510

不是所用语言的库都支持如此多的组件,go、node.js的testcontainers库只支持寥寥几种组件。

 

 

使用介绍

使用前提:test-containers 基于 Docker,所以使用 test-container 前需要安装 Docker环境。

不同版本testcontainers-python的API差异很大,这里使用的相关依赖的版本如下

SQLAlchemy~=1.4.46
testcontainers~=3.7.0
urllib3~=1.25.11

 

官方示例

testcontainers-python文档地址:https://testcontainers-python.readthedocs.io/en/latest/README.html

testcontainers-python给出的官方示例代码都很短小

def test_docker_run_mysql():
   config = MySqlContainer('mysql:5.7.17')
   with config as mysql:
       engine = sqlalchemy.create_engine(mysql.get_connection_url())
       with engine.begin() as connection:
           result = connection.execute(sqlalchemy.text("select version()"))
           for row in result:
               assert row[0].startswith('5.7.17')

以上python代码启动了5.7.17版本的容器实例,并且使用python的ORM框架sqlalchemy去连接数据库,获取数据库连接后执行SQL语句并返回结果。

 

继承关系

事实上,所有的特性Container都直接或间接派生自DockerContainer。以下是继承关系:

DockerContainer
|-KafkaContainer
|-ElasticSearchContainer
|-NginxContainer
|-DbContainer
|-MySqlContainer
|-SqlServerContainer
|-OracleDbContainer

DockerContainer封装了容器的通用操作,DbContainer封装了数据库的通用操作。而DockerContainer底层使用python的docker依赖来进行容器操作的。

 

DockerContainer

这是最灵活也是不太方便的容器类型, 此容器允许使用启动任何Docker镜像。

 

DockerContainer的构造函数

    def __init__(self, image, docker_client_kw: dict = None, **kwargs):
       self.env = {}
       self.ports = {}
       self.volumes = {}
       self.image = image
       self._docker = DockerClient(**(docker_client_kw or {}))
       self._container = None
       self._command = None
       self._name = None
       self._kwargs = kwargs

with_env方法用于设置容器的环境变量

    def with_env(self, key: str, value: str) -> 'DockerContainer':
       self.env[key] = value
       return self

with_command方法指定容器启动时的参数

    def with_command(self, command: str) -> 'DockerContainer':
       self._command = command
       return self

with_bind_ports用于设置一对绑定的端口

    def with_bind_ports(self, container: int,
                       host: int = None) -> 'DockerContainer':
       self.ports[container] = host
       return self

with_exposed_ports方法用于暴露一个内部端口,绑定的宿主机端口是随机的

    def with_exposed_ports(self, *ports) -> 'DockerContainer':
       for port in list(ports):
           self.ports[port] = None
       return self

get_exposed_port方法用来获取指定的内部端口所绑定的宿主机端口

    @wait_container_is_ready()
   def get_exposed_port(self, port) -> str:
       mapped_port = self.get_docker_client().port(self._container.id, port)
       if inside_container():
           gateway_ip = self.get_docker_client().gateway_ip(self._container.id)
           host = self.get_docker_client().host()

           if gateway_ip == host:
               return port
       return mapped_port

with_volume_mapping方法用于挂载数据卷

    def with_volume_mapping(self, host: str, container: str,
                           mode: str = 'ro') -> 'DockerContainer':
       # '/home/user1/': {'bind': '/mnt/vol2', 'mode': 'rw'}
       mapping = {'bind': container, 'mode': mode}
       self.volumes[host] = mapping
       return self

exec方法用于在容器内部执行指令

    def exec(self, command):
       if not self._container:
           raise ContainerStartException("Container should be started before")
       return self.get_wrapped_container().exec_run(command)

get_wrapped_container方法返回container对象,这才真正对应着一个里面有所有的容器信息

    def get_wrapped_container(self) -> Container:
       return self._container

 

可以使用DockerContainer启动任何类型的容器,比如启动一个MySQL容器

    container = DockerContainer("mysql:5.7")
   container.with_env("MYSQL_ROOT_PASSWORD", "afcer554KCJ5")
   container.with_command("--lower_case_table_names=1")
   container.with_exposed_ports(3306)
   container.start()

事实上,MysqlContainer与DockerContainer差异很小,MysqlContainer只是多做了一点事情(设置环境变量、检查容器是否启动就绪),自动化测试直接使用DockerContainer也能满足需求,当然使用MysqlContainer会更加方便一些。

 

MysqlContainer

MySqlContainer继承自DbContainer,DbContainer继承自DockerContainer。

MysqlContainer构造函数

    def __init__(self,
                image="mysql:latest",
                MYSQL_USER=None,
                MYSQL_ROOT_PASSWORD=None,
                MYSQL_PASSWORD=None,
                MYSQL_DATABASE=None,
                **kwargs):
       super(MySqlContainer, self).__init__(image, **kwargs)
       self.port_to_expose = 3306
       self.with_exposed_ports(self.port_to_expose)
       self.MYSQL_USER = MYSQL_USER or environ.get('MYSQL_USER', 'test')
       self.MYSQL_ROOT_PASSWORD = MYSQL_ROOT_PASSWORD or environ.get('MYSQL_ROOT_PASSWORD', 'test')
       self.MYSQL_PASSWORD = MYSQL_PASSWORD or environ.get('MYSQL_PASSWORD', 'test')
       self.MYSQL_DATABASE = MYSQL_DATABASE or environ.get('MYSQL_DATABASE', 'test')

       if self.MYSQL_USER == 'root':
           self.MYSQL_ROOT_PASSWORD = self.MYSQL_PASSWORD

镜像默认是latest,最新版。

 

启动容器

创建一个MysqlContainer对象后,只需简单调用start方法即可启动容器

    # 启动容器
  mysqlContainer.start()

start方法的定义

class DbContainer(DockerContainer):

   @wait_container_is_ready(*ADDITIONAL_TRANSIENT_ERRORS)
   def _connect(self):
       import sqlalchemy
       engine = sqlalchemy.create_engine(self.get_connection_url())
       engine.connect()

   def start(self):
  # 配置环境变量
       self._configure()
       # 这一步就是把容器启动起来
       super().start()
       # 验证容器服务是否可用
       self._connect()
       return self

在start方法里的最后调用_connect()方法是为了验证容器服务是否可用。_connect方法使用SQLAlchemy来连接数据库,SQLAlchemy通过容器的get_connection_url()方法获取数据库地址进行连接。_connect方法添加了@wait_container_is_ready注解,会一直等待直到数据库连接成功,超时时间120s。

SQLAlchemy是一个开源的Python ORM(对象关系映射)框架。

 

在windows上进行测试时,MysqlContainer的get_connection_url方法返回如下的URL对象

mysql+pymysql://test_asd:afrvte54657hnngf@localnpipe:65303/test

PostgresContainer的get_connection_url方法返回如下的URL对象

postgresql+psycopg2://postgres:***@localnpipe:64211/postgres

这里显然是通过named pipe来连接数据库,然而连接失败次数达到指定值,容器启动失败。

Windows命名管道只能用于Windows主机上的进程间通信,WSL上运行的Docker容器被视为独立的进程空间,因此无法通过命名管道进行通信。与WSL2中运行的Docker容器进行通信,需要使用网络通信协议,如TCP协议。

即使我在Windows本地启动MySQL服务,使用SQLAlchemy连接以上的URL对象也是失败。

 

阅读了testcontainers-python源码,通过继承MySqlContainer重写其get_connection_url()来解决。

from testcontainers.mysql import MySqlContainer


class CustomMysqlContainer(MySqlContainer):
   def get_connection_url(self):
       return 'mysql+pymysql://{0}:{1}@127.0.0.1:{2}/{3}'.format('root',
                                                                 self.MYSQL_ROOT_PASSWORD,
                                                                 self.get_exposed_port(3306),
                                                                 self.MYSQL_DATABASE)

将host写死为127.0.0.1,这样即可解决。

 

而testcontainers-java则是使用localhost连接的:

19:09:15.663 [main] INFO 🐳 [mysql:5.7.34] - Container mysql:5.7.34 is starting: 18f8abf99e470a467d86d5b37c09be2b7cfd94e5b270d7e633b001fbb3db2ae2
19:09:16.095 [main] INFO 🐳 [mysql:5.7.34] - Waiting for database connection to become available at jdbc:mysql://localhost:64513/test using query 'SELECT 1'
19:09:24.280 [main] INFO 🐳 [mysql:5.7.34] - Container is started (JDBC URL: jdbc:mysql://localhost:64513/test)
19:09:24.281 [main] INFO 🐳 [mysql:5.7.34] - Container mysql:5.7.34 started in PT8.7804708S

 

 

 

创建mysql账号

MysqlContainer构造函数中,可以指定参数MYSQL_USER、MYSQL_PASSWORD、MYSQL_ROOT_PASSWORD、MYSQL_DATABASE来创建账户相关信息。

    mysqlContainer = MysqlContainer(image='mysql:5.7',
                             MYSQL_USER='test_asd',
                             MYSQL_ROOT_PASSWORD='afrvte54657hnngf',
                             MYSQL_PASSWORD='afrvte54657hnngf',
                             MYSQL_DATABASE='test')

在容器启动过程中看到如下日志

[Warning] root@localhost is created with an empty password ! Please consider switching off the --initialize-insecure option.

MySQL初始化时root@localhost用户是空密码。如果设置了MYSQL_ROOT_PASSWORD,容器会在MySQL服务运行后立即设置root@localhost用户密码,并创建一个新用户root@%,两者密码一致。

 

指定端口

MySQL容器内的MySQL服务默认运行在3306号端口,绑定的宿主机端口是随机的。

可以在容器启动前将其绑定到指定的宿主机端口,例如

    mysqlContainer = MysqlContainer(image='mysql:5.7',
                             MYSQL_USER='test_asd',
                             MYSQL_ROOT_PASSWORD='afrvte54657hnngf',
                             MYSQL_PASSWORD='afrvte54657hnngf',
                             MYSQL_DATABASE='test')
   mysqlContainer.with_bind_ports(3306, 26788)

而with_exposed_ports方法也用于暴露指定的内部端口,但绑定的宿主机端口是随机的。

使用with_bind_ports方法绑定指定的宿主机端口需要确保宿主机该端口空闲,而with_exposed_ports会自动寻找宿主机上空闲的一个可用端口。

 

指定启动参数

如果想要指定MySQL的运行参数,可以在容器启动前使用with_command方法来指定MySQL参数,方法的参数格式如下

    mysqlContainer = MysqlContainer(image='mysql:5.7',
                             MYSQL_USER='test_asd',
                             MYSQL_ROOT_PASSWORD='afrvte54657hnngf',
                             MYSQL_PASSWORD='afrvte54657hnngf',
                             MYSQL_DATABASE='test')
   mysqlContainer.with_command('--lower-case-table-names=1')
   # 将把上面设置的command覆盖
   mysqlContainer.with_command('--character_set_server=utf8mb4')
   # 想要同时设置多个MySQL服务启动参数,按如下格式传递参数
   mysqlContainer.with_command('--lower-case-table-names=1 --character_set_server=utf8mb4')

进入容器查看

image-20230301183941451

 

 

容器操作

DockerContainer提供了start和stop方法,每次调用start方法就会启动一个新容器实例并返回,stop方法不是停止容器而是删除容器。

    def start(self):
       logger.info("Pulling image %s", self.image)
       docker_client = self.get_docker_client()
       self._container = docker_client.run(self.image,
                                           command=self._command,
                                           detach=True,
                                           environment=self.env,
                                           ports=self.ports,
                                           name=self._name,
                                           volumes=self.volumes,
                                           **self._kwargs
                                          )
       logger.info("Container started: %s", self._container.short_id)
       return self

   def stop(self, force=True, delete_volume=True):
       self.get_wrapped_container().remove(force=force, v=delete_volume)

为了避免容器一直运行,容器使用完后一定要调用DockerContainer的stop方法删除容器实例。

DockerContainer封装了具体的容器实例,提供了一些工具方法如with_env、with_command,它的实例变量_container才对应着一个具体的容器实例。

_container是Container类型的对象,是python的docker依赖提供的。

class Container(Model):
   """ Local representation of a container object. Detailed configuration may
      be accessed through the :py:attr:`attrs` attribute. Note that local
      attributes are cached; users may call :py:meth:`reload` to
      query the Docker daemon for the current properties, causing
      :py:attr:`attrs` to be refreshed.
  """
   
   //...
   
   def pause(self):
       """
      Pauses all processes within this container..
      """
       return self.client.api.pause(self.id)

   
   def remove(self, **kwargs):
       """
      Remove this container. Similar to the ``docker rm`` command.
      """
       return self.client.api.remove_container(self.id, **kwargs)
   
   
   def start(self, **kwargs):
       """
      Start this container. Similar to the ``docker start`` command, but
      """
       return self.client.api.start(self.id, **kwargs)
   
   
   def stop(self, **kwargs):
       """
      Stops a container. Similar to the ``docker stop`` command.
      """
       return self.client.api.stop(self.id, **kwargs)
   
   
   def unpause(self):
       """
      Unpause all processes within the container.
      """
       return self.client.api.unpause(self.id)

想要对容器实例进行其他操作,可以通过DockerContainer的get_wrapped_container方法获取_container实例变量。

    container = mysqlContainer.get_wrapped_container()
   #停止活动
   container.pause()
   #继续活动
   container.unpause()

我们可以调用pause方法来模拟数据库不可用状态。

 

性能表现和资源占用

测试环境:Windows WSL2+Windows docker desktop

启动容器的start方法是同步的,在等待容器完全启动后才会返回,这里的容器启动时间7.7s

image-20230301180134161

内存占用

image-20230301183539867

使用testcontainers-python创建容器时,推荐使用官方的Docker镜像,是经过优化和配置的,因此占用的内存资源是相对较少的。

第一次启动容器需要拉取镜像耗时会比较久。

 

 

控制容器启动超时时间

在start方法里,testcontainers通过连接数据库来判断服务是否可用。testcontainers默认设置的最大连接重试次数为120次,间隔1s。

os.environ.setdefault("TC_MAX_TRIES", '120')

调试阶段可以适当调小此值。

虽然单个容器启动速度很快,测试发现,如果连续启动4个MySQL容器,容器的启动时间超过甚至会超过120s导致测试失败(偶尔)。

 

 

PostgresContainer

指定参数

postgresContainer.with_command("-c wal_level=logical -c max_connections=134 -c shared_buffers=2GB")

资源占用

image-20230307093509025

 

 

docker-compose

testcontainers也支持使用docker-compose。

 

创建docker-compose.yml文件,启动一个MySQL和一个postgres。

version: '3'
services:
db:
  image: mysql:5.7
  environment:
    MYSQL_ROOT_PASSWORD: root
    MYSQL_DATABASE: test_db
    MYSQL_USER: test_user
    MYSQL_PASSWORD: test_password
  ports:
    - "13306:3306"
postgres:
  image: postgres:13
  environment:
    POSTGRES_DB: test_db
    POSTGRES_USER: test_user
    POSTGRES_PASSWORD: test_password
  ports:
    - "15432:5432"

创建DockerCompose,指定docker-compose文件在当前目录下,启动DockerCompose

from testcontainers.compose import DockerCompose


def test_compose():
   compose = DockerCompose('.')
   compose.start()

   mysql_host = compose.get_service_host('db', 3306)
   print(mysql_host)
   mysql_port = compose.get_service_port('db', 3306)
   print(mysql_port)

   '''
      Returns tuple[str, str, int] stdout, stderr, return code
  '''
   return_tuple = compose.exec_in_container('db', ['ls'])
   print(return_tuple)

   time.sleep(1)

   compose.stop()

 

 

 

testcontainers-java

@Testcontainers: 用于启用 Testcontainers 支持。它可以用在类或方法级别,让 JUnit 在测试执行前启动 Docker 容器。例如:

@Testcontainers
public class MyTestClass {
// ...
}

@Container: 用于将 Docker 容器作为测试类的静态字段启动。这个注解可以与 DockerComposeContainerGenericContainer 和其他扩展类一起使用。例如:

@Container
public static PostgreSQLContainer postgreSQLContainer = new PostgreSQLContainer();

 

 

@Testcontainers
public class MyTest {
   
@Container
   private static final MySQLContainer mySQLContainer = (MySQLContainer) new MySQLContainer("mysql:5.7.34")
          .withDatabaseName("test")
          .withUsername("dds")
          .withPassword("asfrer54765dsc")
          .withInitScript("mysqlScript.sql")
      .withEnv("name","value")
          .withCommand("--lower-case-table-names=1 --character_set_server=utf8mb4");
   
   @Test
   public void testMysql() throws SQLException {
       System.out.println(mySQLContainer.getMappedPort(3306));
       try (Connection conn = DriverManager.getConnection(mySQLContainer.getJdbcUrl(), mySQLContainer.getUsername(), mySQLContainer.getPassword())) {
           String sql = "SELECT 2 + 2";
           try (PreparedStatement stmt = conn.prepareStatement(sql)) {
               try (ResultSet rs = stmt.executeQuery()) {
                   rs.next();
                   int result = rs.getInt(1);
                   Assertions.assertEquals(4, result);
              }
          }
      }
  }

withPassword方法设置的密码也是root用户的密码;

withInitScript可指定MySQL的初始化脚本;

withCommand可指定MySQL参数;

withEnv设置容器环境变量;

getJdbcUrl返回JDBC连接urljdbc:mysql://localhost:51319/test

getMappedPort方法返回容器内部端口所绑定的宿主机端口。

 

 

 

文章来自个人专栏
测试技术
1 文章 | 1 订阅
0条评论
0 / 1000
请输入你的评论
0
0