feat(executor): 重构用例加载与执行逻辑,支持参数化变量优先级

- 引入 CaseEntity 包装器,实现数据模型与执行上下文解耦。
 - 移除加载阶段的 deepcopy,优化大规模参数化用例的内存占用。
 - 实现 perform 阶段的局部变量注入,确保参数化数据优先级高于全局缓存。
This commit is contained in:
2026-03-11 17:11:19 +08:00
parent 293b5160fe
commit 2116016a0d
7 changed files with 201 additions and 69 deletions

View File

@@ -13,6 +13,7 @@ import logging
import allure import allure
from pathlib import Path from pathlib import Path
from dataclasses import dataclass
from core import settings from core import settings
from core.executor import WorkflowExecutor from core.executor import WorkflowExecutor
from core.session import Session from core.session import Session
@@ -30,6 +31,12 @@ session = Session(settings.base_url)
exchanger = Exchange(settings.DATA_DIR / "extract.yaml") # 指向 data/extract.yaml exchanger = Exchange(settings.DATA_DIR / "extract.yaml") # 指向 data/extract.yaml
executor = WorkflowExecutor(session, exchanger) executor = WorkflowExecutor(session, exchanger)
@dataclass
class CaseEntity:
"""用例执行实体:解耦模型数据与执行上下文"""
step_data: CaseInfo
row_context: dict[str, Any]
class TestTemplateBase: class TestTemplateBase:
""" """
@@ -56,12 +63,12 @@ class CaseDataLoader:
yield from base_path.rglob("test_*.yaml") yield from base_path.rglob("test_*.yaml")
@classmethod @classmethod
def load_cases(cls, file_path: Path) -> List[CaseInfo]: def load_cases(cls, file_path: Path) -> List[CaseEntity]:
""" """
加载单个 YAML 文件并转化为 CaseInfo 列表 加载单个 YAML 文件并转化为 CaseInfo 列表
包含参数化数据的自动拆解逻辑 包含参数化数据的自动拆解逻辑
""" """
cases = [] entities = []
try: try:
# 1. 使用重构后的 YamlProcessor 加载原始字典 # 1. 使用重构后的 YamlProcessor 加载原始字典
processor = FileHandle(file_path) processor = FileHandle(file_path)
@@ -69,13 +76,24 @@ class CaseDataLoader:
if not raw_data: if not raw_data:
return [] return []
# 1. 提取参数化数据
parametrize_data = raw_data.pop("parametrize", None)
# 2. 实例化唯一的模板对象 (Pydantic 校验)
# 此时占位符 ${var} 会被 SmartInt/SmartDict 校验器放行
template_case = CaseInfo(**raw_data)
# 2. 检查是否存在参数化字段 # 2. 检查是否存在参数化字段
if "parametrize" in raw_data and isinstance(raw_data["parametrize"], list): if parametrize_data and isinstance(parametrize_data, list) and len(parametrize_data) >= 2:
cases.extend(cls._parse_parametrize(raw_data)) # 3. 参数化拆分
headers = parametrize_data[0]
for row in parametrize_data[1:]:
row_map = dict(zip(headers, row))
# 包装为实体,存入引用而非副本
entities.append(CaseEntity(step_data=template_case, row_context=row_map))
else: else:
# 3. 普通单条用例封装 # 普通用例,上下文为空
cases.append(CaseInfo(**raw_data)) entities.append(CaseEntity(step_data=template_case, row_context={}))
except YamlLoadError: except YamlLoadError:
# YamlProcessor 已经记录了 error 日志,这里直接跳过 # YamlProcessor 已经记录了 error 日志,这里直接跳过
@@ -85,7 +103,7 @@ class CaseDataLoader:
except Exception as e: except Exception as e:
logger.error(f"加载用例发生未知异常 [{file_path.name}]: {e}") logger.error(f"加载用例发生未知异常 [{file_path.name}]: {e}")
return cases return entities
@staticmethod @staticmethod
def _parse_parametrize(raw_data: dict[str, Any]) -> List[CaseInfo]: def _parse_parametrize(raw_data: dict[str, Any]) -> List[CaseInfo]:
@@ -121,9 +139,10 @@ class CaseDataLoader:
return case_list return case_list
@classmethod @classmethod
def get_all_cases(cls, cases_dir: Union[str, Path]) -> List[CaseInfo]: def get_all_cases(cls, cases_dir: Union[str, Path]) -> List[CaseEntity]:
""" """
全量获取接口:供 CaseGenerator 调用 全量获取接口:供 CaseGenerator 调用 frank
""" """
all_cases = [] all_cases = []
for file in cls.fetch_yaml_files(cases_dir): for file in cls.fetch_yaml_files(cases_dir):
@@ -165,6 +184,8 @@ class CaseGenerator:
# #
# # 4. 挂载 # # 4. 挂载
# method_name = f"test_{file_path.stem}" # method_name = f"test_{file_path.stem}"
case_title = case_info.row_context.get("title") or case_info.step_data.title
method_name = f"test_case_{index}_{case_info.title[:20]}" method_name = f"test_case_{index}_{case_info.title[:20]}"
safe_name = "".join([c if c.isalnum() else "_" for c in method_name]) safe_name = "".join([c if c.isalnum() else "_" for c in method_name])
# setattr(target_cls, method_name, dynamic_test_method) # setattr(target_cls, method_name, dynamic_test_method)
@@ -173,8 +194,10 @@ class CaseGenerator:
@staticmethod @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): def _create_case_method(entity: CaseEntity):
"""封装具体的 pytest 执行节点""" """封装具体的 pytest 执行节点"""
case_template = entity.step_data
context = entity.row_context
# 预取 Allure 层级信息 # 预取 Allure 层级信息
# epic = case_template.get("epic", settings.allure_epic) # epic = case_template.get("epic", settings.allure_epic)
@@ -194,7 +217,7 @@ class CaseGenerator:
# current_params = dict(zip(fields, case_args)) # current_params = dict(zip(fields, case_args))
# case_exec_data = {**case_template, **current_params} # case_exec_data = {**case_template, **current_params}
# case_title = current_params.get("title", "未命名用例") # case_title = current_params.get("title", "未命名用例")
case_title = case_template.title or "未命名用例" case_title = context.get("title") or case_template.title or "未命名用例"
# 日志记录 (利用 instance 标注来源) # 日志记录 (利用 instance 标注来源)
logger.info(f"🚀 [Runner] Class: {instance.__class__.__name__} | Case: {case_title}") logger.info(f"🚀 [Runner] Class: {instance.__class__.__name__} | Case: {case_title}")
@@ -202,7 +225,7 @@ class CaseGenerator:
# 执行与断言 # 执行与断言
allure.dynamic.title(case_title) allure.dynamic.title(case_title)
# executor.perform(case_exec_data) # executor.perform(case_exec_data)
executor.perform(case_template) executor.perform(case_template,context=context)
# 手动链路装饰 (Allure) # 手动链路装饰 (Allure)
# run_actual_case = allure.epic(epic)(run_actual_case) # run_actual_case = allure.epic(epic)(run_actual_case)

View File

@@ -7,7 +7,7 @@
import logging import logging
import importlib import importlib
from typing import Any, List from typing import Any, List, Optional
from pydantic import TypeAdapter from pydantic import TypeAdapter
@@ -28,9 +28,20 @@ class WorkflowExecutor:
self.session = session self.session = session
self.exchanger = exchanger self.exchanger = exchanger
def perform(self, case_info: CaseInfo) -> Any: def perform(self, case_info: CaseInfo,context: Optional[dict[str, Any]] = None) -> Any:
"""执行单个用例支持直接请求和PO模式调用""" """执行单个用例支持直接请求和PO模式调用"""
context = context or {}
# 1. 局部变量优先级注入
# 备份全局缓存,将当前行数据合并进去
old_cache = self.exchanger._variable_cache.copy()
self.exchanger._variable_cache.update(context)
try: try:
# 2. 动态更新标题(如果 context 中包含 title
current_title = context.get("title") or case_info.title
logger.info(f"🚀 执行用例: {current_title}")
# raw_data = case_info.model_dump(by_alias=True, exclude_none=True) # raw_data = case_info.model_dump(by_alias=True, exclude_none=True)
# 1. 变量替换(将 ${var} 替换为真实值) # 1. 变量替换(将 ${var} 替换为真实值)
# rendered_dict = self.exchanger.replace(raw_data) # rendered_dict = self.exchanger.replace(raw_data)
@@ -62,6 +73,10 @@ class WorkflowExecutor:
except Exception as e: except Exception as e:
logger.error(f"用例执行失败: {case_info.title} | 原因: {e}", exc_info=True) logger.error(f"用例执行失败: {case_info.title} | 原因: {e}", exc_info=True)
raise raise
finally:
# 4. 关键:清理现场,还原全局变量池
self.exchanger._variable_cache = old_cache
def _execute_po_method(self, action: ApiActionModel): def _execute_po_method(self, action: ApiActionModel):
"""核心反射逻辑:根据字符串动态加载 api/ 目录下的类并执行方法""" """核心反射逻辑:根据字符串动态加载 api/ 目录下的类并执行方法"""

View File

@@ -21,7 +21,7 @@ load_dotenv()
# --- 目录配置 --- # --- 目录配置 ---
TEST_CASE_DIR = BASE_DIR / "test_cases" TEST_CASE_DIR = BASE_DIR / "test_cases"
EXTRACT_CACHE = BASE_DIR / "data/extract.yaml"
OUTPUT_DIR = BASE_DIR / "outputs" OUTPUT_DIR = BASE_DIR / "outputs"
SCREENSHOT_DIR = OUTPUT_DIR / "screenshots" SCREENSHOT_DIR = OUTPUT_DIR / "screenshots"
@@ -33,8 +33,14 @@ REPORT_DIR = BASE_DIR / "reports"
CONFIG_DIR = BASE_DIR / "config" CONFIG_DIR = BASE_DIR / "config"
DATA_DIR = BASE_DIR / "data" DATA_DIR = BASE_DIR / "data"
# 需要初始化的目录列表
REQUIRED_DIRS = [LOG_DIR, LOG_BACKUP_DIR, ALLURE_TEMP, SCREENSHOT_DIR]
# 核心 API 目录路径 # 核心 API 目录路径
API_PACKAGE = "api" API_PACKAGE = "api"
LOG_SOURCE = LOG_DIR / "pytest.log"
EXTRACT_CACHE = BASE_DIR / "data/extract.yaml"
# 可选:显式映射(类名 -> 完整模块路径),解决文件名不规则的问题 # 可选:显式映射(类名 -> 完整模块路径),解决文件名不规则的问题
API_MAP = { API_MAP = {
@@ -55,4 +61,4 @@ rsa_private = ""
if __name__ == '__main__': if __name__ == '__main__':
print(BASE_DIR) print(BASE_DIR)
print(BASE_DIR, db_host, db_port, db_user, db_password, db_database)

108
main.py
View File

@@ -1,22 +1,90 @@
import os import shutil
import datetime
from pathlib import Path
import pytest import pytest
if __name__ == '__main__': from core.settings import LOG_SOURCE, LOG_BACKUP_DIR, ALLURE_TEMP
# 定义报告和临时文件目录 # from core.enums import AppPlatform
reports_dir = "reports" from utils.dirs_manager import ensure_dirs_ok
temp_dir = os.path.join(reports_dir, "temp") from utils.report_handler import generate_allure_report
html_dir = os.path.join(reports_dir, "html")
# 1. 执行 Pytest 测试 # netstat -ano | findstr :4723
# -v: 输出详细信息 # taskkill /PID 12345 /F
# --alluredir: 指定 Allure 临时数据目录
# -c: 指定 pytest.ini 配置文件路径 def _archive_logs():
pytest.main([ """
"-v", 在测试开始前,归档上一次运行的日志文件。
"tests/", # 明确指定测试目录 此时没有任何句柄占用move 操作是 100% 安全的。
f"--alluredir={temp_dir}", """
"-c=config/pytest.ini" # 4. 备份日志 (无论测试是否崩溃都执行)
]) if LOG_SOURCE.exists() and LOG_SOURCE.stat().st_size > 0:
now = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
# 2. 生成 Allure HTML 报告 backup_path = LOG_BACKUP_DIR / f"pytest_{now}.log"
os.system(f'allure generate {temp_dir} -o {html_dir} --clean') try:
# 移动并重命名
shutil.move(str(LOG_SOURCE), str(backup_path))
print(f"已自动归档上次运行的日志: {backup_path}")
# shutil.copy2(LOG_SOURCE, backup_path)
# print(f"日志已备份至: {backup_path}")
_clean_old_logs(LOG_BACKUP_DIR)
except Exception as e:
print(f"归档旧日志失败 (可能被外部编辑器打开): {e}")
else:
print("未找到原始日志文件,跳过备份。")
# 日志清理
def _clean_old_logs(backup_dir, keep_count=10):
files = sorted(Path(backup_dir).glob("pytest_*.log"), key=lambda p: p.stat().st_mtime)
while len(files) > keep_count:
file_to_remove = files.pop(0)
try:
file_to_remove.unlink(missing_ok=True)
except OSError as e:
print(f"清理旧日志失败 {file_to_remove}: {e}")
def _clean_temp_dirs():
"""
可选:如果你想在测试前清理掉旧的临时文件
"""
if ALLURE_TEMP.exists():
shutil.rmtree(ALLURE_TEMP)
# 加上 ignore_errors 是为了防止文件被占用导致整个测试无法启动
shutil.rmtree(ALLURE_TEMP, ignore_errors=True)
ALLURE_TEMP.mkdir(parents=True, exist_ok=True)
def main():
try:
# 1. 创建目录
ensure_dirs_ok()
# 2. 处理日志
_archive_logs()
# 3. 执行 Pytest
args = [
"test_cases",
"-x", # 注意:-x 表示遇到错误立即停止,如果是全量回归建议去掉 -x
"-v",
f"--alluredir={ALLURE_TEMP}",
# f"--platform={AppPlatform.ANDROID.value}",
# "--caps_name=wan_android"
]
pytest.main(args)
# 4. 生成报告
generate_allure_report()
except Exception as e:
print(f"自动化测试执行过程中发生异常: {e}")
finally:
print("Time-of-check to Time-of-use")
if __name__ == "__main__":
main()

View File

@@ -1,11 +1,22 @@
[pytest] [pytest]
testpaths = tests addopts = -q --show-capture=no --reruns 2 --reruns-delay 1
addopts = -q --show-capture=no
# 1. 开启实时控制台日志
log_cli = True
log_cli_level = INFO
log_cli_format = %(asctime)s %(levelname)-5s [%(name)s] - %(message)s
log_cli_date_format = %H:%M:%S
log_file = logs/pytest.log # 2. 开启日志文件记录
log_file_level = debug log_file = outputs/logs/pytest.log
log_file_format = %(asctime)s [%(name)s] %(levelname)s %(module)s.%(funcName)s:%(lineno)d - %(message)s log_file_level = INFO
log_file_date_format = %m/%d/%Y %H:%M:%S %p log_file_format = %(asctime)s %(levelname)-5s [%(name)s] %(module)s.%(funcName)s:%(lineno)d - %(message)s
log_file_date_format = %Y-%m-%d %H:%M:%S
disable_test_id_escaping_and_forfeit_all_rights_to_community_support = true # 3. 基础配置
# 解决中文测试用例显示为乱码Unicode的问题
disable_test_id_escaping_and_forfeit_all_rights_to_community_support = True
# 限制 Pytest 搜索范围,提升启动速度
testpaths = test_cases
python_files = test_*.py

View File

@@ -12,6 +12,9 @@ request:
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0
Referer: http://119.91.19.171:40065/users/login Referer: http://119.91.19.171:40065/users/login
Accept-Encoding: gzip, deflate Accept-Encoding: gzip, deflate
json_body:
username: "${username}"
password: "${password}"
extract: # 提取变量 extract: # 提取变量
msg: msg:
- "json" - "json"
@@ -19,20 +22,21 @@ extract: # 提取变量
- 0 - 0
validate: validate:
- check: status_code # 检查的对象(或是变量名) - check: status_code
assert: equals # 断言方法 assert: ${status_assert} # <--- 动态断言方法
expect: 200 # 期望值 expect: ${status_expect} # <--- 动态期望值
msg: "校验接口状态码" # 描述(可选) msg: "校验接口状态码"
- check: $.msg - check: message
assert: contains assert: ${msg_assert} # <--- 动态断言方法
expect: "Success" expect: ${msg_expect} # <--- 动态期望值
msg: "检查返回消息" msg: "检查返回消息"
parametrize: # 数据驱动测试 parametrize: # 数据驱动测试
- [ "title","username","password","msg" ] # 变量名 # 定义列名,包括了断言方法和期望值
- [ "测试1","user1","pass1","200" ] # 变量值 - [ "title", "username", "password", "status_assert", "status_expect", "msg_assert", "msg_expect" ]
- [ "测试2","user2","pass2","300" ] # 变量值 # 定义每一行的数据,现在可以为每次运行指定不同的断言逻辑
- [ "测试3","user3","pass3","200" ] # 变量值 - [ "场景1: 成功-状态码相等-消息包含Success", "user1", "pass1", "equals", 200, "contains", "Success" ]
- [ "测试4","user4","pass4","200" ] # 变量值 - [ "场景2: 失败-状态码不相等-消息不包含Error", "user2", "pass2", "not_equals", 200, "not_contains", "Error" ]
- [ "测试5","user5","pass5","200" ] # 变量值 - [ "场景3: 成功-状态码大于199-消息相等", "user3", "pass3", "greater_than", 199, "equals", "Success" ]
- [ "场景4: 失败-状态码小于500-消息为空", "user4", "pass4", "less_than", 500, "is_empty", "" ]

View File

@@ -12,25 +12,30 @@ request:
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0 User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0
Referer: http://119.91.19.171:40065/users/login Referer: http://119.91.19.171:40065/users/login
Accept-Encoding: gzip, deflate Accept-Encoding: gzip, deflate
json_body: { username:${ username },password:${ password } }
extract: # 提取变量 extract: # 提取变量
msg: msg:
- "json" - "json"
- "$.msg" - "$.msg"
- 0 - 0
validate:
- check: status_code # 检查的对象(或是变量名)
assert: equals # 断言方法
expect: 200 # 期望值
msg: "校验接口状态码" # 描述(可选)
- check: $.msg validate:
assert: contains - check: status_code
expect: "Success" assert: ${status_assert} # <--- 动态断言方法
expect: ${status_expect} # <--- 动态期望值
msg: "校验接口状态码"
- check: message
assert: ${msg_assert} # <--- 动态断言方法
expect: ${msg_expect} # <--- 动态期望值
msg: "检查返回消息" msg: "检查返回消息"
parametrize: # 数据驱动测试 parametrize: # 数据驱动测试
- [ "title","username","password","msg" ] # 变量名 # 定义列名,包括了断言方法和期望值
- [ "测试1","user1","pass1","200" ] # 变量值 - [ "title", "username", "password", "status_assert", "status_expect", "msg_assert", "msg_expect" ]
- [ "测试2","user2","pass2","300" ] # 变量值 # 定义每一行的数据,现在可以为每次运行指定不同的断言逻辑
- [ "测试3","user3","pass3","200" ] # 变量值 - [ "场景1: 成功-状态码相等-消息包含Success", "user1", "pass1", "equals", 200, "contains", "Success" ]
- [ "测试4","user4","pass4","200" ] # 变量值 - [ "场景2: 失败-状态码不相等-消息不包含Error", "user2", "pass2", "not_equals", 200, "not_contains", "Error" ]
- [ "场景3: 成功-状态码大于199-消息相等", "user3", "pass3", "greater_than", 199, "equals", "Success" ]
- [ "场景4: 失败-状态码小于500-消息为空", "user4", "pass4", "less_than", 500, "is_empty", "" ]