refactor(): 重构动态用例生成逻辑并解耦核心组件

- 将 `CaseGenerator` 拆分为 `CaseDataLoader`(数据加载)和 `CaseGenerator`(用例构造),实现单一职责原则。
- 引入 `TestTemplateBase` 作为纯净的方法挂载容器,避免逻辑代码污染测试用例。
- 优化 YAML 解析流程,将文件扫描、参数化解析与 pytest 方法构建逻辑完全分离。
- 改进装饰器写法,使用更直观的 @ 语法糖处理 Allure 和 pytest.mark.parametrize。
- 增强执行日志,通过类型注解和实例引用记录更详细的运行上下文。
This commit is contained in:
2026-03-06 15:06:54 +08:00
parent 300b5a92d4
commit 69a96a0060
8 changed files with 258 additions and 34 deletions

124
core/creator.py Normal file
View File

@@ -0,0 +1,124 @@
#!/usr/bin/env python
# coding=utf-8
"""
@author: CNWei,ChenWei
@Software: PyCharm
@contact: t6g888@163.com
@file: creator
@date: 2026/3/6 10:40
@desc:
"""
import logging
import allure
import pytest
from pathlib import Path
from core import settings
from core.executor import WorkflowExecutor
from core.session import Session
from commons.exchange import Exchange
from commons.file_processors.yaml_processor import YamlProcessor as FileHandle
from typing import Any, Dict, List, Type, Generator, Tuple
logger = logging.getLogger(__name__)
# 初始化全局组件
session = Session(settings.base_url)
exchanger = Exchange(settings.DATA_DIR / "extract.yaml") # 指向 data/extract.yaml
executor = WorkflowExecutor(session, exchanger)
class TestTemplateBase:
"""
具体的测试用例容器。
此映射类不包含任何逻辑方法,仅用于承载由 Loader 挂载的 test_* 方法。
"""
pass
class CaseDataLoader:
"""
职责 1: 数据加载器
负责与底层存储YAML文件打交道输出标准化的原始数据对象
"""
@staticmethod
def fetch_yaml_cases(cases_dir: str) -> Generator[Tuple[Path, Dict], None, None]:
"""扫描目录并迭代返回 (文件路径, 原始内容)"""
yaml_files = Path(cases_dir).glob("**/test_*.yaml")
for file_path in yaml_files:
yield file_path, FileHandle(file_path)
@staticmethod
def parse_parametrize(raw_data: Dict, default_name: str) -> Tuple[List[str], List[List[Any]], List[str]]:
"""解析参数化结构,返回 (字段名列表, 数据值列表, ID列表)"""
if "parametrize" in raw_data:
fields = raw_data["parametrize"][0]
values = raw_data["parametrize"][1:]
ids = [f"{v[0]}" for v in values]
else:
fields = ["case_data"]
values = [[raw_data]]
ids = [raw_data.get("title", default_name)]
return fields, values, ids
class CaseGenerator:
"""
职责 2: 用例构造工厂
负责将数据转化为 pytest 装饰的方法,并挂载到目标类
"""
@classmethod
def build_and_register(cls, target_cls: Type[TestTemplateBase], cases_dir: str):
# 1. 通过 Loader 获取数据
for file_path, raw_data in CaseDataLoader.fetch_yaml_cases(cases_dir):
# 2. 解析参数化信息
fields, values, ids = CaseDataLoader.parse_parametrize(raw_data, file_path.stem)
# 3. 生成执行函数 (闭包)
dynamic_test_method = cls._create_case_method(raw_data, fields, values, ids)
# 4. 挂载
method_name = f"test_{file_path.stem}"
setattr(target_cls, method_name, dynamic_test_method)
logger.debug(f"Successfully registered: {method_name}")
@staticmethod
def _create_case_method(case_template: Dict, fields: List[str], values: List[Any], ids: List[str]):
"""封装具体的 pytest 执行节点"""
# 预取 Allure 层级信息
epic = case_template.get("epic", settings.allure_epic)
feature = case_template.get("feature", settings.allure_feature)
story = case_template.get("story", settings.allure_story)
@allure.epic(epic)
@allure.feature(feature)
@allure.story(story)
@pytest.mark.parametrize("case_args", values, ids=ids)
def build_actual_case(instance: TestTemplateBase, case_args: List[Any]):
# 数据组装
current_params = dict(zip(fields, case_args))
case_exec_data = {**case_template, **current_params}
case_title = current_params.get("title", "未命名用例")
# 日志记录 (利用 instance 标注来源)
logger.info(f"🚀 [Runner] Class: {instance.__class__.__name__} | Case: {case_title}")
# 执行与断言
allure.dynamic.title(case_title)
executor.perform(case_exec_data)
# 手动链路装饰 (Allure)
# run_actual_case = allure.epic(epic)(run_actual_case)
# run_actual_case = allure.feature(feature)(run_actual_case)
# run_actual_case = allure.story(story)(run_actual_case)
return build_actual_case
if __name__ == '__main__':
# --- 引导执行 ---
CaseGenerator.build_and_register(TestTemplateBase, settings.TEST_CASE_DIR)

75
core/executor.py Normal file
View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python
# coding=utf-8
"""
@desc: 核心测试用例执行引擎
"""
import logging
import importlib
from typing import Any
from commons.models.case_model import CaseInfo
from core.session import Session
from commons.exchange import Exchange
from commons.asserts import Asserts
logger = logging.getLogger(__name__)
class WorkflowExecutor:
def __init__(self, session: Session, exchanger: Exchange):
self.session = session
self.exchanger = exchanger
def perform(self, case_data: dict) -> Any:
"""执行单个用例支持直接请求和PO模式调用"""
# 1. 变量替换(将 ${var} 替换为真实值)
rendered_case = self.exchanger.replace(case_data)
# 2. 决定执行模式
if "api_action" in rendered_case:
# --- PO 模式:反射调用业务层 ---
action = rendered_case["api_action"]
resp = self._execute_po_method(
class_name=action["class"],
method_name=action["method"],
params=action.get("params", {})
)
else:
# --- 数据驱动模式:直接发送请求 ---
# 使用 Pydantic 校验 request 结构
case_info = CaseInfo(**rendered_case)
request_info = case_info.request.model_dump(by_alias=True, exclude_none=True)
resp = self.session.request(**request_info)
# 3. 提取变量 (接口关联)
if rendered_case.get("extract"):
for var_name, extract_info in rendered_case["extract"].items():
self.exchanger.extract(resp, var_name, *extract_info)
# 4. 断言校验
if rendered_case.get("validate"):
Asserts.validate(resp, rendered_case["validate"])
return resp
def _execute_po_method(self, class_name: str, method_name: str, params: dict):
"""核心反射逻辑:根据字符串动态加载 api/ 目录下的类并执行方法"""
try:
# 1. 动态导入模块(假设都在 api 目录下)
# 例如 class_name 是 UserAPI则尝试从 api.user 导入
# 这里简单处理,你可以根据你的文件名约定进一步优化逻辑
module_name = f"api.{class_name.lower().replace('api', '')}"
module = importlib.import_module(module_name)
# 2. 获取类并实例化
cls = getattr(module, class_name)
api_instance = cls(self.session) # 传入 session 保持会话统一
# 3. 调用方法并返回结果
method = getattr(api_instance, method_name)
logger.info(f"🚀 调用业务层: {class_name}.{method_name} 参数: {params}")
return method(**params)
except Exception as e:
logger.error(f"反射调用失败: {class_name}.{method_name} -> {e}")
raise

53
core/session.py Normal file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python
# coding=utf-8
"""
@author: chen wei
@Software: PyCharm
@contact: t6i888@163.com
@file: session.py
@date: 2024 2024/9/12 21:56
@desc:
"""
import logging
from urllib.parse import urljoin
import requests
from requests import Response, PreparedRequest
import allure
# logger = logging.getLogger("requests.session")
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO)
class Session(requests.Session):
def __init__(self, base_url=None):
super().__init__() # 先执行父类的初始化
self.base_url = base_url # 在执行子类的初始化操作
@allure.step("发送请求")
def request(self, method, url: str, *args, **kwargs) -> Response:
if not url.startswith("http"):
# 自动添加baseurl
url = urljoin(self.base_url, url)
return super().request(method, url, *args, **kwargs) # 按照原有方式执行
def send(self, request: PreparedRequest, *args, **kwargs) -> Response:
logger.info(f"发送请求>>>>>> 接口地址 = {request.method} {request.url}")
logger.info(f"发送请求>>>>>> 请求头 = {request.headers}")
logger.info(f"发送请求>>>>>> 请求正文 = {request.body} ")
resp = super().send(request, **kwargs) # 按照原有方式发送请求
logger.info(f"接收响应 <<<<<< 状态码 = {resp.status_code}")
logger.info(f"接收响应 <<<<<< 响应头 = {resp.headers}")
logger.info(f"接收响应 <<<<<< 响应正文 = {resp.text}")
# logger.info(f"接收响应 <<<<<< 响应正文 = {resp.json()}")
return resp
if __name__ == '__main__':
...

52
core/settings.py Normal file
View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python
# coding=utf-8
"""
@author: CNWei
@Software: PyCharm
@contact: t6i888@163.com
@file: settings
@date: 2025/2/23 21:34
@desc:
"""
import os
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = (Path(__file__)).resolve().parents[1]
load_dotenv()
# --- 目录配置 ---
TEST_CASE_DIR = BASE_DIR / "test_cases"
OUTPUT_DIR = BASE_DIR / "outputs"
SCREENSHOT_DIR = OUTPUT_DIR / "screenshots"
LOG_DIR = OUTPUT_DIR / "logs"
LOG_BACKUP_DIR = LOG_DIR / "backups"
ALLURE_TEMP = BASE_DIR / "temp"
REPORT_DIR = BASE_DIR / "reports"
CONFIG_DIR = BASE_DIR / "config"
DATA_DIR = BASE_DIR / "data"
test_suffix = "yaml"
base_url = os.getenv("BASE_URL")
db_host = os.getenv("DB_HOST") # ip
db_port = os.getenv("DB_PORT") # 端口
db_user = os.getenv("DB_USER") # 用户名
db_password = os.getenv("DB_PASSWORD") # 密码
db_database = os.getenv("DB_DATABASE")
allure_epic: str = "项目名称answer"
allure_feature: str = "默认特征feature"
allure_story: str = "默认事件story"
rsa_public = ""
rsa_private = ""
if __name__ == '__main__':
print(BASE_DIR)
print(BASE_DIR, db_host, db_port, db_user, db_password, db_database)