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

15
core/base_api.py Normal file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python
# coding=utf-8
import logging
from core.session import Session
from core import settings
class BaseApi:
def __init__(self, session: Session = None):
self.session = session or Session(base_url=settings.base_url)
self.logger = logging.getLogger(self.__class__.__name__)
def _log_action(self, method_name: str, **kwargs):
"""统一的动作日志记录"""
self.logger.info(f"执行动作: {method_name} | 参数: {kwargs}")

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)

189
core/exchange.py Normal file
View File

@@ -0,0 +1,189 @@
#!/usr/bin/env python
# coding=utf-8
"""
@desc: 变量交换器,用于数据替换和提取
"""
import logging
import re
from typing import Any, Union, TypeVar
import jsonpath
from lxml import etree
from core.models import CaseInfo
from core.settings import EXTRACT_CACHE
from core.templates import Template
from commons.file_processors.yaml_processor import YamlProcessor
logger = logging.getLogger(__name__)
# 定义泛型,用于保持返回类型一致
T = TypeVar("T", bound=Union[dict, list, str, Any])
class Exchange:
def __init__(self, cache_path: str):
self.cache_path = cache_path
self.file_handler = YamlProcessor(filepath=self.cache_path)
# 1. 增加内存缓存,避免频繁磁盘 I/O
self._variable_cache = self.file_handler.load() or {}
# 匹配标准变量 ${var},排除函数调用 ${func()}
# self.var_only_pattern = re.compile(r"\$\{([a-zA-Z_]\w*)}")
self.var_only_pattern = re.compile(r"^\$\{([a-zA-Z_]\w*)}$")
def extract(self, resp, var_name: str, attr: str, expr: str, index: int = 0):
"""
从响应中提取数据并更新到缓存及文件
:param resp: Response 对象
:param var_name: 变量名
:param attr: 属性名 (json, text, headers 等)
:param expr: 提取表达式 ($.jsonpath, //xpath, regex)
:param index: 索引
"""
try:
# 兼容处理 resp.json
target_data = getattr(resp, attr, None)
if attr == "json":
try:
target_data = resp.json()
except Exception:
target_data = {"msg": "not json data"}
if target_data is None:
logger.warning(f"提取失败: 响应对象中不存在属性 '{attr}'")
return
value = None
if expr.startswith("$"): # JSONPath
res = jsonpath.jsonpath(target_data, expr)
if res: value = res[index]
elif expr.startswith("/") or expr.startswith("./"): # XPath 模式
# 将文本解析为 HTML 树
html_content = resp.text
tree = etree.HTML(html_content)
res = tree.xpath(expr)
if res:
# 获取节点文本或属性值
target_node = res[index]
value = target_node.text if hasattr(target_node, 'text') else str(target_node)
else: # 正则
res = re.findall(expr, str(target_data))
if res: value = res[index]
if value is None:
logger.warning(f"变量 [{var_name}] 未通过表达式 [{expr}] 提取到数据")
value = "not data"
self._variable_cache[var_name] = value
self.file_handler.save(self._variable_cache)
logger.info(f"变量提取成功: {var_name} -> {value} (Type: {type(value).__name__})")
except Exception as e:
logger.error(f"提取变量 [{var_name}] 过程中发生异常: {e}", exc_info=True)
def _smart_replace(self, content: Any) -> Any:
"""
递归替换逻辑:
- 如果是纯变量占位符 ${token},则返回变量在缓存中的原始类型 (int, dict, list 等)
- 如果是混合字符串或函数调用,则调用 Template 渲染为字符串
"""
if isinstance(content, dict):
return {k: self._smart_replace(v) for k, v in content.items()}
elif isinstance(content, list):
return [self._smart_replace(i) for i in content]
elif isinstance(content, str):
# A. 场景:纯变量(为了保持类型,不走 Template 渲染成字符串)
# 例子content = "${order_id}",如果 order_id 是 int 123则返回 123
full_match = self.var_only_pattern.fullmatch(content)
if full_match:
var_name = full_match.group(1)
return self._variable_cache.get(var_name, content)
# B. 场景:混合文本或函数调用
# 例子:"Bearer ${token}" 或 "${gen_phone()}"
if "${" in content:
# 调用你提供的 Template 类
return Template(content).render(self._variable_cache)
return content
def replace(self, data: T) -> T:
"""
通用的变量替换入口
支持输入 dict, list, str 或 Pydantic Model (需先 dump)
"""
if not data:
return data
logger.debug(f"开始变量替换,原始数据类型: {type(data).__name__}")
rendered_data = self._smart_replace(data)
return rendered_data
if __name__ == "__main__":
from core.models import CaseInfo, RequestModel
# 模拟外部写入一个初始变量
with open(EXTRACT_CACHE, "w") as f:
f.write("existing_var: '100'\n")
ex = Exchange(EXTRACT_CACHE)
# --- 场景 1: 变量提取验证 ---
class MockResponse:
def __init__(self):
self.json_data = {"data": {"token": "auth_123", "user_id": 888}}
self.text = "<html><body><div id='name'>ChenWei</div></body></html>"
def json(self): return self.json_data
mock_resp = MockResponse()
print(">>> 执行提取...")
ex.extract(mock_resp, "token", "json", "$.data.token")
ex.extract(mock_resp, "u_id", "json", "$.data.user_id")
ex.extract(mock_resp, "user_name", "text", "//div[@id='name']")
# --- 场景 2: 变量替换与类型保持 ---
# 定义一个复杂的 CaseInfo
raw_case = {
"title": "测试用例",
"request": {
"method": "POST",
"url": "http://api.com/${token}", # 混合文本 -> 应转为 str
"json_body": {
"id": "${u_id}", # 纯变量 -> 应保持 int
"name": "${user_name}", # 纯变量 -> str
"config": "${existing_var}" # 初始文件变量 -> int
},
"timeout": "${existing_var}" # 字符串形式的数字 -> Pydantic 应转回 int
}
}
print("\n>>> 执行替换...")
new_case = ex.replace(raw_case)
print(new_case)
new_case = CaseInfo(**new_case)
# --- 校验结果 ---
print("\n--- 验证结果 ---")
print(f"URL (混合文本): {new_case.request.url} | 类型: {type(new_case.request.url)}")
print(f"ID (类型保持): {new_case.request.json_body['id']} | 类型: {type(new_case.request.json_body['id'])}")
print(f"Timeout (自动转换): {new_case.request.timeout} | 类型: {type(new_case.request.timeout)}")
assert isinstance(new_case.request.json_body['id'], int)
assert new_case.request.url == "http://api.com/auth_123"
assert new_case.request.timeout == 100
# if os.path.exists(cache_path): os.remove(cache_path)
print("\nExchange 场景全部验证通过!")

View File

@@ -7,59 +7,82 @@
import logging
import importlib
from typing import Any
from commons.models.case_model import CaseInfo
from typing import Any, List
from pydantic import TypeAdapter
from core import settings
from core.models import CaseInfo, ValidateItem, RequestModel, ApiActionModel
from core.session import Session
from commons.exchange import Exchange
from commons.asserts import Asserts
from core.exchange import Exchange
from utils.case_validator import CaseValidator
logger = logging.getLogger(__name__)
# 定义一个复用的适配器(减少初始化开销)
VALIDATE_LIST_ADAPTER = TypeAdapter(List[ValidateItem])
class WorkflowExecutor:
def __init__(self, session: Session, exchanger: Exchange):
self.session = session
self.exchanger = exchanger
def perform(self, case_data: dict) -> Any:
def perform(self, case_info: CaseInfo) -> Any:
"""执行单个用例支持直接请求和PO模式调用"""
# 1. 变量替换(将 ${var} 替换为真实值)
rendered_case = self.exchanger.replace(case_data)
try:
# raw_data = case_info.model_dump(by_alias=True, exclude_none=True)
# 1. 变量替换(将 ${var} 替换为真实值)
# rendered_dict = self.exchanger.replace(raw_data)
# rendered_case = CaseInfo.model_validate(rendered_dict)
# --- 2. 决定执行模式 ---
if case_info.is_po_mode():
# PO 模式:仅渲染 api_action
action_dict = case_info.api_action.model_dump(by_alias=True, exclude_none=True)
rendered_action_dict = self.exchanger.replace(action_dict)
# 重新校验以修复类型(如 params 里的 int
rendered_action = ApiActionModel.model_validate(rendered_action_dict)
# PO 模式:反射调用
# 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)
resp = self._execute_po_method(action=rendered_action)
else:
# 接口模式:直接请求
# 直接将 RequestModel 转为字典传给 session.request
request_kwargs = case_info.request.model_dump(by_alias=True, exclude_none=True)
rendered_req_dict = self.exchanger.replace(request_kwargs)
rendered_request = RequestModel.model_validate(rendered_req_dict)
# 3. 提取变量 (接口关联)
if rendered_case.get("extract"):
for var_name, extract_info in rendered_case["extract"].items():
self.exchanger.extract(resp, var_name, *extract_info)
request_kwargs = rendered_request.model_dump(by_alias=True, exclude_none=True)
resp = self.session.request(**request_kwargs)
# 4. 断言校验
if rendered_case.get("validate"):
Asserts.validate(resp, rendered_case["validate"])
# --- 3. 后置处理 (提取 & 断言) ---
self._post_process(resp, case_info)
return resp
return resp
except Exception as e:
logger.error(f"用例执行失败: {case_info.title} | 原因: {e}", exc_info=True)
raise
def _execute_po_method(self, class_name: str, method_name: str, params: dict):
def _execute_po_method(self, action: ApiActionModel):
"""核心反射逻辑:根据字符串动态加载 api/ 目录下的类并执行方法"""
class_name = action.api_class
method_name = action.method
params = action.params or {}
# 1. 确定模块路径:优先级策略
# 优先级 1: 显式映射 (API_MAP)
module_name = settings.API_MAP.get(class_name)
# 优先级 2: 规约命名 (UserAPI -> api.user_api)
if not module_name:
base_name = class_name.lower().replace('api', '')
module_name = f"{settings.API_PACKAGE}.{base_name}_api"
try:
# 1. 动态导入模块(假设都在 api 目录下)
# 例如 class_name 是 UserAPI则尝试从 api.user 导入
# 这里简单处理,你可以根据你的文件名约定进一步优化逻辑
module_name = f"api.{class_name.lower().replace('api', '')}"
# module_name = f"api.{class_name.lower().replace('api', '')}"
module = importlib.import_module(module_name)
# 2. 获取类并实例化
@@ -68,8 +91,33 @@ class WorkflowExecutor:
# 3. 调用方法并返回结果
method = getattr(api_instance, method_name)
logger.info(f"🚀 调用业务层: {class_name}.{method_name} 参数: {params}")
logger.info(f"调用业务层: {class_name}.{method_name} 参数: {params}")
return method(**params)
except ImportError as e:
logger.error(f"模块导入失败: 在 '{module_name}' 未找到对应文件。请检查文件名或 settings.API_MAP 配置。")
raise e
except AttributeError as e:
logger.error(f"成员获取失败: 模块 '{module_name}' 中不存在类或方法 '{class_name}.{method_name}'")
raise e
except Exception as e:
logger.error(f"反射调用失败: {class_name}.{method_name} -> {e}")
raise
raise
def _post_process(self, resp: Any, rendered_case: CaseInfo):
# 3. 提取变量 (接口关联)
if rendered_case.extract:
for var_name, extract_info in rendered_case.extract.items():
self.exchanger.extract(resp, var_name, *extract_info)
# 4. 断言校验
if rendered_case.validate_data:
# raw_validate_list = [i.model_dump(by_alias=True) for i in rendered_case.validate_data]
raw_validate_list = [
item.model_dump(by_alias=True) if isinstance(item, ValidateItem) else item
for item in rendered_case.validate_data
]
rendered_validate_list = self.exchanger.replace(raw_validate_list)
# 重新通过 Adapter 触发类型修复 (str -> int)
final_validate_data = VALIDATE_LIST_ADAPTER.validate_python(rendered_validate_list)
CaseValidator.validate(resp, final_validate_data)

176
core/models.py Normal file
View File

@@ -0,0 +1,176 @@
#!/usr/bin/env python
# coding=utf-8
"""
@author: chen wei
@Software: PyCharm
@contact: t6i888@163.com
@file: models.py
@date: 2024 2024/9/15 21:14
@desc: 声明yaml用例格式
"""
import logging
from typing import List, Any, Optional, Union, Annotated
import yaml
from pydantic import BaseModel, Field, ConfigDict, model_validator, field_validator, AfterValidator
from core import settings
logger = logging.getLogger(__name__)
def smart_cast_int(v: Any) -> Any:
if isinstance(v, str) and v.startswith("${") and v.endswith("}"):
return v
try:
return int(v)
except (ValueError, TypeError):
return v
def smart_cast_dict(v: Any) -> Any:
"""确保字典格式,若是占位符(字符串形式)则放行"""
if isinstance(v, str) and v.startswith("${") and v.endswith("}"):
return v
if isinstance(v, dict) or v is None:
return v
return v # 也可以根据需求抛出异常
# 使用 Annotated 定义带校验的类型
SmartInt = Annotated[Union[int, str], AfterValidator(smart_cast_int)]
SmartDict = Annotated[Union[dict[str, Any], str], AfterValidator(smart_cast_dict)]
# --- 基础请求模型 (用于第一种示例:直接请求) ---
class RequestModel(BaseModel):
method: str = Field(..., description="HTTP 请求方法: get, post, etc.")
url: str = Field(..., description="接口路径或完整 URL")
params: Optional[SmartDict] = None
data: Optional[SmartDict] = None
json_body: Optional[Any] = Field(None, alias="json")
headers: Optional[SmartDict] = None
cookies: Optional[dict[str, str]] = None
timeout: SmartInt = Field(default=10)
files: Optional[SmartDict] = None
model_config = ConfigDict(extra="allow", populate_by_name=True) # 允许扩展 requests 的其他参数
# --- PO 动作模型 (用于第二种示例:业务层反射调用) ---
class ApiActionModel(BaseModel):
api_class: str = Field(..., alias="class", description="要调用的 API 类名")
method: str = Field(..., description="类中的方法名")
params: Optional[SmartDict] = Field(default_factory=dict, description="传给方法的参数")
model_config = ConfigDict(populate_by_name=True)
# --- 新增:断言条目模型 ---
class ValidateItem(BaseModel):
check: Any = Field(..., description="要检查的字段或表达式")
expect: Any = Field(..., description="期望值")
assert_method: str = Field(default="equals", alias="assert", description="断言方法")
msg: Optional[str] = Field(default="Assertion", description="断言描述")
model_config = ConfigDict(populate_by_name=True)
# --- 核心用例数据模型 ---
class CaseInfo(BaseModel):
# 公共元数据
title: str = Field(..., description="用例标题")
epic: Optional[str] = None
feature: Optional[str] = None
story: Optional[str] = None
# 核心逻辑分叉:可以是 request 对象,也可以是 api_action 对象
# 根据 WorkflowExecutor 的逻辑,这里设为 Optional但在具体校验时可以互斥
request: Optional[RequestModel] = None
api_action: Optional[ApiActionModel] = None
# 后置处理
extract: Optional[dict[str, List[Any]]] = Field(
default=None,
description="变量提取表达式,格式: {变量名: [来源, 表达式, 索引]}"
)
validate_data: Optional[List[Union[ValidateItem, dict[str, Any]]]] = Field(
default_factory=list,
alias="validate",
description="断言信息"
)
# 参数化(在 DataLoader 阶段会被拆解,但在初始加载时需要定义)
parametrize: Optional[List[List[Any]]] = None
model_config = ConfigDict(
populate_by_name=True, # 无论是在代码中用 api_class 还是在 YAML 中用 class 赋值Pydantic 都能正确识别。
arbitrary_types_allowed=True # 允许在模型中使用非 Pydantic 标准类型(如自定义类实例)
)
# 核心优化:增加互斥校验
@model_validator(mode='after')
def check_action_type(self) -> 'CaseInfo':
if not self.request and not self.api_action:
raise ValueError("用例必须包含 'request''api_action' 其中之一")
if self.request and self.api_action:
raise ValueError("'request''api_action' 不能同时存在")
return self
def is_po_mode(self) -> bool:
"""判断是否为 PO 模式"""
return self.api_action is not None
if __name__ == '__main__':
# 模拟数据 1标准请求模式
raw_case_1 = {
"title": "查询状态信息",
"request": {
"method": "get",
"url": "/api/v1/info",
"headers": {"User-Agent": "pytest-ai"}
},
"validate": [
{"check": "status_code", "assert": "equals", "expect": 200, "msg": "响应码200"},
{"check": "$.msg", "expect": "Success"}
]
}
# 模拟数据 2PO 模式 (反射调用)
raw_case_2 = {
"title": "用户登录测试",
"api_action": {
"class": "UserAPI",
"method": "login",
"params": {"user": "admin", "pwd": "123"}
},
"extract": {
"token": ["json", "$.data.token", 0]
}
}
print("--- 开始模型校验测试 ---\n")
try:
# 验证模式 1
case1 = CaseInfo(**raw_case_1)
print(f"✅ 模式1 (Request) 校验通过: {case1.title}")
print(f" 请求URL: {case1.request.url}")
print(f" 第一个断言方法: {case1.validate_data[0].assert_method}\n")
# 验证模式 2
case2 = CaseInfo(**raw_case_2)
print(f"✅ 模式2 (PO Mode) 校验通过: {case2.title}")
print(f" 调用类: {case2.api_action.api_class}")
print(f" 提取规则数: {len(case2.extract)}\n")
# 验证非法数据(如:既没有 request 也没有 api_action 的情况可以在业务层进一步校验)
# 这里演示 Pydantic 自动类型转换
invalid_data = {"title": "错误用例", "request": {"url": "/api"}} # 缺少 method
print("--- 预期失败测试 ---")
CaseInfo(**invalid_data)
except Exception as e:
print(f"❌ 预期内的校验失败: \n{e}")

View File

@@ -21,6 +21,8 @@ load_dotenv()
# --- 目录配置 ---
TEST_CASE_DIR = BASE_DIR / "test_cases"
EXTRACT_CACHE = BASE_DIR / "data/extract.yaml"
OUTPUT_DIR = BASE_DIR / "outputs"
SCREENSHOT_DIR = OUTPUT_DIR / "screenshots"
LOG_DIR = OUTPUT_DIR / "logs"
@@ -31,19 +33,23 @@ REPORT_DIR = BASE_DIR / "reports"
CONFIG_DIR = BASE_DIR / "config"
DATA_DIR = BASE_DIR / "data"
test_suffix = "yaml"
# 核心 API 目录路径
API_PACKAGE = "api"
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")
# 可选:显式映射(类名 -> 完整模块路径),解决文件名不规则的问题
API_MAP = {
"UserAPI": "api.business.user",
"OrderAPI": "api.v2.order_manager"
}
allure_epic: str = "项目名称answer"
allure_feature: str = "默认特征feature"
allure_story: str = "默认事件story"
test_suffix = "yaml"
base_url = os.getenv("BASE_URL")
rsa_public = ""
rsa_private = ""

180
core/templates.py Normal file
View File

@@ -0,0 +1,180 @@
#!/usr/bin/env python
# coding=utf-8
"""
@author: chen wei
@Software: PyCharm
@contact: t6i888@163.com
@file: templates.py
@date: 2024 2024/9/22 22:20
@desc:
"""
import copy
import logging
import re
import string
import ast
from typing import List, Any
from commons.funcs import Funcs
logger = logging.getLogger(__name__)
class Template(string.Template):
"""
增强型模板引擎:
1. 兼容标准变量替换 ${var}
2. 支持带参数的函数调用 ${func(arg1, arg2)}
3. 支持变量嵌套作为函数参数 ${func(${var})}
"""
# call_pattern = re.compile(r"\${(?P<func_name>.*?)\((?P<func_args>.*?)\)}")
# call_pattern = re.compile(r"\$\{(?P<func_name>[a-zA-Z_]\w*)\((?P<func_args>.*)\)}")
# 匹配函数调用结构:${函数名(参数)}
# 分组func_name (字母下划线开头), func_args (括号内的所有内容)
call_pattern = re.compile(r"\$\{(?P<func_name>[a-zA-Z_]\w*)\((?P<func_args>.*)\)}")
def render(self, mapping: dict) -> str:
"""
渲染入口
:param mapping: 变量缓存(来自 Exchange._variable_cache
:return: 渲染后的字符串
"""
# 1. 第一步:利用原生 string.Template 替换基础变量
# 这一步会将参数中的 ${var} 预先替换为实际值,从而支持函数嵌套调用
s = self.safe_substitute(mapping) # 原有方法替换变量
# 2. 第二步:解析并执行函数调用
s = self.safe_substitute_funcs(s, mapping)
return s
@staticmethod
def _parse_args(args_str: str, mapping: dict) -> List[Any]:
"""
核心优化:安全拆分函数参数
利用正则预读,跳过引号内的逗号,解决 ${func('a,b', 123)} 的分割问题
"""
args_str = args_str.strip()
if not args_str:
return []
# 正则解析说明:匹配逗号,但该逗号后面必须有偶数个引号(说明逗号不在引号内)
raw_args = re.split(r',(?=(?:[^\'"]*[\'"][^\'"]*[\'"])*[^\'"]*$)', args_str)
processed_args = []
for arg in raw_args:
arg = arg.strip()
# 1. 处理带引号的字符串参数
if (arg.startswith("'") and arg.endswith("'")) or (arg.startswith('"') and arg.endswith('"')):
processed_args.append(arg[1:-1])
# 2. 处理数字类型
elif arg.isdigit():
processed_args.append(int(arg))
# 3. 处理布尔值
elif arg.lower() == "true":
processed_args.append(True)
elif arg.lower() == "false":
processed_args.append(False)
# 4. 如果在 mapping 中能找到(针对未经过第一步替换的情况),取其值
elif arg in mapping:
processed_args.append(mapping[arg])
# 5. 其他情况按原样字符串处理
else:
processed_args.append(arg)
return processed_args
def safe_substitute_funcs(self, template: str, mapping: dict) -> str:
"""
解析字符串中的函数名和参数,并将函数调用结果进行替换
:param template: 字符串
:param mapping: 上下文,提供要使用的函数和变量
:return: 替换后的结果
"""
# 合并函数映射和变量映射,作为统一上下文
# 使用解构赋值替代 deepcopy提升性能
logger.info(f"mapping更新前: {mapping}")
render_context = {**Funcs.FUNC_MAPPING, **mapping}
logger.info(f"mapping更新后: {render_context}")
# mapping = copy.deepcopy(mapping)
# logger.info(f"mapping更新前: {mapping}")
# mapping.update(self.FUNC_MAPPING) # 合并两个mapping
# mapping.update(Funcs.FUNC_MAPPING) # 合并两个mapping
# logger.info(f"mapping更新后: {mapping}")
def convert(mo):
func_name = mo.group("func_name")
# func_args = mo.group("func_args").split(",")
func_args_str = mo.group("func_args")
func = render_context.get(func_name) # 读取指定函数
if not callable(func):
logger.warning(f"模板中的函数 '{func_name}' 未定义或不可调用")
return mo.group()
# 解析参数列表
args = self._parse_args(func_args_str, render_context)
try:
# 执行函数并强制转为字符串返回,以便 re.sub 替换
result = func(*args)
return str(result)
except Exception as e:
logger.error(f"执行函数 ${{{func_name}(...)}} 报错: {e}", exc_info=True)
return mo.group()
return self.call_pattern.sub(convert, template)
if __name__ == '__main__':
# 模拟 Funcs.FUNC_MAPPING
def mock_concat(a, b):
return f"{a}_{b}"
def mock_get_now():
return "2026-03-09"
def mock_add(x, y):
return x + y
# 注入模拟函数
Funcs.FUNC_MAPPING = {
"concat": mock_concat,
"now": mock_get_now,
"add": mock_add
}
# 模拟变量缓存
test_mapping = {
"env": "prod",
"num1": 10,
"num2": 20
}
test_cases = [
("场景A标准变量", "Current env is ${env}", "Current env is prod"),
("场景B无参数函数", "Date: ${now()}", "Date: 2026-03-09"),
("场景C带参数函数(含逗号)", "Res: ${concat('hello,world', 'test')}", "Res: hello,world_test"),
("场景D变量嵌套函数参数", "Sum: ${add(${num1}, ${num2})}", "Sum: 30"),
("场景E混合模式", "URL: /${env}/api/${now()}", "URL: /prod/api/2026-03-09"),
("场景F参数类型自动识别", "Value: ${add(5, 5)}", "Value: 10"), # 5应该被识别为int
]
print(f"{'测试场景':<25} | {'预期结果':<30} | {'实际结果'}")
print("-" * 80)
for scene, tpl_str, expected in test_cases:
actual = Template(tpl_str).render(test_mapping)
status = "" if str(actual) == str(expected) else ""
print(f"{scene:<25} | {expected:<30} | {actual} {status}")
# 特殊验证:嵌套失败回退
print("\n>>> 验证未定义函数回退:")
error_tpl = "Check: ${undefined_func()}"
print(f"结果: {Template(error_tpl).render(test_mapping)}")