api-engine-xin 0.0.16__tar.gz → 0.0.18__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- api_engine_xin-0.0.18/ApiEngine/BaseCase.py +740 -0
- {api_engine_xin-0.0.16 → api_engine_xin-0.0.18}/ApiEngine/caseLog.py +7 -0
- api_engine_xin-0.0.18/ApiEngine/core.py +447 -0
- {api_engine_xin-0.0.16 → api_engine_xin-0.0.18}/ApiEngine/dbClient.py +13 -2
- api_engine_xin-0.0.18/PKG-INFO +668 -0
- api_engine_xin-0.0.18/README.md +645 -0
- api_engine_xin-0.0.18/api_engine_xin.egg-info/PKG-INFO +668 -0
- {api_engine_xin-0.0.16 → api_engine_xin-0.0.18}/api_engine_xin.egg-info/SOURCES.txt +4 -1
- {api_engine_xin-0.0.16 → api_engine_xin-0.0.18}/api_engine_xin.egg-info/top_level.txt +1 -0
- {api_engine_xin-0.0.16 → api_engine_xin-0.0.18}/setup.py +1 -1
- api_engine_xin-0.0.18/tests/Tools.py +75 -0
- api_engine_xin-0.0.18/tests/__init__.py +0 -0
- api_engine_xin-0.0.18/tests/runTest.py +58 -0
- api_engine_xin-0.0.16/ApiEngine/BaseCase.py +0 -375
- api_engine_xin-0.0.16/ApiEngine/core.py +0 -232
- api_engine_xin-0.0.16/PKG-INFO +0 -148
- api_engine_xin-0.0.16/README.md +0 -112
- api_engine_xin-0.0.16/api_engine_xin.egg-info/PKG-INFO +0 -148
- {api_engine_xin-0.0.16 → api_engine_xin-0.0.18}/ApiEngine/__init__.py +0 -0
- {api_engine_xin-0.0.16 → api_engine_xin-0.0.18}/ApiEngine/global_func.py +0 -0
- {api_engine_xin-0.0.16 → api_engine_xin-0.0.18}/ApiEngine/testResult.py +0 -0
- {api_engine_xin-0.0.16 → api_engine_xin-0.0.18}/LICENSE +0 -0
- {api_engine_xin-0.0.16 → api_engine_xin-0.0.18}/api_engine_xin.egg-info/dependency_links.txt +0 -0
- {api_engine_xin-0.0.16 → api_engine_xin-0.0.18}/api_engine_xin.egg-info/requires.txt +0 -0
- {api_engine_xin-0.0.16 → api_engine_xin-0.0.18}/setup.cfg +0 -0
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import re,time
|
|
2
|
+
import requests
|
|
3
|
+
from jsonpath import jsonpath
|
|
4
|
+
from . import global_func,log
|
|
5
|
+
from .caseLog import CaseLogHandler, PreconditionChainError
|
|
6
|
+
from .dbClient import DBClient
|
|
7
|
+
# 定义脚本中的全局变量
|
|
8
|
+
ENV = {}
|
|
9
|
+
db = DBClient()
|
|
10
|
+
|
|
11
|
+
class BaseCase(CaseLogHandler):
|
|
12
|
+
"""用例执行基本父类"""
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.session = requests.Session()
|
|
15
|
+
|
|
16
|
+
def __run_script(self, data):
|
|
17
|
+
# 执行前后置脚本,可以在前后置脚本中共享数据
|
|
18
|
+
test = self
|
|
19
|
+
global_var = ENV.get("envs")
|
|
20
|
+
print = self.print_log
|
|
21
|
+
# 定义脚本中的临时变量
|
|
22
|
+
self.env = {}
|
|
23
|
+
# 1、读取前置脚本数据
|
|
24
|
+
setup_scripts = data.get("setup_script")
|
|
25
|
+
# 2、执行字符串中有效的python代码
|
|
26
|
+
exec(setup_scripts)
|
|
27
|
+
|
|
28
|
+
response = yield
|
|
29
|
+
|
|
30
|
+
# 1、读取后置脚本数据
|
|
31
|
+
teardown_scripts = data.get("teardown_script")
|
|
32
|
+
# 2、执行字符串中有效的python代码
|
|
33
|
+
exec(teardown_scripts)
|
|
34
|
+
|
|
35
|
+
yield
|
|
36
|
+
|
|
37
|
+
def __setup_script(self, data):
|
|
38
|
+
"""前置脚本处理"""
|
|
39
|
+
self.script_hook = self.__run_script(data)
|
|
40
|
+
next(self.script_hook)
|
|
41
|
+
|
|
42
|
+
def __teardown_script(self, data, response):
|
|
43
|
+
"""后置脚本处理"""
|
|
44
|
+
self.script_hook.send(response)
|
|
45
|
+
"""删除生成器对象"""
|
|
46
|
+
delattr(self, "script_hook")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def execute_function_if_exists(self, value):
|
|
50
|
+
"""
|
|
51
|
+
支持带参数函数调用,增加异常兜底(避免返回None)
|
|
52
|
+
"""
|
|
53
|
+
if not isinstance(value, str):
|
|
54
|
+
# 非字符串类型直接返回
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
# 匹配函数调用格式:如 gen_random_num(2)、random_mobile()
|
|
58
|
+
func_pattern = r"^(\w+)\((.*)\)$"
|
|
59
|
+
func_match = re.match(func_pattern, value.strip())
|
|
60
|
+
if not func_match:
|
|
61
|
+
return value
|
|
62
|
+
|
|
63
|
+
# 提取函数名和参数
|
|
64
|
+
func_name = func_match.group(1)
|
|
65
|
+
func_args_str = func_match.group(2).strip()
|
|
66
|
+
|
|
67
|
+
# 从 global_func 模块获取函数
|
|
68
|
+
try:
|
|
69
|
+
func = getattr(global_func, func_name)
|
|
70
|
+
if not callable(func):
|
|
71
|
+
log.warning_log(f"{func_name} 不是可调用函数,返回原格式")
|
|
72
|
+
return value # 兜底:不返回None
|
|
73
|
+
except AttributeError:
|
|
74
|
+
log.warning_log(f"全局函数 {func_name} 不存在,返回原格式")
|
|
75
|
+
return value # 兜底:不返回None
|
|
76
|
+
|
|
77
|
+
# 解析参数
|
|
78
|
+
func_args = []
|
|
79
|
+
if func_args_str:
|
|
80
|
+
try:
|
|
81
|
+
func_args = eval(f"[{func_args_str}]")
|
|
82
|
+
except Exception as e:
|
|
83
|
+
log.error_log(f"解析函数 {func_name} 参数失败:{str(e)},返回原格式")
|
|
84
|
+
return value # 兜底:不返回None
|
|
85
|
+
|
|
86
|
+
# 执行函数
|
|
87
|
+
log.info_log(f"执行函数 {func_name},参数:{func_args},参数类型:{[type(x) for x in func_args]}")
|
|
88
|
+
try:
|
|
89
|
+
func_result = func(*func_args)
|
|
90
|
+
log.info_log(f"函数 {func_name} 执行结果:{func_result},类型:{type(func_result)}")
|
|
91
|
+
return func_result
|
|
92
|
+
except Exception as e:
|
|
93
|
+
log.error_log(f"执行 {func_name} 抛出异常:{str(e)}", exc_info=True)
|
|
94
|
+
return value # 兜底:不返回None
|
|
95
|
+
|
|
96
|
+
def replace_data(self, data):
|
|
97
|
+
"""替换测试用例中的变量数据"""
|
|
98
|
+
# 1、定义替换数据的规则
|
|
99
|
+
pattern = r"\${(.+?)}"
|
|
100
|
+
# 2、将测试数据转为字符串(统一处理)
|
|
101
|
+
data_str = str(data)
|
|
102
|
+
log.info_log("替换数据原始内容:", data_str)
|
|
103
|
+
|
|
104
|
+
# 3、循环替换变量(直到无未解析的${},或达到最大循环次数)
|
|
105
|
+
max_loop = 100 # 避免死循环
|
|
106
|
+
loop_count = 0
|
|
107
|
+
has_unresolved = True # 标记是否还有未解析的变量
|
|
108
|
+
|
|
109
|
+
while has_unresolved and loop_count < max_loop:
|
|
110
|
+
loop_count += 1
|
|
111
|
+
# 记录替换前的内容,用于判断是否有变化
|
|
112
|
+
original_str = data_str
|
|
113
|
+
|
|
114
|
+
# 遍历所有匹配的变量,逐个替换
|
|
115
|
+
for match_data in list(re.finditer(pattern, data_str)): # list避免迭代时字符串变化
|
|
116
|
+
full_match = match_data.group() # 完整匹配:如 ${e_Cloud_vendors_name}
|
|
117
|
+
key = match_data.group(1) # 提取内容:如 e_Cloud_vendors_name
|
|
118
|
+
|
|
119
|
+
log.info_log(f"第{loop_count}次匹配:{full_match} → 提取内容:{key}")
|
|
120
|
+
|
|
121
|
+
# 步骤1:先从临时变量 self.env 取值
|
|
122
|
+
value = self.env.get(key)
|
|
123
|
+
# 步骤2:临时变量无值 → 从全局变量 ENV["envs"] 取值
|
|
124
|
+
if value is None:
|
|
125
|
+
value = ENV.get("envs", {}).get(key)
|
|
126
|
+
# 步骤3:仍无值 → 直接使用 key 作为值(如 key 是函数调用:gen_random_num(2))
|
|
127
|
+
if value is None:
|
|
128
|
+
log.info_log(f"变量 {key} 无临时/全局值,尝试作为函数调用处理")
|
|
129
|
+
value = key
|
|
130
|
+
|
|
131
|
+
# 核心:执行函数(支持带参数),得到最终替换值
|
|
132
|
+
value_after_exec = self.execute_function_if_exists(value)
|
|
133
|
+
|
|
134
|
+
# 替换当前匹配的变量
|
|
135
|
+
data_str = data_str.replace(full_match, str(value_after_exec))
|
|
136
|
+
log.info_log(f"替换结果:{full_match} → {value_after_exec}")
|
|
137
|
+
|
|
138
|
+
# 判断是否还有未解析的变量(替换后是否变化)
|
|
139
|
+
has_unresolved = re.search(pattern, data_str) is not None
|
|
140
|
+
if original_str == data_str:
|
|
141
|
+
# 内容无变化,说明无法继续替换,退出循环
|
|
142
|
+
has_unresolved = False
|
|
143
|
+
|
|
144
|
+
if loop_count >= max_loop:
|
|
145
|
+
log.warning_log(f"变量替换达到最大循环次数 {max_loop},强制退出(可能存在循环引用)")
|
|
146
|
+
|
|
147
|
+
# 4、还原数据类型(避免所有数据都是字符串)
|
|
148
|
+
try:
|
|
149
|
+
return eval(data_str)
|
|
150
|
+
except (SyntaxError, NameError, TypeError):
|
|
151
|
+
log.error_log(f"数据 {data_str} 无法还原类型,返回字符串格式")
|
|
152
|
+
return data_str
|
|
153
|
+
|
|
154
|
+
def __handle_request_data(self, data):
|
|
155
|
+
"""处理请求数据"""
|
|
156
|
+
self.name = data.get("title")
|
|
157
|
+
request_data = {}
|
|
158
|
+
# 1、处理请求url
|
|
159
|
+
if data.get("interface").get("url").startswith("http"):
|
|
160
|
+
request_data["url"] = data.get("interface").get("url")
|
|
161
|
+
else:
|
|
162
|
+
request_data["url"] = ENV.get("base_url") + data.get("interface").get("url")
|
|
163
|
+
request_data["method"] = data.get("interface").get("method")
|
|
164
|
+
# 2、处理请求头
|
|
165
|
+
request_data["headers"] = ENV.get("headers")
|
|
166
|
+
request_data["headers"].update(data.get("headers"))
|
|
167
|
+
# 3、处理请求参数
|
|
168
|
+
request_data["params"] = data.get("request").get("params")
|
|
169
|
+
content_type = request_data["headers"].get("Content-Type", "")
|
|
170
|
+
if "application/json" in content_type:
|
|
171
|
+
request_data["json"] = data.get("request").get("json")
|
|
172
|
+
if "application/x-www-form-urlencoded" in content_type or "multipart/form-data" in content_type:
|
|
173
|
+
request_data["data"] = data.get("request").get("data")
|
|
174
|
+
if "multipart/form-data" in content_type:
|
|
175
|
+
# 注意:这里不直接把 files 放进需要做变量替换的数据里,避免 open 文件对象被 eval 破坏
|
|
176
|
+
request_data["files"] = data.get("request").get("files")
|
|
177
|
+
# 4、替换请求中的变量名 为 具体的变量数据(files 保持原值)
|
|
178
|
+
files_raw = request_data.get("files")
|
|
179
|
+
request_data_for_replace = dict(request_data)
|
|
180
|
+
if "files" in request_data_for_replace:
|
|
181
|
+
request_data_for_replace.pop("files")
|
|
182
|
+
request_data = self.replace_data(request_data_for_replace)
|
|
183
|
+
if files_raw is not None:
|
|
184
|
+
request_data["files"] = files_raw
|
|
185
|
+
self.url = request_data.get("url","")
|
|
186
|
+
self.method = request_data.get("method","")
|
|
187
|
+
self.request_headers = request_data.get("headers", {})
|
|
188
|
+
return request_data
|
|
189
|
+
|
|
190
|
+
def __send_request(self, data):
|
|
191
|
+
"""发送请求"""
|
|
192
|
+
request_data = self.__handle_request_data(data)
|
|
193
|
+
self.info_log(request_data)
|
|
194
|
+
files_param = request_data.get("files")
|
|
195
|
+
# ★ 新增:用于跟踪已打开的文件句柄(便于后续统一关闭)
|
|
196
|
+
opened_files = []
|
|
197
|
+
# 执行文件转换
|
|
198
|
+
if isinstance(files_param, dict):
|
|
199
|
+
files_param, new_opened = self.convert_files(files_param)
|
|
200
|
+
opened_files.extend(new_opened)
|
|
201
|
+
try:
|
|
202
|
+
response = self.session.request(method=request_data.get("method"),
|
|
203
|
+
url=request_data.get("url"),
|
|
204
|
+
headers=request_data.get("headers"),
|
|
205
|
+
params=request_data.get("params"),
|
|
206
|
+
data=request_data.get("data"),
|
|
207
|
+
json=request_data.get("json"),
|
|
208
|
+
files=files_param,
|
|
209
|
+
allow_redirects=False)
|
|
210
|
+
# 获取用例执行的请求和响应信息
|
|
211
|
+
self.request_body = response.request.body
|
|
212
|
+
self.status_code = response.status_code
|
|
213
|
+
self.response_headers = response.headers
|
|
214
|
+
self.response_body = response.text
|
|
215
|
+
# 拼接完整URL(包含params参数)
|
|
216
|
+
full_url = self.url
|
|
217
|
+
params = request_data.get("params")
|
|
218
|
+
if params:
|
|
219
|
+
from urllib.parse import urlencode
|
|
220
|
+
query_string = urlencode(params)
|
|
221
|
+
full_url = f"{self.url}?{query_string}"
|
|
222
|
+
self.info_log("请求地址:", full_url)
|
|
223
|
+
self.info_log("请求方法:", self.method)
|
|
224
|
+
self.info_log("请求头:", self.request_headers)
|
|
225
|
+
self.info_log("响应头:", self.response_headers)
|
|
226
|
+
self.info_log("请求体:", self.request_body)
|
|
227
|
+
self.info_log("响应体:", self.response_body)
|
|
228
|
+
return response
|
|
229
|
+
finally:
|
|
230
|
+
# ★ 统一关闭所有打开的文件句柄(防止资源泄漏)
|
|
231
|
+
for f in opened_files:
|
|
232
|
+
try:
|
|
233
|
+
f.close()
|
|
234
|
+
except Exception:
|
|
235
|
+
pass
|
|
236
|
+
if opened_files:
|
|
237
|
+
self.info_log(f"已关闭 {len(opened_files)} 个文件句柄")
|
|
238
|
+
|
|
239
|
+
def convert_files(self,param):
|
|
240
|
+
"""递归转换文件参数,返回 (converted_dict, opened_files_list)"""
|
|
241
|
+
local_opened = []
|
|
242
|
+
|
|
243
|
+
if not isinstance(param, dict):
|
|
244
|
+
return param
|
|
245
|
+
|
|
246
|
+
converted = {}
|
|
247
|
+
for field, val in param.items():
|
|
248
|
+
if isinstance(val, dict) and "path" in val:
|
|
249
|
+
# 格式 A: {"path": "...", "name": "..."}
|
|
250
|
+
try:
|
|
251
|
+
import os
|
|
252
|
+
file_path = val["path"]
|
|
253
|
+
filename = val.get("name") or os.path.basename(file_path)
|
|
254
|
+
# ★ 修复:不用 with,手动管理文件句柄
|
|
255
|
+
f = open(file_path, "rb")
|
|
256
|
+
local_opened.append(f)
|
|
257
|
+
self.info_log(f"成功加载文件: {field} = {filename}")
|
|
258
|
+
converted[field] = (filename, f)
|
|
259
|
+
except Exception as e:
|
|
260
|
+
self.error_log(f"文件加载失败 [{val.get('path')}]: {e}")
|
|
261
|
+
|
|
262
|
+
elif isinstance(val, list):
|
|
263
|
+
# 格式 C: 列表格式(单字段多文件)
|
|
264
|
+
file_list = []
|
|
265
|
+
for item in val:
|
|
266
|
+
if isinstance(item, tuple) and len(item) == 2:
|
|
267
|
+
# 已经是 (filename, fileobj) 元组
|
|
268
|
+
file_list.append(item)
|
|
269
|
+
elif isinstance(item, dict) and "path" in item:
|
|
270
|
+
try:
|
|
271
|
+
import os
|
|
272
|
+
fp = item["path"]
|
|
273
|
+
fn = item.get("name") or os.path.basename(fp)
|
|
274
|
+
# ★ 修复:不用 with
|
|
275
|
+
f = open(fp, "rb")
|
|
276
|
+
local_opened.append(f)
|
|
277
|
+
file_list.append((fn, f))
|
|
278
|
+
except Exception as e:
|
|
279
|
+
self.error_log(f"列表项文件加载失败 [{item.get('path')}]: {e}")
|
|
280
|
+
else:
|
|
281
|
+
# 其他格式直接使用
|
|
282
|
+
file_list.append(item)
|
|
283
|
+
if file_list:
|
|
284
|
+
converted[field] = file_list
|
|
285
|
+
else:
|
|
286
|
+
# 格式 B: 直接传值/元组
|
|
287
|
+
converted[field] = val
|
|
288
|
+
|
|
289
|
+
return converted if converted else None, local_opened
|
|
290
|
+
|
|
291
|
+
def __extract_data(self, extract, response):
|
|
292
|
+
"""
|
|
293
|
+
从响应中提取数据并保存到环境变量
|
|
294
|
+
:param extract: 提取规则,格式 ("变量名", "JSONPath表达式")
|
|
295
|
+
例如 {"var_name":"user_id","extract_expr":"$.result.user_id"},
|
|
296
|
+
:param response: requests.Response 对象
|
|
297
|
+
"""
|
|
298
|
+
# 1、解构元组:(变量名, 提取表达式)
|
|
299
|
+
var_name = extract.get("var_name")
|
|
300
|
+
extract_expr = extract.get("extract_expr")
|
|
301
|
+
# 2、将响应体解析为 JSON/dict
|
|
302
|
+
try:
|
|
303
|
+
resp_json = response.json()
|
|
304
|
+
except Exception:
|
|
305
|
+
resp_json = {}
|
|
306
|
+
self.error_log("响应体非JSON格式,无法提取数据")
|
|
307
|
+
# 3、通过 JSONPath 提取值
|
|
308
|
+
extracted_value = self.json_extract(resp_json, extract_expr)
|
|
309
|
+
# 4、保存到环境变量(后续用 ${user_id} 引用)
|
|
310
|
+
self.save_env_variable(var_name, extracted_value)
|
|
311
|
+
self.info_log(f"数据提取成功:{var_name} = {extracted_value}")
|
|
312
|
+
|
|
313
|
+
def __assert_data(self, assertion, response):
|
|
314
|
+
"""
|
|
315
|
+
断言响应数据
|
|
316
|
+
:param assertion: 断言规则,格式 {
|
|
317
|
+
"type": "相等",
|
|
318
|
+
"field": "$.msg",
|
|
319
|
+
"expected": "登陆成功"
|
|
320
|
+
}
|
|
321
|
+
:param response: requests.Response 对象
|
|
322
|
+
"""
|
|
323
|
+
# 1、解构元组
|
|
324
|
+
assertion_type = assertion.get("type")
|
|
325
|
+
extract_expr = assertion.get("field")
|
|
326
|
+
assertion_content = assertion.get("expected")
|
|
327
|
+
# ★ 核心修复:对 expected 字段进行变量替换
|
|
328
|
+
# 支持格式:${var_name} → 替换为实际值
|
|
329
|
+
if isinstance(assertion_content, str) and "${" in assertion_content:
|
|
330
|
+
self.info_log(f"检测到断言期望值包含变量引用:{assertion_content}")
|
|
331
|
+
assertion_content = self.replace_data(assertion_content)
|
|
332
|
+
self.info_log(f"变量替换后期望值:{assertion_content}")
|
|
333
|
+
# 2、将响应体解析为 JSON/dict
|
|
334
|
+
# ★ 新增:特殊字段处理 —— status_code 直接取 HTTP 状态码
|
|
335
|
+
if extract_expr == "status_code":
|
|
336
|
+
extracted_value = response.status_code
|
|
337
|
+
self.info_log(f"检测到断言字段为 status_code,直接获取 HTTP 状态码:{extracted_value}")
|
|
338
|
+
else:
|
|
339
|
+
try:
|
|
340
|
+
resp_json = response.json()
|
|
341
|
+
except Exception:
|
|
342
|
+
resp_json = {}
|
|
343
|
+
self.error_log("响应体非JSON格式,无法提取数据")
|
|
344
|
+
# 3、通过 JSONPath 提取值
|
|
345
|
+
extracted_value = self.json_extract(resp_json, extract_expr)
|
|
346
|
+
# 4、断言
|
|
347
|
+
self.assertion(assertion_type, assertion_content, extracted_value)
|
|
348
|
+
|
|
349
|
+
# 遍历所有assertions项,依次执行断言
|
|
350
|
+
def __execute_assertions(self, data, response):
|
|
351
|
+
# ★ 新增:收集断言错误列表
|
|
352
|
+
assertion_errors = []
|
|
353
|
+
if data.get("assertions"):
|
|
354
|
+
total = len(data.get("assertions"))
|
|
355
|
+
self.info_log(f"========== 开始执行断言(共 {total} 个) ==========")
|
|
356
|
+
for idx, assertion in enumerate(data.get("assertions"), start=1):
|
|
357
|
+
field = assertion.get("field", "未知")
|
|
358
|
+
expected = assertion.get("expected", "未知")
|
|
359
|
+
try:
|
|
360
|
+
self.info_log(f" [{idx}/{total}] 执行断言: field={field}, expected={expected}")
|
|
361
|
+
self.__assert_data(assertion, response)
|
|
362
|
+
self.info_log(f" [{idx}/{total}] ✅ 断言通过: {field}")
|
|
363
|
+
except AssertionError as e:
|
|
364
|
+
self.error_log(f" [{idx}/{total}] ❌ 断言失败: {field} — {e}")
|
|
365
|
+
assertion_errors.append({
|
|
366
|
+
"index": idx,
|
|
367
|
+
"field": field,
|
|
368
|
+
"expected": expected,
|
|
369
|
+
"error_type": "ASSERTION_FAILED",
|
|
370
|
+
"message": str(e)
|
|
371
|
+
})
|
|
372
|
+
# 所有断言执行完毕后输出汇总
|
|
373
|
+
passed_count = total - len(assertion_errors)
|
|
374
|
+
if assertion_errors:
|
|
375
|
+
self.warning_log(
|
|
376
|
+
f"========== 断言汇总:共 {total} 个,"
|
|
377
|
+
f"通过 {passed_count} 个,失败 {len(assertion_errors)} 个 =========="
|
|
378
|
+
)
|
|
379
|
+
for err in assertion_errors:
|
|
380
|
+
self.warning_log(
|
|
381
|
+
f" ❌ [第{err['index']}个] field={err['field']}, "
|
|
382
|
+
f"expected={err['expected']}, 原因: {err['message']}"
|
|
383
|
+
)
|
|
384
|
+
else:
|
|
385
|
+
self.info_log(f"========== 断言汇总:{total} 个全部通过 ✅ ==========")
|
|
386
|
+
|
|
387
|
+
# ★ 如果有失败的断言,最终抛出一个聚合的 AssertionError
|
|
388
|
+
if assertion_errors:
|
|
389
|
+
fail_details = "; ".join(
|
|
390
|
+
f"[{err['field']}] {err['message']}" for err in assertion_errors
|
|
391
|
+
)
|
|
392
|
+
raise AssertionError(
|
|
393
|
+
f"用例 [{data.get('title')}] 断言失败:"
|
|
394
|
+
f"通过 {passed_count}/{total},"
|
|
395
|
+
f"失败详情 → {fail_details}"
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
def __execute_preconditions(self, steps, depth=1,failure_mode="continue"):
|
|
399
|
+
"""
|
|
400
|
+
递归执行多级前置接口(深度优先 DFS)
|
|
401
|
+
容错模式:单步失败记录错误,不影响后续步骤执行
|
|
402
|
+
"""
|
|
403
|
+
if not steps:
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
prefix = " " * depth
|
|
407
|
+
# 前置步骤整体状态记录
|
|
408
|
+
precondition_errors = [] # 收集所有前置步骤的错误
|
|
409
|
+
|
|
410
|
+
for step in steps:
|
|
411
|
+
title = step.get('title', '未命名前置')
|
|
412
|
+
# failure_mode = step.get('on_failure', 'continue') # 默认容错继续
|
|
413
|
+
|
|
414
|
+
self.info_log(f"{prefix}▶ [L{depth}] 执行前置步骤: {title}")
|
|
415
|
+
|
|
416
|
+
# ★ 递归子前置(也带容错)
|
|
417
|
+
if step.get("preconditions"):
|
|
418
|
+
self.info_log(f"{prefix} [L{depth}] '{title}' 有下级前置,先递归执行")
|
|
419
|
+
child_errors = self.__execute_preconditions(
|
|
420
|
+
step.get("preconditions"), depth + 1
|
|
421
|
+
)
|
|
422
|
+
if child_errors:
|
|
423
|
+
precondition_errors.extend(child_errors)
|
|
424
|
+
self.warning_log(f"{prefix} [L{depth}] 子前置存在 {len(child_errors)} 个错误")
|
|
425
|
+
self.info_log(f"{prefix} [L{depth}] '{title}' 的下级前置完成,开始执行自身")
|
|
426
|
+
|
|
427
|
+
# ★ 单步 try-except 容错包裹
|
|
428
|
+
step_error = None
|
|
429
|
+
response= None
|
|
430
|
+
try:
|
|
431
|
+
self.__setup_script(step)
|
|
432
|
+
response = self.__send_request(step)
|
|
433
|
+
|
|
434
|
+
# 数据提取
|
|
435
|
+
if step.get("extract"):
|
|
436
|
+
for extract in step.get("extract"):
|
|
437
|
+
self.__extract_data(extract, response)
|
|
438
|
+
|
|
439
|
+
# 断言(批量执行所有的断言)
|
|
440
|
+
self.__execute_assertions(step, response)
|
|
441
|
+
|
|
442
|
+
except AssertionError as e:
|
|
443
|
+
self.warning_log(f"{prefix}❌ [L{depth}] 存在断言失败: {title} — {e}")
|
|
444
|
+
|
|
445
|
+
except Exception as e:
|
|
446
|
+
step_error = {
|
|
447
|
+
"level": depth,
|
|
448
|
+
"step_title": title,
|
|
449
|
+
"error_type": "EXECUTION_ERROR",
|
|
450
|
+
"message": str(e)
|
|
451
|
+
}
|
|
452
|
+
self.error_log(f"{prefix}❌ [L{depth}] 前置执行异常: {title} — {e}")
|
|
453
|
+
|
|
454
|
+
finally:
|
|
455
|
+
# 确保 teardown 在任何情况下都执行(如果 setup 已执行成功)
|
|
456
|
+
# 注意:如果 __setup_script 都失败了,teardown 不应再执行
|
|
457
|
+
self.__teardown_script(step, response)
|
|
458
|
+
self.info_log(f"{prefix}✅ [L{depth}] 前置完成: {title}")
|
|
459
|
+
|
|
460
|
+
# ★ 根据 on_failure 决定是否继续
|
|
461
|
+
if step_error:
|
|
462
|
+
precondition_errors.append(step_error)
|
|
463
|
+
if failure_mode == "stop":
|
|
464
|
+
self.error_log(f"{prefix}🛑 [L{depth}] 配置 on_failure=stop,中止执行")
|
|
465
|
+
# 将收集到的所有错误抛出给上层或 perform 处理
|
|
466
|
+
raise PreconditionChainError(precondition_errors)
|
|
467
|
+
# 如果是 continue 模式,即使出错也继续下一个步骤
|
|
468
|
+
# 但注意:extract 失败意味着某些变量可能未设置,
|
|
469
|
+
# 后续步骤引用这些变量时会得到空值
|
|
470
|
+
|
|
471
|
+
return precondition_errors # 返回本层所有错误供上级参考
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def perform(self, data):
|
|
475
|
+
"""执行用例"""
|
|
476
|
+
start_time = time.time()
|
|
477
|
+
self._precondition_errors = [] # 记录前置错误(供结果查询)
|
|
478
|
+
case_name = data.get('title')
|
|
479
|
+
has_failure = False # ★ 新增:失败标志位
|
|
480
|
+
try:
|
|
481
|
+
# 1、判断是否有前置步骤 preconditions(支持多级嵌套递归)
|
|
482
|
+
if data.get("preconditions"):
|
|
483
|
+
self.info_log("========== 开始执行前置步骤链 ==========")
|
|
484
|
+
self._precondition_errors = self.__execute_preconditions(
|
|
485
|
+
data.get("preconditions"), depth=1
|
|
486
|
+
)
|
|
487
|
+
if self._precondition_errors:
|
|
488
|
+
has_failure = True # ★ 前置有失败
|
|
489
|
+
self.warning_log(
|
|
490
|
+
f"前置步骤链完成,但有 {len(self._precondition_errors)} 个步骤失败,"
|
|
491
|
+
f"继续执行主用例"
|
|
492
|
+
)
|
|
493
|
+
else:
|
|
494
|
+
self.info_log("========== 前置步骤链全部通过 ==========")
|
|
495
|
+
# 2、执行测试用例(前置 - 测试用例)
|
|
496
|
+
self.info_log(f"开始执行用例步骤:{case_name}")
|
|
497
|
+
self.__setup_script(data)
|
|
498
|
+
response = self.__send_request(data)
|
|
499
|
+
# 3、判断是否有数据提取
|
|
500
|
+
if data.get("extract"):
|
|
501
|
+
for extract in data.get("extract"):
|
|
502
|
+
self.__extract_data(extract, response)
|
|
503
|
+
# ★ 4、断言部分 —— 容错模式:全部执行完再汇总结果
|
|
504
|
+
self.__execute_assertions(data,response)
|
|
505
|
+
# 5、执行后置步骤
|
|
506
|
+
self.__teardown_script(data, response)
|
|
507
|
+
self.info_log(f"结束执行用例步骤:{case_name}")
|
|
508
|
+
# ★ 新增:try块结束时检查前置是否有失败
|
|
509
|
+
if has_failure:
|
|
510
|
+
# 前置有断言/执行失败,但主用例可能通过了
|
|
511
|
+
# 需要通知上层这个用例整体不算成功
|
|
512
|
+
raise AssertionError(
|
|
513
|
+
f"[{case_name}] 用例执行完成但存在 {len(self._precondition_errors)} 个前置失败"
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
except AssertionError as e:
|
|
517
|
+
# ★ 断言失败 —— 不吞掉
|
|
518
|
+
has_failure = True
|
|
519
|
+
self.warning_log(f"⚠️ 用例 [{case_name}] 存在失败断言")
|
|
520
|
+
# 让异常继续传播给上层
|
|
521
|
+
raise # ← 关键:重新抛出!
|
|
522
|
+
|
|
523
|
+
except Exception as e:
|
|
524
|
+
step_error = {
|
|
525
|
+
"level": 0,
|
|
526
|
+
"step_title": case_name,
|
|
527
|
+
"error_type": "EXECUTION_ERROR",
|
|
528
|
+
"message": str(e)
|
|
529
|
+
}
|
|
530
|
+
self.error_log(f" ❌用例执行异常: {case_name} — {e}")
|
|
531
|
+
raise # 其他异常也要继续传播
|
|
532
|
+
finally:
|
|
533
|
+
end_time = time.time()
|
|
534
|
+
elapsed_time = end_time - start_time
|
|
535
|
+
self.elapsed_ms = "{} ms".format(int(elapsed_time * 1000))
|
|
536
|
+
self.info_log(f"✅ 用例执行完成:{case_name}")
|
|
537
|
+
|
|
538
|
+
def save_env_variable(self, key, value):
|
|
539
|
+
"""保存测试运行环境变量"""
|
|
540
|
+
self.info_log(f"保存(临时)环境变量:{key} = {value}")
|
|
541
|
+
self.env[key] = value
|
|
542
|
+
# 运行期间:局部变量也写入 ENV["envs"],使其在同一批用例中可跨脚本传递
|
|
543
|
+
try:
|
|
544
|
+
envs = ENV.get("envs")
|
|
545
|
+
if isinstance(envs, dict):
|
|
546
|
+
envs[key] = value
|
|
547
|
+
except Exception:
|
|
548
|
+
# 防御性兜底:不影响当前用例执行
|
|
549
|
+
pass
|
|
550
|
+
|
|
551
|
+
def del_evn_variable(self, key):
|
|
552
|
+
"""删除测试运行环境变量"""
|
|
553
|
+
self.info_log(f"删除(临时)环境变量:{key}")
|
|
554
|
+
del self.env[key]
|
|
555
|
+
|
|
556
|
+
def save_global_variable(self, key, value):
|
|
557
|
+
"""保存测试运行环境的全局变量"""
|
|
558
|
+
self.info_log(f"保存全局变量:{key} = {value}")
|
|
559
|
+
envs = ENV.get("envs")
|
|
560
|
+
if isinstance(envs, dict):
|
|
561
|
+
envs[key] = value
|
|
562
|
+
# 记录本次运行中修改/新增的全局变量,便于上层平台同步到“调试运行变量”
|
|
563
|
+
try:
|
|
564
|
+
debug_updates = ENV.get("debug_updates")
|
|
565
|
+
if isinstance(debug_updates, dict):
|
|
566
|
+
debug_updates[key] = value
|
|
567
|
+
except Exception:
|
|
568
|
+
# 防御性兜底:不影响当前用例执行
|
|
569
|
+
pass
|
|
570
|
+
|
|
571
|
+
def del_global_variable(self, key):
|
|
572
|
+
"""删除测试运行环境的全局变量"""
|
|
573
|
+
self.info_log(f"删除全局变量:{key}")
|
|
574
|
+
del ENV.get("envs")[key]
|
|
575
|
+
|
|
576
|
+
def get_env_variable(self, key, default=None):
|
|
577
|
+
"""
|
|
578
|
+
获取临时变量(局部变量)
|
|
579
|
+
优先从当前用例的 self.env 中读取,可选提供默认值
|
|
580
|
+
"""
|
|
581
|
+
self.info_log(f"获取(临时)环境变量:{key}")
|
|
582
|
+
return self.env.get(key, default)
|
|
583
|
+
|
|
584
|
+
def get_global_variable(self, key, default=None):
|
|
585
|
+
"""
|
|
586
|
+
获取环境变量(全局变量/环境变量)
|
|
587
|
+
从 ENV['envs'] 中读取,可选提供默认值
|
|
588
|
+
"""
|
|
589
|
+
self.info_log(f"获取环境变量:{key}")
|
|
590
|
+
envs = ENV.get("envs")
|
|
591
|
+
if isinstance(envs, dict):
|
|
592
|
+
return envs.get(key, default)
|
|
593
|
+
return default
|
|
594
|
+
|
|
595
|
+
def json_extract(self,obj,ext):
|
|
596
|
+
"""通过jsonpath提取一个json数据"""
|
|
597
|
+
self.info_log("----通过jsonpath提取单个数据---")
|
|
598
|
+
res = jsonpath(obj, ext)
|
|
599
|
+
value = res[0] if res else ""
|
|
600
|
+
return value
|
|
601
|
+
|
|
602
|
+
def json_extract_list(self,obj,ext):
|
|
603
|
+
"""通过jsonpath提取一组json数据"""
|
|
604
|
+
self.info_log("----通过jsonpath提取一组数据---")
|
|
605
|
+
res = jsonpath(obj, ext)
|
|
606
|
+
value = res if res else []
|
|
607
|
+
return value
|
|
608
|
+
|
|
609
|
+
def re_extract(self,obj,ext):
|
|
610
|
+
"""
|
|
611
|
+
通过正则提取一个数据
|
|
612
|
+
obj: 响应的json数据
|
|
613
|
+
ext: 匹配的正则表达式
|
|
614
|
+
"""
|
|
615
|
+
self.info_log("----通过正则提取数据---")
|
|
616
|
+
# 1、判断响应是否为字符串
|
|
617
|
+
if not isinstance(obj,str):
|
|
618
|
+
obj = str(obj)
|
|
619
|
+
# 2、提取匹配正则表达式的第一个数据
|
|
620
|
+
res = re.search(ext,obj)
|
|
621
|
+
value = res.group(1) if res else ""
|
|
622
|
+
return value
|
|
623
|
+
|
|
624
|
+
def re_extract_list(self,obj,ext):
|
|
625
|
+
"""
|
|
626
|
+
通过正则提取一组数据
|
|
627
|
+
obj: 响应的json数据
|
|
628
|
+
ext: 匹配的正则表达式
|
|
629
|
+
"""
|
|
630
|
+
self.info_log("----通过正则提取一组数据---")
|
|
631
|
+
# 1、判断响应是否为字符串
|
|
632
|
+
if not isinstance(obj,str):
|
|
633
|
+
obj = str(obj)
|
|
634
|
+
# 2、提取匹配正则表达式的所有数据
|
|
635
|
+
res = re.findall(ext,obj)
|
|
636
|
+
value = res if res else []
|
|
637
|
+
return value
|
|
638
|
+
|
|
639
|
+
def assertion(self,method,expect,actual):
|
|
640
|
+
"""
|
|
641
|
+
:param method: 断言比较的方式
|
|
642
|
+
:param expect: 断言的期望结果
|
|
643
|
+
:param actual: 断言的实际结果
|
|
644
|
+
:return:
|
|
645
|
+
"""
|
|
646
|
+
# 1、断言的方法(支持中英文关键字)
|
|
647
|
+
method_map = {
|
|
648
|
+
# 相等
|
|
649
|
+
"相等": lambda a,b: a == b,
|
|
650
|
+
"equals": lambda a,b: a == b,
|
|
651
|
+
"eq": lambda a,b: a == b,
|
|
652
|
+
"==": lambda a,b: a == b,
|
|
653
|
+
# 相等忽略大小写
|
|
654
|
+
"相等忽略大小写": lambda a, b: a.lower() == b.lower(),
|
|
655
|
+
"equals_ignore_case": lambda a, b: a.lower() == b.lower(),
|
|
656
|
+
"eq_ignore_case": lambda a, b: a.lower() == b.lower(),
|
|
657
|
+
# 不相等
|
|
658
|
+
"不相等": lambda a,b: a != b,
|
|
659
|
+
"not_equals": lambda a,b: a != b,
|
|
660
|
+
"ne": lambda a,b: a != b,
|
|
661
|
+
"!=": lambda a,b: a != b,
|
|
662
|
+
# 包含
|
|
663
|
+
"包含": lambda a,b: a in b,
|
|
664
|
+
"contains": lambda a,b: a in b,
|
|
665
|
+
"in": lambda a,b: a in b,
|
|
666
|
+
# 不包含
|
|
667
|
+
"不包含": lambda a,b: a not in b,
|
|
668
|
+
"not_contains": lambda a,b: a not in b,
|
|
669
|
+
"not_in": lambda a,b: a not in b,
|
|
670
|
+
# 大于
|
|
671
|
+
"大于": lambda a,b: a > b,
|
|
672
|
+
"greater_than": lambda a,b: a > b,
|
|
673
|
+
"gt": lambda a,b: a > b,
|
|
674
|
+
">": lambda a,b: a > b,
|
|
675
|
+
# 小于
|
|
676
|
+
"小于": lambda a,b: a < b,
|
|
677
|
+
"less_than": lambda a,b: a < b,
|
|
678
|
+
"lt": lambda a,b: a < b,
|
|
679
|
+
"<": lambda a,b: a < b,
|
|
680
|
+
# 大于等于
|
|
681
|
+
"大于等于": lambda a,b: a >= b,
|
|
682
|
+
"greater_than_or_equals": lambda a,b: a >= b,
|
|
683
|
+
"ge": lambda a,b: a >= b,
|
|
684
|
+
">=": lambda a,b: a >= b,
|
|
685
|
+
# 小于等于
|
|
686
|
+
"小于等于": lambda a,b: a <= b,
|
|
687
|
+
"less_than_or_equals": lambda a,b: a <= b,
|
|
688
|
+
"le": lambda a,b: a <= b,
|
|
689
|
+
"<=": lambda a,b: a <= b,
|
|
690
|
+
# 正则匹配
|
|
691
|
+
"正则匹配": lambda a,b: re.search(a,b),
|
|
692
|
+
"regex_match": lambda a,b: re.search(a,b),
|
|
693
|
+
"regex": lambda a,b: re.search(a,b),
|
|
694
|
+
"match": lambda a,b: re.search(a,b)
|
|
695
|
+
}
|
|
696
|
+
# ★ 新增:智能类型兼容处理
|
|
697
|
+
# 场景:expect="200"(str), actual=200(int) → 自动转一致后再比较
|
|
698
|
+
expect, actual = self._normalize_for_compare(expect, actual)
|
|
699
|
+
# 2、断言操作
|
|
700
|
+
assert_fun = method_map.get(method)
|
|
701
|
+
if assert_fun is None:
|
|
702
|
+
raise Exception("不支持的断言方法")
|
|
703
|
+
else:
|
|
704
|
+
self.debug_log(f"断言比较方法是:{method}")
|
|
705
|
+
self.debug_log(f"预期结果是:{expect}")
|
|
706
|
+
self.debug_log(f"实际结果是:{actual}")
|
|
707
|
+
try:
|
|
708
|
+
assert assert_fun(expect,actual)
|
|
709
|
+
except AssertionError:
|
|
710
|
+
self.error_log(f"断言失败,实际结果({actual}) 不满足({method}) 期望结果({expect})")
|
|
711
|
+
raise AssertionError(f"断言失败,实际结果({actual}) 不满足({method}) 期望结果({expect})")
|
|
712
|
+
else:
|
|
713
|
+
self.info_log(f"断言成功,实际结果({actual}) 满足({method}) 期望结果({expect})")
|
|
714
|
+
|
|
715
|
+
def _normalize_for_compare(self, expect, actual):
|
|
716
|
+
"""
|
|
717
|
+
智能类型归一化,解决 str/int/float 类型不一致导致的断言失败
|
|
718
|
+
"""
|
|
719
|
+
# 如果两者类型已经相同,直接返回
|
|
720
|
+
if type(expect) == type(actual):
|
|
721
|
+
return expect, actual
|
|
722
|
+
|
|
723
|
+
# 尝试将 expect 转为 actual 的类型
|
|
724
|
+
try:
|
|
725
|
+
if isinstance(actual, (int, float)) and isinstance(expect, str):
|
|
726
|
+
# actual 是数字, expect 是字符串 → 尝试将 expect 转为数字
|
|
727
|
+
if '.' in str(expect):
|
|
728
|
+
return float(expect), actual
|
|
729
|
+
else:
|
|
730
|
+
return int(expect), actual
|
|
731
|
+
elif isinstance(expect, (int, float)) and isinstance(actual, str):
|
|
732
|
+
# expect 是数字, actual 是字符串 → 尝试将 actual 转为数字
|
|
733
|
+
if '.' in str(actual):
|
|
734
|
+
return expect, float(actual)
|
|
735
|
+
else:
|
|
736
|
+
return expect, int(actual)
|
|
737
|
+
except (ValueError, TypeError):
|
|
738
|
+
pass # 转换失败,保持原值
|
|
739
|
+
|
|
740
|
+
return expect, actual
|