fix(exchange,case_validator),refactor(),feat(model): 解决 Pydantic 模型初始化与变量占位符的类型冲突,优化变量替换逻辑,重构 CaseInfo 模型并引入延迟校验机制

- 引入 SmartInt 和 SmartDict 类型,支持 YAML 占位符与业务类型的自动转换。
- 优化 CaseInfo 互斥校验逻辑,确保 request 与 api_action 二选一。
- 统一使用 Pydantic V2 的 model_config 规范。
- 将变量替换时机提前至模型实例化之前,支持占位符在校验前完成真实值注入,
保证了 int/bool 等字段的类型转换正确性。
- 优化断言渲染时机,支持响应提取值关联。
This commit is contained in:
2026-03-11 10:29:16 +08:00
parent 69a96a0060
commit 293b5160fe
39 changed files with 1359 additions and 1031 deletions

View File

@@ -12,14 +12,16 @@
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
from core.exchange import Exchange
from pydantic import ValidationError
from commons.file_processors.yaml_processor import YamlProcessor as FileHandle,YamlLoadError
from core.models import CaseInfo # 导入之前定义的 Pydantic 模型
from typing import Any, List, Type, Generator, Union
logger = logging.getLogger(__name__)
@@ -39,29 +41,107 @@ class TestTemplateBase:
class CaseDataLoader:
"""
职责 1: 数据加载器
负责与底层存储YAML文件打交道输出标准化的原始数据对象
测试用例加载器
职责:扫描文件系统 -> 载入 YAML -> 拆解参数化 -> 封装为 CaseInfo 模型
"""
@staticmethod
def fetch_yaml_cases(cases_dir: str) -> Generator[Tuple[Path, Dict], None, None]:
def fetch_yaml_files(cases_dir: str) -> Generator[Path, None, None]:
"""扫描目录并迭代返回 (文件路径, 原始内容)"""
yaml_files = Path(cases_dir).glob("**/test_*.yaml")
for file_path in yaml_files:
yield file_path, FileHandle(file_path)
base_path = Path(cases_dir)
if not base_path.exists():
logger.error(f"📂 测试目录不存在: {base_path}")
return
# 匹配所有以 test_ 开头的 yaml 文件
yield from base_path.rglob("test_*.yaml")
@classmethod
def load_cases(cls, file_path: Path) -> List[CaseInfo]:
"""
加载单个 YAML 文件并转化为 CaseInfo 列表
包含参数化数据的自动拆解逻辑
"""
cases = []
try:
# 1. 使用重构后的 YamlProcessor 加载原始字典
processor = FileHandle(file_path)
raw_data = processor.load()
if not raw_data:
return []
# 2. 检查是否存在参数化字段
if "parametrize" in raw_data and isinstance(raw_data["parametrize"], list):
cases.extend(cls._parse_parametrize(raw_data))
else:
# 3. 普通单条用例封装
cases.append(CaseInfo(**raw_data))
except YamlLoadError:
# YamlProcessor 已经记录了 error 日志,这里直接跳过
pass
except ValidationError as e:
logger.error(f"用例格式校验失败 [{file_path.name}]:\n{e.json()}")
except Exception as e:
logger.error(f"加载用例发生未知异常 [{file_path.name}]: {e}")
return cases
@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
def _parse_parametrize(raw_data: dict[str, Any]) -> List[CaseInfo]:
"""
解析参数化逻辑:将 raw_data 中的 parametrize 展开为多个 CaseInfo 实例
"""
param_content = raw_data.pop("parametrize")
if len(param_content) < 2:
logger.warning(f"参数化数据不足(需包含 Header 和至少一行 Data: {raw_data.get('title')}")
return [CaseInfo(**raw_data)]
# 第一行作为变量名 (Headers),后续作为数据行
headers = param_content[0]
data_rows = param_content[1:]
case_list = []
for row in data_rows:
# 将变量名和对应行数据打包成字典,例如 {"username": "user1", "title": "测试1"}
row_map = dict(zip(headers, row))
# 深拷贝原始模板,避免多行数据互相干扰
case_tmp = raw_data.copy()
# 关键优化:如果参数化里包含 'title',自动更新顶层的 title 字段
# 注意:此处仅做初步合并,更复杂的 ${var} 替换由 WorkflowExecutor 的 Exchanger 完成
if "title" in row_map:
case_tmp["title"] = row_map["title"]
# 将当前行的数据注入到 CaseInfo 中(此处可以暂存在字段中,或由执行器处理)
# 为了保持模型兼容,我们把 row_map 的信息合入 case_tmp
case_list.append(CaseInfo(**case_tmp))
return case_list
@classmethod
def get_all_cases(cls, cases_dir: Union[str, Path]) -> List[CaseInfo]:
"""
全量获取接口:供 CaseGenerator 调用
"""
all_cases = []
for file in cls.fetch_yaml_files(cases_dir):
all_cases.extend(cls.load_cases(file))
return all_cases
# @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:
@@ -73,43 +153,56 @@ class CaseGenerator:
@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)
all_cases=CaseDataLoader.get_all_cases(cases_dir)
for index, case_info in enumerate(all_cases):
dynamic_test_method=cls._create_case_method(case_info)
# for file_path, raw_data in CaseDataLoader.get_all_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}"
method_name = f"test_case_{index}_{case_info.title[:20]}"
safe_name = "".join([c if c.isalnum() else "_" for c in method_name])
# setattr(target_cls, method_name, dynamic_test_method)
setattr(target_cls, safe_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]):
# def _create_case_method(case_template: Dict, fields: List[str], values: List[Any], ids: List[str]):
def _create_case_method(case_template: CaseInfo):
"""封装具体的 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)
# epic = case_template.get("epic", settings.allure_epic)
# feature = case_template.get("feature", settings.allure_feature)
# story = case_template.get("story", settings.allure_story)
epic = case_template.epic or settings.allure_epic
feature = case_template.feature or settings.allure_feature
story = case_template.story or 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]):
# @pytest.mark.parametrize("case_args", values, ids=ids)
# def build_actual_case(instance: TestTemplateBase, case_args: List[Any]):
def build_actual_case(instance: TestTemplateBase):
# 数据组装
current_params = dict(zip(fields, case_args))
case_exec_data = {**case_template, **current_params}
case_title = current_params.get("title", "未命名用例")
# current_params = dict(zip(fields, case_args))
# case_exec_data = {**case_template, **current_params}
# case_title = current_params.get("title", "未命名用例")
case_title = case_template.title or "未命名用例"
# 日志记录 (利用 instance 标注来源)
logger.info(f"🚀 [Runner] Class: {instance.__class__.__name__} | Case: {case_title}")
# 执行与断言
allure.dynamic.title(case_title)
executor.perform(case_exec_data)
# executor.perform(case_exec_data)
executor.perform(case_template)
# 手动链路装饰 (Allure)
# run_actual_case = allure.epic(epic)(run_actual_case)
@@ -120,5 +213,8 @@ class CaseGenerator:
if __name__ == '__main__':
from settings import TEST_CASE_DIR
print(CaseDataLoader.get_all_cases(TEST_CASE_DIR))
# --- 引导执行 ---
CaseGenerator.build_and_register(TestTemplateBase, settings.TEST_CASE_DIR)
# CaseGenerator.build_and_register(TestTemplateBase, settings.TEST_CASE_DIR)