pytestx容器化執行引擎

系統架構

前端、後端、pytest均以Docker容器運行服務,單獨的容器化執行引擎,項目環境隔離,即用即取,用完即齊,簡單,高效。

  • 前端容器:頁面交互,請求後端,展示HTML報告

  • 後端容器:接收前端請求,啓動任務,構建鏡像,觸發運行pytest,掛載HTML報告

  • pytest容器:拉取項目代碼,指定目錄執行,生成HTML報告

說明:構建鏡像目前是在宿主機啓動後端服務來執行docker命令的,暫未支持Kubernetes編排。宿主機安裝了Docker,啓動服務後,可以執行docker命令。如果採用容器部署後端,容器裏面不包含Docker,無法構建,個人想法是可以藉助K8S來編排,當前版本還未實現

系統流程

支持2種運行模式配置:容器和本地。

容器模式:判斷是否支持docker,如果支持,構建pytest鏡像,在構建時,通過git拉取項目代碼,再運行容器,按照指定目錄執行pytest,生成測試報告,並將報告文件掛載到後端。如果不支持,降級爲本地運行。

本地模式:模擬容器行爲,在本地目錄拉取代碼,執行pytest,生成測試報告。

效果展示

任務管理:

容器模式:

本地模式:

平臺大改造

pytestx平臺更輕、更薄,移除了用例管理、任務關聯用例相關功能代碼,只保留真正的任務調度功能,backend的requirements.txt解耦,只保留後端依賴,pytest相關依賴轉移到tep-project。

那如何管理用例呢?約定大於配置,我們約定pytest項目已經通過目錄維護好了一個穩定的自動化用例集,也就是說需要通過平臺任務調度的用例,都統一存放在目錄X下,這些用例基本不需要維護,可以每日穩定執行,然後將目錄X配置到平臺任務信息中,按指定目錄執行用例集。對於那些不夠穩定的用例,就不能放到目錄X下,需要調試好以後再納入。

爲什麼不用marker?pytest的marker確實可以給測試用例打標記,也有人是手動建立任務和用例進行映射,這些方式都不如維護一個穩定的自動化用例集方便,在我們公司平臺上,也是維護用例集,作爲基礎用例集。使用pytest項目同理。

核心代碼

一鍵部署

#!/bin/bash
PkgName='backend'

Dockerfile='./deploy/Dockerfile.backend'
DockerContext=./

echo "Start build image..."
docker build -f $Dockerfile -t $PkgName $DockerContext
if [ $? -eq 0 ]
then
    echo "Build docker image success"
    echo "Start run image..."
    docker run -p 8000:80 $PkgName
else
    echo "Build docker image failed"
fi
FROM python:3.8

ENV LANG C.UTF-8
ENV TZ=Asia/Shanghai

RUN /bin/cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone

WORKDIR /app
COPY ./backend .
RUN pip install -r ./requirements.txt -i \
    https://pypi.tuna.tsinghua.edu.cn/simple \
    --default-timeout=3000

CMD ["python", "./manage.py", "runserver", "0.0.0.0:80"]

數據庫表

更精簡,只有project和task兩張表,簡化平臺功能,聚焦任務調度:

需要說明的是,如果多人運行任務,只會存儲最後一次執行結果,這個問題不是核心,個人精力有限,不打算在開源項目中開發,更側重於實現任務調度,供大家參考

執行任務

settings配置任務模式,判斷執行不同分支:

def run(self):
    logger.info("任務開始執行")
    if settings.TASK_RUN_MODE == TaskRunMode.DOCKER:  # 容器模式
        try:
            self.execute_by_docker()
        except Exception as e:
            logger.info(e)
            if e == TaskException.DockerNotSupportedException:
                logger.info("降級爲本地執行")
                self.execute_by_local()
    if settings.TASK_RUN_MODE == TaskRunMode.LOCAL:  # 本地模式
        self.execute_by_local()
    self.save_task()

容器模式

先根據docker -v命令判斷是否支持docker,然後docker build,再docker run

def execute_by_docker(self):
    logger.info("運行模式:容器")
    output = subprocess.getoutput("docker -v")
    logger.info(output)
    if "not found" in output:
        raise TaskException.DockerNotSupportedException
    build_args = [
        f'--build-arg CMD_GIT_CLONE="{self.cmd_git_clone}"',
        f'--build-arg GIT_NAME="{self.git_name}"',
        f'--build-arg EXEC_DIR="{self.exec_dir}"',
        f'--build-arg REPORT_NAME="{self.report_name}"',
    ]
    cmd = f"docker build {' '.join(build_args)} -f {self.dockerfile_pytest} -t {self.git_name} {BASE_DIR}"
    logger.info(cmd)
    output = subprocess.getoutput(cmd)
    logger.info(output)
    cmd = f"docker run -v {REPORT_PATH}:/app/{os.path.join(self.exec_dir, 'reports')} {self.git_name}"
    logger.info(cmd)
    output = subprocess.getoutput(cmd)
    logger.info(output)

將項目倉庫、執行目錄、報告名稱信息,通過參數傳入Dockerfile.pytest

FROM python:3.8

ENV LANG C.UTF-8
ENV TZ=Asia/Shanghai
ARG CMD_GIT_CLONE
ARG GIT_NAME
ARG EXEC_DIR
ARG REPORT_NAME

RUN /bin/cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone

WORKDIR /app
RUN $CMD_GIT_CLONE
RUN pip install -r ./$GIT_NAME/requirements.txt -i \
    https://pypi.tuna.tsinghua.edu.cn/simple \
    --default-timeout=3000

WORKDIR $EXEC_DIR
ENV HTML_NAME=$REPORT_NAME
CMD ["pytest", "--html=./reports/$HTML_NAME", "--self-contained-html"]

docker run的-v參數將容器報告掛載在後端服務,當報告生成後,後端服務也會生成一份報告文件。再將文件內容返給前端展示:

def report(request, *args, **kwargs):
    task_id = kwargs["task_id"]
    task = Task.objects.get(id=task_id)
    report_path = task.report_path

    with open(os.path.join(REPORT_PATH, report_path), 'r', encoding="utf8") as f:
        html_content = f.read()

    return HttpResponse(html_content, content_type='text/html')

測試報告使用的pytest-html,重數據內容,輕外觀樣式。

本地模式

模擬容器行爲,把本地.local目錄當做容器,拉代碼,執行pytest,生成報告,複製報告到報告文件夾,刪除本地目錄:

def execute_by_local(self):
    logger.info("運行模式:本地")
    os.makedirs(self.local_path, exist_ok=True)
    os.chdir(self.local_path)
    cmd_list = [self.cmd_git_clone, self.cmd_pytest]
    for cmd in cmd_list:
        logger.info(cmd)
        output = subprocess.getoutput(cmd)
        if output:
            logger.info(output)
    os.makedirs(REPORT_PATH, exist_ok=True)
    shutil.copy2(self.project_report_path, REPORT_PATH)
    shutil.rmtree(LOCAL_PATH)

本地模式,主要用於本地調試,在缺失Docker環境時,也能調試其他功能。

配置

TASK_RUN_MODE = TaskRunMode.DOCKER
LOCAL_PATH = os.path.join(BASE_DIR, ".local")
REPORT_PATH = os.path.join(BASE_DIR, "task", "report")
class TaskRunner:
    def __init__(self, task_id, run_user_id):
        self.task_id = task_id
        self.directory = Task.objects.get(id=task_id).directory
        self.project_id = Task.objects.get(id=task_id).project_id
        self.git_repository = Project.objects.get(id=self.project_id).git_repository
        self.git_branch = Project.objects.get(id=self.project_id).git_branch
        self.git_name = re.findall(r"^.*/(.*).git", self.git_repository)[0]
        self.local_path = os.path.join(LOCAL_PATH, str(uuid.uuid1()).replace("-", ""))
        self.run_user_id = run_user_id
        self.current_time = time.strftime("%Y-%m-%d-%H-%M-%S", time.localtime(time.time()))
        self.report_name = f"{str(self.git_name)}-{self.task_id}-{self.run_user_id}-{self.current_time}.html"
        self.project_report_path = os.path.join(self.local_path, self.git_name, "reports", self.report_name)
        self.dockerfile_pytest = os.path.join(DEPLOY_PATH, "Dockerfile.pytest")
        self.exec_dir = os.path.join(self.git_name, self.directory)
        self.cmd_git_clone = f"git clone -b {self.git_branch} {self.git_repository}"
        self.cmd_pytest = f"pytest {self.exec_dir} --html={self.project_report_path} --self-contained-html"

tep-project更新

1、整合fixture,功能類放在fixture_function模塊,數據類放在其他模塊,突出fixture存放數據概念,比如登錄接口fixture_login存儲用戶名密碼、數據庫fixture_mysql存儲連接信息、文件fixture_file_data存儲文件路徑

2、改造fixture_login,數據類fixture代碼更簡潔

import pytest
from loguru import logger


@pytest.fixture(scope="session")
def login(http, file_data):
    logger.info("----------------開始登錄----------------")
    response = http(
        "post",
        url=file_data["domain"] + "/api/users/login",
        headers={"Content-Type": "application/json"},
        json={"username": "admin", "password": "qa123456"}
    )
    assert response.status_code < 400
    logger.info("----------------登錄成功----------------")
    response = response.json()
    return {"Content-Type": "application/json", "Authorization": f"Bearer {response['token']}"}


@pytest.fixture(scope="session")
def login_xdist(http, tep_context_manager, file_data):
    """
    該login只會在整個運行期間執行一次
    """

    def produce_expensive_data(variable):
        logger.info("----------------開始登錄----------------")
        response = http(
            "post",
            url=variable["domain"] + "/api/users/login",
            headers={"Content-Type": "application/json"},
            json={"username": "admin", "password": "qa123456"}
        )
        assert response.status_code < 400
        logger.info("----------------登錄成功----------------")
        return response.json()

    response = tep_context_manager(produce_expensive_data, file_data)
    return {"Authorization": "Bearer " + response["token"]}

3、改造fixture_mysql,支持維護多個連接,並且保持簡潔

fixture_function.py

@pytest.fixture(scope="class")
def executor():
    class Executor:
        def __init__(self, db):
            self.db = db
            self.cursor = db.cursor()

        def execute_sql(self, sql):
            try:
                self.cursor.execute(sql)
                self.db.commit()
            except Exception as e:
                print(e)
                self.db.rollback()
            return self.cursor

    return Executor

fixture_mysql.py

@pytest.fixture(scope="class")
def mysql_execute(executor):
    db = pymysql.connect(host="host",
                         port=3306,
                         user="root",
                         password="password",
                         database="database")
    yield executor(db).execute_sql
    db.close()


@pytest.fixture(scope="class")
def mysql_execute_x(executor):
    db = pymysql.connect(host="x",
                         port=3306,
                         user="x",
                         password="x",
                         database="x")
    yield executor(db).execute_sql
    db.close()

4、改造fixture_file_data,並添加示例test_file_data.py

import os

import pytest

from conftest import RESOURCE_PATH


@pytest.fixture(scope="session")
def file_data(resource):
    file_path = os.path.join(RESOURCE_PATH, "demo.yaml")
    return resource(file_path).get_data()


@pytest.fixture(scope="session")
def file_data_json(resource):
    file_path = os.path.join(RESOURCE_PATH, "demo.json")
    return resource(file_path).get_data()

5、添加接口複用的示例代碼

tests/base就是平臺調度使用的穩定自動化用例集。

接口代碼複用設計

5條用例:

  1. test_search_sku.py:搜索商品,前置條件:登錄

  2. test_add_cart.py:添加購物車,前置條件:登錄,搜索商品

  3. test_order.py:下單,前置條件:登錄,搜索商品,添加購物車

  4. test_pay.py:支付,前置條件:登錄,搜索商品,添加購物車,下單

  5. test_flow.py:完整流程

怎麼設計?

  • 登錄,每條用例前置條件都依賴,定義爲fixture_login,放在fixtures目錄下

  • 搜索商品,test_search_sku.py用例本身不需要複用,被前置條件依賴3次,可以複用

①定義爲fixture_search_sku放在fixtures❌ 弊端:導致fixtures臃腫

②複製用例文件,允許多份代碼,平行展開✅ 好處:高度解耦,不用擔心依賴問題

總結,定義爲fixture需要具備底層性,足夠精煉。對於業務接口用例的前置條件,儘量在用例文件內部處理,保持文件解耦,遵循獨立可運行的原則。

複製多份文件?需要修改的話要改多份文件?

是的,但這種情況極少。我能想到的情況:一、框架設計不成熟,動了底層設計,二、接口不穩定,改了公共接口,三、用例設計不合理,不能算是自動化。接口自動化要做好的前提,其實就是框架成熟,接口穩定,用例設計合理,滿足這些前提以後,沉澱下來的自動化用例,幾乎不需要大批量修改,更多的是要針對每條用例,去修改內部的數據,以滿足不同場景的測試需要。也就是說,針對某個用例修改這個用例的數據,是更常見的行爲。

如果項目變動實在太大,整個自動化都不能用了,不管是做封裝還是平行展開,維護量都非常大,耦合度太高的話,反而還不好改。

跟着pytestx學習接口自動化框架設計,更簡單,更快速,更高效

https://github.com/dongfanger/pytestx

https://gitee.com/dongfanger/tep-project

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章