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:
15
core/base_api.py
Normal file
15
core/base_api.py
Normal 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}")
|
||||
180
core/creator.py
180
core/creator.py
@@ -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
189
core/exchange.py
Normal 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 场景全部验证通过!")
|
||||
116
core/executor.py
116
core/executor.py
@@ -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
176
core/models.py
Normal 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"}
|
||||
]
|
||||
}
|
||||
|
||||
# 模拟数据 2:PO 模式 (反射调用)
|
||||
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}")
|
||||
@@ -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
180
core/templates.py
Normal 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)}")
|
||||
Reference in New Issue
Block a user