【Pytest框架学习】分层架构

5 阅读12分钟

 前言:

代码同步分享平台:

【Pytest框架学习】分层架构-CSDN博客

Version 01链接:

【Pytest框架学习】0-1搭建API测试框架-Version01-跑通test case

Github地址: 后续补充

思路: 通过分层架构,将“业务逻辑”、“数据驱动”和“环境配置”解耦,实现测试用例的高内聚、低耦合与可复用性。

        我们先跑通核心的测试流程,这个在Version01我们已经做到了。跑通简单的测试案例之后,你就会发现,有一些数据不能写死,人工添加案例(可能是上百条)太累了、断言还可以细分,还一些重复的动作,比如读取文件、读取参数、批量生成案例、环境隔离、可视化页面等等;于是我们再看看Version01的代码里,哪里有这些功能,把这些动作剥离出来单独成为一个类/单元(util)、相互调用,哪里没有这些功能,可以拓展补充;从而实现框架复用,做到后续尽可能只要维护设计到参数,具体内容的模块... ...



此版本改动

1. 优化数据驱动

数据驱动由Json文件改为Yaml文件:方便维护;


2. 新增日志模块

将输出日志保存为文件,且在终端打印结果;


3. 新增可视化模块

(1)可视化展示:

包括:测试通过统计、测试案例对应的请求数据、响应数据等信息;


(2)为什么选择选择Allure?

        Allure Report 是一个测试报告工具,核心特点:

  • 可视化测试报告(图表、趋势、用例分布)
  • 支持多种测试框架(Pytest / JUnit / TestNG / Cucumber / Appium)
  • 能记录失败步骤、截图、日志
  • 支持步骤级别的测试过程展示(不像传统报告只看结果)
  • 可生成历史趋势分析(CI/CD 很常用)

(3)配置环境

安装python依赖:

pip install allure-pytest

命令行安装:

mac安装allure命令行工具:

brew install allure

安装过程中发现本机环境太老,没有办法安装X code,选择手动下载安装包安装:

Releases · allure-framework/allure2 · GitHub

下载.zip文件解压后移动到系统的以下目录下,先创建,然后移入,指令如下:

sudo mkdir -p /opt/allure

sudo mv allure-2.* /opt/allure   

Mac电脑zsh终端环境变量配置文件:.zshrc文件中添加如下字段,然后保存:

#allure
export ALLURE\_HOME=/opt/allure/allure-2.38.1
export PATH=$ALLURE_HOME/bin:$PATH

终端输入指令,  出现allure即可。

allure --version


conda环境配置:

使用conda环境无法找到allure指令,依次执行如下指令:

conda activate 你的conda环境

mkdir -p $CONDA_PREFIX/etc/conda/activate.d

nano $CONDA_PREFIX/etc/conda/activate.d/allure.sh

->nano/PICO 编辑器写入:

export PATH=/opt/allure/allure-2.38.1/bin:$PATH

退出conda环境:

conda deactivate

再进入conda环境:

conda activate 你的conda环境

验证:

which allureallure --version


Allure可视化 :(在跑完测试生成report文件后):

a. 在项目根目录运行(本地):

allure serve reports/allure-results

b.或者(本地)

 cd /Users/xxx/Desktop/PythonFloder/PytestProject/PytestProjectLocal02/report/allure-html(html目录)

python -m http.server 8080

浏览器打开:

http://127.0.0.1:8080

c.或者(本地)

 python -m http.server 8000

http://127.0.0.1:8000/report/allure-html/

d. 在线运行:容易出现loading

使用浏览器打开该网址: open report/allure-html/index.html

问题:浏览器打开——加载静态资源遇到了loarding的情况

解释:Allure 报告依赖很多静态资源文件,file:// 模式下浏览器常常限制这些资源加载;用本地服务变成 http:// 后,浏览器才会把它当成正常网页完整加载。


4. 新增测试结果输出到飞书群聊模块

将输出测试结果发送到飞书:

在飞书创建群聊,添加机器人(获取webhook链接(不要泄漏)放入环境变量配置),给机器人设置关键词触发验证(发送给机器人的消息要包括这条验证)


5. 优化断言

——


6. 添加统一执行入口:run.py

cd tests

python run.py



代码示例

安装依赖:

pip install -e . 

(1) case_loader.py

yaml文件示例:

cases:
  - case_id: home_article_list_page_0
    name: 首页文章列表-第1页
    enabled: true
    request:
      method: GET
      path: /article/list/0/json
      params: {}
      json: null
      data: null
      headers: {}
    expect:
      status_code: 200
      error_code: 0
      data_keys:
        - datas
        - curPage
        - pageCount
      path_equals: {}

from pathlib import Path

#只负责读取yaml文件

import yaml

BASE_DIR = Path(__file__).resolve().parents[3] / "tests"


def read_yaml(file_path, file_name):
    """加载 YAML 测试用例"""
    file_path = BASE_DIR / file_path / file_name
    with open(file_path, encoding="utf-8") as f:
        return yaml.safe_load(f)

class DataUtil:
    def __init__(self):
        self.base_dir = "data"

    def get_yaml_data(self, file_name, key=None):
        data = read_yaml(self.base_dir, file_name)
        return [
            case
            for case in data["cases"]
            if case.get("enabled", True)
        ]

(2) logger.py

在需要输出日志的地方引入日志模块即可


# 日志工具类-统一创建logger
# 日志会同时输出到控制台和 logs/test_YYYYMMDD.log

import logging
from datetime import datetime
from pathlib import Path

#获取根目录,然后创建日志文件目录
BASE_DIR = Path(__file__).resolve().parents[3]
LOG_DIR = BASE_DIR / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)


#日志处理器-将日志写入文件
def _build_file_handler() -> logging.Handler:
    log_file = LOG_DIR / f"test_{datetime.now():%Y%m%d}.log"
    handler = logging.FileHandler(log_file, encoding="utf-8")
    handler.setLevel(logging.INFO)
    return handler

#终端打印日志
def _build_stream_handler() -> logging.Handler:
    handler = logging.StreamHandler()
    handler.setLevel(logging.INFO)
    return handler


def get_logger(name: str = "pytest_project") -> logging.Logger: #这个函数返回一个日志处理器logging.Logger对象(类型提示)
    logger = logging.getLogger(name) #按名字取日志
    if logger.handlers: #如果日志处理器已经存在,直接返回,避免重复创建日志处理器
        return logger

    logger.setLevel(logging.INFO)
    logger.propagate = False #设置为False,避免日志处理器向父日志处理器传递日志
    
    #日志格式化器-定义日志的格式,时间 | 级别 | logger名称 | 消息
    formatter = logging.Formatter(
        "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
    )

    file_handler = _build_file_handler()
    stream_handler = _build_stream_handler()
    file_handler.setFormatter(formatter)
    stream_handler.setFormatter(formatter)

    logger.addHandler(file_handler) #将文件日志处理器添加到日志处理器中
    logger.addHandler(stream_handler) #将终端日志处理器添加到日志处理器中   
    return logger

(3) allure_report.py

import json
import os
import shutil
from pathlib import Path
from typing import Any
from datetime import datetime, timezone
from contextlib import nullcontext

# 报告目录
BASE_DIR = Path(__file__).resolve().parents[3]
REPORT_DIR = BASE_DIR / "reports"
ALLURE_RESULT_DIR = REPORT_DIR / "allure-results"
ALLURE_HTML_DIR = REPORT_DIR / "allure-html"


# Allure 兼容层
try:
    import allure as _allure
except ImportError:
    _allure = None

class _NoOpAttachmentType:
    JSON = "json"

class _NoOpDynamic:

    @staticmethod
    def title(*args, **kwargs) -> None:
        return None

    @staticmethod
    def label(*args, **kwargs) -> None:
        return None

    @staticmethod
    def parameter(*args, **kwargs) -> None:
        return None


class _NoOpAllure:

    attachment_type = _NoOpAttachmentType()
    dynamic = _NoOpDynamic()

    @staticmethod
    def attach(*args, **kwargs) -> None:
        return None

    @staticmethod
    def step(*args, **kwargs):
        return nullcontext()

    @staticmethod
    def _decorator(*args, **kwargs):
        def wrapper(func):
            return func

        return wrapper

    epic = _decorator
    feature = _decorator
    story = _decorator
    severity = _decorator
    tag = _decorator

    class severity_level:
        BLOCKER = "blocker"
        CRITICAL = "critical"
        NORMAL = "normal"
        MINOR = "minor"
        TRIVIAL = "trivial"

allure = _allure or _NoOpAllure()

# 报告目录
def ensure_report_dirs() -> None:
    """确保 Allure 报告目录存在。"""
    REPORT_DIR.mkdir(parents=True, exist_ok=True)
    ALLURE_RESULT_DIR.mkdir(parents=True, exist_ok=True)
    ALLURE_HTML_DIR.mkdir(parents=True, exist_ok=True)

def clean_allure_results() -> None:
    """清理上一次测试产生的 Allure 结果。"""
    if ALLURE_RESULT_DIR.exists():
        shutil.rmtree(ALLURE_RESULT_DIR)

    ALLURE_RESULT_DIR.mkdir(parents=True, exist_ok=True)


def is_allure_available() -> bool:
    """判断 Python Allure 库是否可用。"""
    return _allure is not None


# 数据处理
def _safe_value(getter, default: str = "unknown") -> str:
    try:
        value = getter()
    except Exception:
        return default

    return str(value) if value not in (None, "") else default


def to_pretty_text(data: Any) -> str:
    """将数据转换成适合 Allure 展示的 JSON 文本。"""

    if data is None:
        return ""

    if isinstance(data, str):
        return data

    try:
        return json.dumps(
            data,
            ensure_ascii=False,
            indent=2,
        )
    except TypeError:
        return str(data)


def attach_json(name: str, data: Any) -> None:
    """将数据作为 JSON 附件写入当前 Allure 测试用例。"""

    if not is_allure_available():
        return

    allure.attach(
        to_pretty_text(data),
        name=name,
        attachment_type=allure.attachment_type.JSON,
    )

# Allure Environment
def write_environment_properties() -> Path:
    """写入 Allure 环境信息。"""

    ensure_report_dirs()
    environment_file = (
        ALLURE_RESULT_DIR / "environment.properties"
    )
    rows = {
        "ENV": os.getenv("env", "test"),
        "BASE_URL": os.getenv("API_BASE_URL", "unknown"),
        "PYTHON": os.getenv(
            "PYTHON_VERSION",
            os.getenv("VIRTUAL_ENV", "unknown"),
        ),
        "REPORT_GENERATED_AT_UTC": datetime.now(
            timezone.utc
        ).strftime("%Y-%m-%d %H:%M:%S"),
        "HOSTNAME": os.getenv(
            "HOSTNAME",
            os.getenv("COMPUTERNAME", "local"),
        ),
    }

    environment_file.write_text(
        "\n".join(
            f"{key}={value}"
            for key, value in rows.items()
        )
        + "\n",
        encoding="utf-8",
    )

    return environment_file


def write_executor_json() -> Path:
    """写入 Allure 执行器信息。"""
    ensure_report_dirs()
    executor_file = ALLURE_RESULT_DIR / "executor.json"
    executor = {
        "name": os.getenv(
            "CI_PLATFORM",
            "Local Run",
        ),
        "type": os.getenv(
            "CI_PLATFORM_TYPE",
            "local",
        ),
        "buildName": os.getenv(
            "BUILD_NAME",
            "Pytest Allure Report",
        ),
        "buildOrder": os.getenv(
            "BUILD_NUMBER",
            "1",
        ),
        "buildUrl": os.getenv(
            "BUILD_URL",
            "",
        ),
        "reportName": os.getenv(
            "ALLURE_REPORT_NAME",
            "",
        ),
        "reportUrl": os.getenv(
            "ALLURE_REPORT_URL",
            "",
        ),
    }

    executor_file.write_text(
        json.dumps(
            executor,
            ensure_ascii=False,
            indent=2,
        ),
        encoding="utf-8",
    )

    return executor_file


def write_categories_json() -> Path:
    """写入 Allure 失败分类规则。"""
    ensure_report_dirs()
    categories_file = ALLURE_RESULT_DIR / "categories.json"
    categories = [
        {
            "name": "Assertion Failure",
            "matchedStatuses": ["failed"],
            "messageRegex": ".*AssertionError.*|.*assert .*",
        },
        {
            "name": "Environment Or Config Error",
            "matchedStatuses": ["broken"],
            "messageRegex": (
                ".*未配置.*"
                "|.*Missing env var.*"
                "|.*Placeholder variable not found.*"
                "|.*不支持的环境.*"
            ),
        },
        {
            "name": "Authentication Failure",
            "matchedStatuses": [
                "failed",
                "broken",
            ],
            "messageRegex": (
                ".*401.*"
                "|.*403.*"
                "|.*token.*"
                "|.*access_token.*"
            ),
        },
        {
            "name": "HTTP Or Dependency Error",
            "matchedStatuses": [
                "failed",
                "broken",
            ],
            "messageRegex": (
                ".*Timeout.*"
                "|.*ConnectError.*"
                "|.*ReadTimeout.*"
                "|.*HTTPStatusError.*"
            ),
        },
        {
            "name": "Data Or Schema Error",
            "matchedStatuses": [
                "failed",
                "broken",
            ],
            "messageRegex": (
                ".*KeyError.*"
                "|.*TypeError.*"
                "|.*ValueError.*"
                "|.*JSON.*"
            ),
        },
    ]
    categories_file.write_text(
        json.dumps(
            categories,
            ensure_ascii=False,
            indent=2,
        ),
        encoding="utf-8",
    )
    return categories_file

(4) feishu.py



# 将测试结果发送到飞书群聊

import base64
import hashlib
import hmac
import os
import time
from typing import Any
import requests
from auto_tests.utils.logger import get_logger

logger = get_logger("feishu_notify")

# 配置
def _is_enabled() -> bool:
    """
    判断是否开启飞书通知。
    环境变量:
        FEISHU_NOTIFY_ENABLED=true  -> 开启
        FEISHU_NOTIFY_ENABLED=false -> 关闭
    """
    return os.environ.get("FEISHU_NOTIFY_ENABLED", "false").lower() == "true"

def _get_webhook_url() -> str:
    """获取飞书机器人 Webhook URL。"""
    return os.environ.get("FEISHU_WEBHOOK_URL", "").strip()

def _get_bot_secret() -> str:
    """
    获取飞书机器人签名 Secret。
    如果没有配置 Secret,则不进行签名。
    """
    return os.environ.get("FEISHU_BOT_SECRET", "").strip()

# 飞书签名
def _sign(secret: str) -> tuple[str, str]:
    """
    根据飞书机器人 Secret 生成签名。

    Returns:
        timestamp: 时间戳
        sign: 签名
    """
    timestamp = str(int(time.time()))
    string_to_sign = f"{timestamp}\n{secret}"
    digest = hmac.new(
        string_to_sign.encode("utf-8"),
        digestmod=hashlib.sha256,
    ).digest()
    sign = base64.b64encode(digest).decode("utf-8")
    return timestamp, sign


# 飞书响应处理

def _is_success_response(body: Any) -> bool:
    """判断飞书接口是否返回成功。"""
    if not isinstance(body, dict):
        return False
    if "StatusCode" in body:
        return body.get("StatusCode") == 0
    if "code" in body:
        return body.get("code") == 0
    return False

def _stringify_body(body: Any) -> str:
    """将响应内容转换成字符串,方便日志输出。"""
    if isinstance(body, dict):
        return str(body)
    return repr(body)

# 发送飞书通知
def send_feishu_notification(message: str) -> tuple[bool, str]:
    """
    发送文本消息到飞书群聊。
    Args:
        message: 要发送的消息内容。
    Returns:
        tuple[bool, str]:
            True  -> 发送成功
            False -> 未发送或发送失败
    """
    # 1. 判断是否开启通知
    if not _is_enabled():
        result = "skipped: disabled"
        logger.info("Feishu notify %s", result)
        return False, result

    # 2. 获取 Webhook
    webhook_url = _get_webhook_url()
    if not webhook_url:
        result = "skipped: FEISHU_WEBHOOK_URL is empty"
        logger.warning("Feishu notify %s", result)
        return False, result

    # 3. 构造消息
    payload: dict[str, Any] = {
        "msg_type": "text",
        "content": {
            "text": message,
        },
    }

    # 4. 如果配置了 Secret,则添加签名
    secret = _get_bot_secret()
    if secret:
        timestamp, sign = _sign(secret)

        payload["timestamp"] = timestamp
        payload["sign"] = sign

    # 5. 发送请求
    try:
        response = requests.post(
            webhook_url,
            json=payload,
            timeout=10,
        )

        response.raise_for_status()
        body = response.json()

        # 6. 判断飞书返回结果
        if not _is_success_response(body):
            result = f"failed: response body={_stringify_body(body)}"

            logger.warning(
                "Feishu notify %s",
                result,
            )
            return False, result
        logger.info("Feishu notification sent successfully")
        return True, "sent successfully"

    except Exception as exc:
        result = f"failed: {exc}"
        logger.warning(
            "Feishu notify %s",
            result,
        )
        return False, result

(5) api_client.py

import httpx

from auto_tests.utils.logger import get_logger
from auto_tests.report_util.allure import attach_json


class ApiClient:
    """
    API 客户端封装类。

    用于统一管理:
    - HTTP GET/POST 请求
    - 请求 Header
    - Cookie
    - HTTP Client 生命周期
    - Allure 请求/响应附件
    - 根据测试数据自动分发 HTTP 请求
    """

    def __init__(self, base_url: str, timeout: float = 30.0):
        self._client = httpx.Client(
            base_url=base_url,
            timeout=timeout,
        )

        self.logger = get_logger("api_client")

        self.last_request: dict | None = None
        self.last_response: dict | None = None

        self._http_seq = 0

    def _next_http_seq(self) -> int:
        self._http_seq += 1
        return self._http_seq

    @staticmethod
    def _empty(value) -> bool:
        return value is None or value == {} or value == []

    def request(self, req: dict) -> httpx.Response:
        """
        数据驱动时,根据测试数据中的 method 自动发送 HTTP 请求。
        Args:
            req:
                {
                    "method": "GET",
                    "path": "/xxx",
                    "params": {},
                    "json": {},
                    "data": {},
                    "headers": {}
                }
        """

        method = (req.get("method") or "GET").upper()
        path = req["path"]
        params = (
            req.get("params")
            if not self._empty(req.get("params"))
            else None
        )

        json_body = (
            req.get("json")
            if not self._empty(req.get("json"))
            else None
        )

        data = (
            req.get("data")
            if not self._empty(req.get("data"))
            else None
        )

        headers = (
            req.get("headers")
            if not self._empty(req.get("headers"))
            else None
        )

        if method == "GET":
            return self.get(
                path,
                params=params,
                headers=headers,
            )

        if method == "POST":
            return self.post(
                path,
                json=json_body,
                data=data,
                headers=headers,
            )

        raise ValueError(
            f"Unsupported method: {method}"
        )

    def set_header(self, name: str, value: str) -> None:
        """设置公共 Header。"""
        self._client.headers[name] = value

    def set_cookie(
        self,
        name: str,
        value: str,
        domain: str | None = None,
        path: str = "/",
    ) -> None:
        """设置 Cookie。"""
        self._client.cookies.set(
            name,
            value,
            domain=domain,
            path=path,
        )

        self.logger.info(
            "Cookie 已经被设置!"
        )

    def get(
        self,
        path: str,
        params: dict | None = None,
        headers: dict | None = None,
    ) -> httpx.Response:

        http_seq = self._next_http_seq()

        request_payload = {
            "method": "GET",
            "path": path,
            "params": params,
            "headers": headers or dict(self._client.headers),
        }

        self.last_request = request_payload

        attach_json(
            f"[HTTP {http_seq}] GET {path} request",
            request_payload,
        )

        self.logger.info(
            "HTTP GET %s params=%s",
            path,
            params,
        )

        response = self._client.get(
            path,
            params=params,
            headers=headers,
        )

        self._record_response(
            response,
            http_seq,
        )

        return response

    def post(
        self,
        path: str,
        json: dict | None = None,
        data: dict | None = None,
        headers: dict | None = None,
    ) -> httpx.Response:

        http_seq = self._next_http_seq()

        request_payload = {
            "method": "POST",
            "path": path,
            "json": json,
            "data": data,
            "headers": headers or dict(self._client.headers),
        }

        self.last_request = request_payload

        attach_json(
            f"[HTTP {http_seq}] POST {path} request",
            request_payload,
        )

        self.logger.info(
            "HTTP POST %s json=%s data=%s",
            path,
            json,
            data,
        )

        response = self._client.post(
            path,
            json=json,
            data=data,
            headers=headers,
        )

        self._record_response(
            response,
            http_seq,
        )

        return response

    def _record_response(
        self,
        response: httpx.Response,
        http_seq: int,
    ) -> None:

        try:
            body = response.json()
        except Exception:
            body = response.text

        self.last_response = {
            "status_code": response.status_code,
            "headers": dict(response.headers),
            "body": body,
        }

        self.logger.info(
            "HTTP RESPONSE %s body=%s",
            response.status_code,
            body,
        )

        attach_json(
            f"[HTTP {http_seq}] "
            f"{response.request.method} "
            f"{response.request.url.path} response",
            self.last_response,
        )

    def close(self):
        """关闭 HTTP Client。"""
        self._client.close()

(6) assertion.py



from typing import Any

def key_exists_anywhere(obj: Any, target_key: str) -> bool:
    """
    递归查找对象(dict/list 混合)中是否存在指定 key。
    """
    if isinstance(obj, dict):
        if target_key in obj:
            return True
        return any(
            key_exists_anywhere(value, target_key)
            for value in obj.values()
        )
    if isinstance(obj, list):
        return any(
            key_exists_anywhere(item, target_key)
            for item in obj
        )
    return False

def get_by_path(obj: Any, path: str) -> Any:
    """
    按点分路径取值,支持数组索引。

    例如:
        data.datas.0.title

    缺失时安全返回 None。
    """
    cur = obj

    for part in path.split("."):
        if cur is None:
            return None
        if isinstance(cur, dict):
            cur = cur.get(part)
        elif isinstance(cur, list) and part.isdigit():
            index = int(part)
            cur = cur[index] if 0 <= index < len(cur) else None
        else:
            return None
    return cur


def assert_wan_response(
    resp,
    expected_status: int = 200,
    expected_error_code: int = 0,
    data_keys: list[str] | None = None,
    path_equals: dict[str, Any] | None = None,
) -> dict:
    """
    针对 WanAndroid 统一响应结构进行断言。
    默认成功场景:
        errorCode == 0
    如果测试预期接口返回业务错误,可以指定:
        expected_error_code=-1001
    例如:
        assert_wan_response(response)
        # 预期登录过期
        assert_wan_response(
            response,
            expected_error_code=-1001,
        )
        # 预期其他业务错误
        assert_wan_response(
            response,
            expected_error_code=-1,
        )
    参数:
        resp:
            httpx.Response
        expected_status:
            预期 HTTP 状态码,默认 200
        expected_error_code:
            预期业务 errorCode,默认 0
        data_keys:
            要求响应中存在的 key,支持递归查找
        path_equals:
            按点分路径进行等值断言,例如:
            {
                "data.datas.0.title": "首页文章"
            }
    """

    # 1. HTTP 状态码
    assert resp.status_code == expected_status, (
        f"HTTP status mismatch: "
        f"expected {expected_status}, "
        f"got {resp.status_code}. "
        f"body: {resp.text[:500]}"
    )

    # 2. 获取响应 JSON
    body = resp.json()

    # 3. 基础字段检查
    assert "errorCode" in body, (
        f"Missing errorCode in response: {body}"
    )
    assert "errorMsg" in body, (
        f"Missing errorMsg in response: {body}"
    )

    # 4. errorCode 断言
    actual_error_code = body["errorCode"]
    assert actual_error_code == expected_error_code, (
        f"errorCode mismatch: "
        f"expected {expected_error_code}, "
        f"got {actual_error_code}. "
        f"errorMsg: {body.get('errorMsg')}"
    )

    # 5. 检查指定 key 是否存在
    if data_keys:
        for key in data_keys:
            assert key_exists_anywhere(body, key), (
                f"Expected key '{key}' not found "
                f"anywhere in response body"
            )

    # 6. 检查指定路径的值
    if path_equals:
        for path, expected in path_equals.items():
            actual = get_by_path(body, path)
            assert actual == expected, (
                f"path '{path}' mismatch: "
                f"expected {expected!r}, "
                f"got {actual!r}"
            )
    return body

(7) run.py


import subprocess
import sys

import pytest

from auto_tests.utils.logger import get_logger
from auto_tests.report_util import allure
from auto_tests.report_util.feishu import send_feishu_notification


logger = get_logger("run")

# 1. 初始化报告目录
allure.ensure_report_dirs()

# 2. 清理上一次 Allure 测试结果
allure.clean_allure_results()

# 3. 写入 Allure 元数据
allure.write_environment_properties()
allure.write_executor_json()
allure.write_categories_json()

# 4. 执行 pytest
pytest_args = [
    "--alluredir",
    str(allure.ALLURE_RESULT_DIR),
]

logger.info(
    "Running pytest with args: %s",
    pytest_args,
)

pytest_exit_code = pytest.main(pytest_args)

# 5. 生成 Allure HTML 报告
command = [
    "allure",
    "generate",
    str(allure.ALLURE_RESULT_DIR),
    "-o",
    str(allure.ALLURE_HTML_DIR),
    "--clean",
]

logger.info(
    "Generating Allure report: %s",
    " ".join(command),
)

subprocess.run(
    command,
    check=True,
)

# 6. 发送飞书通知
message = (
    "自动化测试执行完成\n"
    f"Pytest Exit Code: {pytest_exit_code}\n"
    f"Allure Report: {allure.ALLURE_HTML_DIR}"
)
feishu_success, feishu_message = send_feishu_notification(message)
if feishu_success:
    logger.info(
        "Feishu notification result: %s",
        feishu_message,
    )
else:
    logger.warning(
        "Feishu notification result: %s",
        feishu_message,
    )

# 7. 保留 pytest 原始退出码
sys.exit(pytest_exit_code)