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

View File

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

View File

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

102
main.py
View File

@@ -1,22 +1,90 @@
import os
import shutil
import datetime
from pathlib import Path
import pytest
if __name__ == '__main__':
# 定义报告和临时文件目录
reports_dir = "reports"
temp_dir = os.path.join(reports_dir, "temp")
html_dir = os.path.join(reports_dir, "html")
from core.settings import LOG_SOURCE, LOG_BACKUP_DIR, ALLURE_TEMP
# from core.enums import AppPlatform
from utils.dirs_manager import ensure_dirs_ok
from utils.report_handler import generate_allure_report
# 1. 执行 Pytest 测试
# -v: 输出详细信息
# --alluredir: 指定 Allure 临时数据目录
# -c: 指定 pytest.ini 配置文件路径
pytest.main([
# netstat -ano | findstr :4723
# taskkill /PID 12345 /F
def _archive_logs():
"""
在测试开始前,归档上一次运行的日志文件。
此时没有任何句柄占用move 操作是 100% 安全的。
"""
# 4. 备份日志 (无论测试是否崩溃都执行)
if LOG_SOURCE.exists() and LOG_SOURCE.stat().st_size > 0:
now = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = LOG_BACKUP_DIR / f"pytest_{now}.log"
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",
"tests/", # 明确指定测试目录
f"--alluredir={temp_dir}",
"-c=config/pytest.ini"
])
f"--alluredir={ALLURE_TEMP}",
# f"--platform={AppPlatform.ANDROID.value}",
# "--caps_name=wan_android"
]
pytest.main(args)
# 2. 生成 Allure HTML 报告
os.system(f'allure generate {temp_dir} -o {html_dir} --clean')
# 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]
testpaths = tests
addopts = -q --show-capture=no
addopts = -q --show-capture=no --reruns 2 --reruns-delay 1
# 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
log_file_level = debug
log_file_format = %(asctime)s [%(name)s] %(levelname)s %(module)s.%(funcName)s:%(lineno)d - %(message)s
log_file_date_format = %m/%d/%Y %H:%M:%S %p
# 2. 开启日志文件记录
log_file = outputs/logs/pytest.log
log_file_level = INFO
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
Referer: http://119.91.19.171:40065/users/login
Accept-Encoding: gzip, deflate
json_body:
username: "${username}"
password: "${password}"
extract: # 提取变量
msg:
- "json"
@@ -19,20 +22,21 @@ extract: # 提取变量
- 0
validate:
- check: status_code # 检查的对象(或是变量名)
assert: equals # 断言方法
expect: 200 # 期望值
msg: "校验接口状态码" # 描述(可选)
- check: status_code
assert: ${status_assert} # <--- 动态断言方法
expect: ${status_expect} # <--- 动态期望值
msg: "校验接口状态码"
- check: $.msg
assert: contains
expect: "Success"
- check: message
assert: ${msg_assert} # <--- 动态断言方法
expect: ${msg_expect} # <--- 动态期望值
msg: "检查返回消息"
parametrize: # 数据驱动测试
- [ "title","username","password","msg" ] # 变量名
- [ "测试1","user1","pass1","200" ] # 变量值
- [ "测试2","user2","pass2","300" ] # 变量值
- [ "测试3","user3","pass3","200" ] # 变量值
- [ "测试4","user4","pass4","200" ] # 变量值
- [ "测试5","user5","pass5","200" ] # 变量值
# 定义列名,包括了断言方法和期望值
- [ "title", "username", "password", "status_assert", "status_expect", "msg_assert", "msg_expect" ]
# 定义每一行的数据,现在可以为每次运行指定不同的断言逻辑
- [ "场景1: 成功-状态码相等-消息包含Success", "user1", "pass1", "equals", 200, "contains", "Success" ]
- [ "场景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", "" ]

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
Referer: http://119.91.19.171:40065/users/login
Accept-Encoding: gzip, deflate
json_body: { username:${ username },password:${ password } }
extract: # 提取变量
msg:
- "json"
- "$.msg"
- 0
validate:
- check: status_code # 检查的对象(或是变量名)
assert: equals # 断言方法
expect: 200 # 期望值
msg: "校验接口状态码" # 描述(可选)
- check: $.msg
assert: contains
expect: "Success"
validate:
- check: status_code
assert: ${status_assert} # <--- 动态断言方法
expect: ${status_expect} # <--- 动态期望值
msg: "校验接口状态码"
- check: message
assert: ${msg_assert} # <--- 动态断言方法
expect: ${msg_expect} # <--- 动态期望值
msg: "检查返回消息"
parametrize: # 数据驱动测试
- [ "title","username","password","msg" ] # 变量名
- [ "测试1","user1","pass1","200" ] # 变量值
- [ "测试2","user2","pass2","300" ] # 变量值
- [ "测试3","user3","pass3","200" ] # 变量值
- [ "测试4","user4","pass4","200" ] # 变量值
# 定义列名,包括了断言方法和期望值
- [ "title", "username", "password", "status_assert", "status_expect", "msg_assert", "msg_expect" ]
# 定义每一行的数据,现在可以为每次运行指定不同的断言逻辑
- [ "场景1: 成功-状态码相等-消息包含Success", "user1", "pass1", "equals", 200, "contains", "Success" ]
- [ "场景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", "" ]