feat(executor): 重构用例加载与执行逻辑,支持参数化变量优先级
- 引入 CaseEntity 包装器,实现数据模型与执行上下文解耦。 - 移除加载阶段的 deepcopy,优化大规模参数化用例的内存占用。 - 实现 perform 阶段的局部变量注入,确保参数化数据优先级高于全局缓存。
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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/ 目录下的类并执行方法"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
108
main.py
108
main.py
@@ -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")
|
||||
|
||||
# 1. 执行 Pytest 测试
|
||||
# -v: 输出详细信息
|
||||
# --alluredir: 指定 Allure 临时数据目录
|
||||
# -c: 指定 pytest.ini 配置文件路径
|
||||
pytest.main([
|
||||
"-v",
|
||||
"tests/", # 明确指定测试目录
|
||||
f"--alluredir={temp_dir}",
|
||||
"-c=config/pytest.ini"
|
||||
])
|
||||
|
||||
# 2. 生成 Allure HTML 报告
|
||||
os.system(f'allure generate {temp_dir} -o {html_dir} --clean')
|
||||
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
|
||||
|
||||
|
||||
# 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",
|
||||
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()
|
||||
|
||||
25
pytest.ini
25
pytest.ini
@@ -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
|
||||
@@ -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", "" ]
|
||||
@@ -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", "" ]
|
||||
Reference in New Issue
Block a user