pytest-dsl 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl
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.
- pytest_dsl/core/__init__.py +7 -0
- pytest_dsl/core/auth_provider.py +50 -10
- pytest_dsl/core/custom_keyword_manager.py +213 -0
- pytest_dsl/core/dsl_executor.py +39 -2
- pytest_dsl/core/http_client.py +11 -6
- pytest_dsl/core/http_request.py +517 -119
- pytest_dsl/core/lexer.py +14 -1
- pytest_dsl/core/parser.py +45 -2
- pytest_dsl/core/parsetab.py +50 -38
- pytest_dsl/core/variable_utils.py +1 -1
- pytest_dsl/examples/custom/test_advanced_keywords.auto +31 -0
- pytest_dsl/examples/custom/test_custom_keywords.auto +37 -0
- pytest_dsl/examples/custom/test_default_values.auto +34 -0
- pytest_dsl/examples/http/http_retry_assertions.auto +2 -2
- pytest_dsl/examples/http/http_retry_assertions_enhanced.auto +2 -2
- pytest_dsl/examples/quickstart/api_basics.auto +55 -0
- pytest_dsl/examples/quickstart/assertions.auto +31 -0
- pytest_dsl/examples/quickstart/loops.auto +24 -0
- pytest_dsl/examples/test_custom_keyword.py +9 -0
- pytest_dsl/examples/test_http.py +0 -139
- pytest_dsl/examples/test_quickstart.py +14 -0
- pytest_dsl/keywords/http_keywords.py +290 -102
- pytest_dsl/parsetab.py +69 -0
- pytest_dsl-0.2.0.dist-info/METADATA +504 -0
- {pytest_dsl-0.1.0.dist-info → pytest_dsl-0.2.0.dist-info}/RECORD +29 -24
- {pytest_dsl-0.1.0.dist-info → pytest_dsl-0.2.0.dist-info}/WHEEL +1 -1
- pytest_dsl/core/custom_auth_example.py +0 -425
- pytest_dsl/examples/http/csrf_auth_test.auto +0 -64
- pytest_dsl/examples/http/custom_auth_test.auto +0 -76
- pytest_dsl/examples/http_clients.yaml +0 -48
- pytest_dsl/examples/keyword_example.py +0 -70
- pytest_dsl-0.1.0.dist-info/METADATA +0 -537
- {pytest_dsl-0.1.0.dist-info → pytest_dsl-0.2.0.dist-info}/entry_points.txt +0 -0
- {pytest_dsl-0.1.0.dist-info → pytest_dsl-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {pytest_dsl-0.1.0.dist-info → pytest_dsl-0.2.0.dist-info}/top_level.txt +0 -0
pytest_dsl/core/http_request.py
CHANGED
@@ -2,7 +2,7 @@ import json
|
|
2
2
|
import re
|
3
3
|
import yaml
|
4
4
|
import jsonpath_ng.ext as jsonpath
|
5
|
-
from typing import Dict, List, Any, Union, Optional, Tuple
|
5
|
+
from typing import Dict, List, Any, Union, Optional, Tuple, Set
|
6
6
|
import lxml.etree as etree
|
7
7
|
from requests import Response
|
8
8
|
import allure
|
@@ -11,6 +11,23 @@ import requests
|
|
11
11
|
from pytest_dsl.core.http_client import http_client_manager
|
12
12
|
|
13
13
|
|
14
|
+
# 定义支持的比较操作符
|
15
|
+
COMPARISON_OPERATORS = {
|
16
|
+
"eq", "neq", "lt", "lte", "gt", "gte", "in", "not_in", "matches"
|
17
|
+
}
|
18
|
+
|
19
|
+
# 定义支持的断言类型
|
20
|
+
ASSERTION_TYPES = {
|
21
|
+
"exists", "not_exists", "contains", "not_contains", "startswith",
|
22
|
+
"endswith", "matches", "type", "length", "schema", "value"
|
23
|
+
}
|
24
|
+
|
25
|
+
# 操作符和断言类型重叠的集合(可以作为二者使用的)
|
26
|
+
DUAL_PURPOSE = {
|
27
|
+
"contains", "not_contains", "matches", "in", "not_in"
|
28
|
+
}
|
29
|
+
|
30
|
+
|
14
31
|
class HTTPRequest:
|
15
32
|
"""HTTP请求处理类
|
16
33
|
|
@@ -122,14 +139,17 @@ class HTTPRequest:
|
|
122
139
|
# 重新抛出异常
|
123
140
|
raise ValueError(f"HTTP请求执行错误: {str(e)}") from e
|
124
141
|
|
125
|
-
def
|
126
|
-
"""
|
142
|
+
def _ensure_response_exists(self, operation: str = "处理"):
|
143
|
+
"""确保响应对象存在
|
127
144
|
|
128
|
-
|
129
|
-
|
145
|
+
Args:
|
146
|
+
operation: 操作名称,用于错误消息
|
147
|
+
|
148
|
+
Raises:
|
149
|
+
ValueError: 如果响应对象不存在
|
130
150
|
"""
|
131
151
|
if not self.response:
|
132
|
-
error_message = "
|
152
|
+
error_message = f"需要先执行请求才能{operation}"
|
133
153
|
# 记录更详细的错误信息到Allure
|
134
154
|
debug_info = (
|
135
155
|
f"错误详情: self.response 为 None\n"
|
@@ -138,11 +158,19 @@ class HTTPRequest:
|
|
138
158
|
)
|
139
159
|
allure.attach(
|
140
160
|
debug_info,
|
141
|
-
name="
|
161
|
+
name=f"{operation}失败详情",
|
142
162
|
attachment_type=allure.attachment_type.TEXT
|
143
163
|
)
|
144
164
|
raise ValueError(error_message)
|
145
165
|
|
166
|
+
def process_captures(self) -> Dict[str, Any]:
|
167
|
+
"""处理响应捕获
|
168
|
+
|
169
|
+
Returns:
|
170
|
+
捕获的值字典
|
171
|
+
"""
|
172
|
+
self._ensure_response_exists("捕获响应")
|
173
|
+
|
146
174
|
captures_config = self.config.get('captures', {})
|
147
175
|
|
148
176
|
for var_name, capture_spec in captures_config.items():
|
@@ -178,38 +206,19 @@ class HTTPRequest:
|
|
178
206
|
attachment_type=allure.attachment_type.TEXT
|
179
207
|
)
|
180
208
|
except Exception as e:
|
181
|
-
#
|
182
|
-
error_msg =
|
183
|
-
|
184
|
-
|
185
|
-
|
186
|
-
|
187
|
-
error_msg += f"\nURL: {self.config.get('url', '')}"
|
188
|
-
if 'headers' in self.config.get('request', {}):
|
189
|
-
error_msg += "\nHeaders: " + str(self.config.get('request', {}).get('headers', {}))
|
190
|
-
if 'params' in self.config.get('request', {}):
|
191
|
-
error_msg += "\nParams: " + str(self.config.get('request', {}).get('params', {}))
|
192
|
-
if 'json' in self.config.get('request', {}):
|
193
|
-
error_msg += "\nJSON Body: " + str(self.config.get('request', {}).get('json', {}))
|
194
|
-
|
195
|
-
# 添加响应信息
|
196
|
-
error_msg += "\n\n=== 响应信息 ==="
|
197
|
-
error_msg += f"\nStatus: {self.response.status_code} {self.response.reason}"
|
198
|
-
error_msg += f"\nHeaders: {dict(self.response.headers)}"
|
199
|
-
try:
|
200
|
-
if 'application/json' in self.response.headers.get('Content-Type', ''):
|
201
|
-
error_msg += f"\nBody: {json.dumps(self.response.json(), ensure_ascii=False)}"
|
202
|
-
else:
|
203
|
-
error_msg += f"\nBody: {self.response.text}"
|
204
|
-
except:
|
205
|
-
error_msg += "\nBody: <无法解析响应体>"
|
206
|
-
|
209
|
+
# 记录长度计算失败的详细信息
|
210
|
+
error_msg = (
|
211
|
+
f"无法计算长度: {type(e).__name__}: {str(e)}\n"
|
212
|
+
f"类型: {type(original_value).__name__}\n"
|
213
|
+
f"值: {original_value}"
|
214
|
+
)
|
207
215
|
allure.attach(
|
208
216
|
error_msg,
|
209
|
-
name=f"
|
217
|
+
name=f"长度计算失败: {extractor_type} {extraction_path}",
|
210
218
|
attachment_type=allure.attachment_type.TEXT
|
211
219
|
)
|
212
|
-
|
220
|
+
# 抛出更具体的异常,而不是用0替代
|
221
|
+
raise ValueError(f"断言类型'length'无法应用于值 '{original_value}': {str(e)}")
|
213
222
|
else:
|
214
223
|
# 记录捕获到Allure
|
215
224
|
allure.attach(
|
@@ -235,21 +244,90 @@ class HTTPRequest:
|
|
235
244
|
|
236
245
|
return self.captured_values
|
237
246
|
|
238
|
-
def
|
247
|
+
def _format_error_details(self,
|
248
|
+
extractor_type: str,
|
249
|
+
extraction_path: str,
|
250
|
+
assertion_type: str,
|
251
|
+
compare_operator: str,
|
252
|
+
actual_value: Any,
|
253
|
+
expected_value: Any,
|
254
|
+
original_actual_value: Any = None,
|
255
|
+
error_message: str = None,
|
256
|
+
additional_context: str = None) -> str:
|
257
|
+
"""格式化断言错误的详细信息
|
258
|
+
|
259
|
+
Args:
|
260
|
+
extractor_type: 提取器类型
|
261
|
+
extraction_path: 提取路径
|
262
|
+
assertion_type: 断言类型
|
263
|
+
compare_operator: 比较操作符
|
264
|
+
actual_value: 实际值
|
265
|
+
expected_value: 预期值
|
266
|
+
original_actual_value: 原始值(用于length断言)
|
267
|
+
error_message: 错误消息
|
268
|
+
additional_context: 附加上下文信息
|
269
|
+
|
270
|
+
Returns:
|
271
|
+
格式化的错误详情字符串
|
272
|
+
"""
|
273
|
+
error_details = []
|
274
|
+
|
275
|
+
# 添加基本错误信息
|
276
|
+
prefix = "断言执行错误" if additional_context else "断言失败"
|
277
|
+
error_details.append(f"{prefix} [{extractor_type}]")
|
278
|
+
|
279
|
+
# 添加异常类型信息(如果存在)
|
280
|
+
if additional_context:
|
281
|
+
error_details.append(f"异常类型: {additional_context}")
|
282
|
+
|
283
|
+
# 添加路径信息
|
284
|
+
if extraction_path:
|
285
|
+
error_details.append(f"路径: {extraction_path}")
|
286
|
+
|
287
|
+
# 添加断言类型和值信息
|
288
|
+
if assertion_type == "length":
|
289
|
+
error_details.append(f"断言类型: 长度比较")
|
290
|
+
if original_actual_value is not None:
|
291
|
+
error_details.append(f"原始值: {original_actual_value}")
|
292
|
+
error_details.append(f"实际长度: {actual_value}")
|
293
|
+
else:
|
294
|
+
error_details.append(f"断言类型: {assertion_type}")
|
295
|
+
error_details.append(f"实际值: {actual_value}")
|
296
|
+
|
297
|
+
# 添加比较操作符
|
298
|
+
if compare_operator:
|
299
|
+
error_details.append(f"比较操作符: {compare_operator}")
|
300
|
+
|
301
|
+
# 添加预期值
|
302
|
+
if expected_value is not None:
|
303
|
+
error_details.append(f"预期值: {expected_value}")
|
304
|
+
|
305
|
+
# 添加类型信息
|
306
|
+
error_details.append(f"实际类型: {type(actual_value).__name__}")
|
307
|
+
if expected_value is not None:
|
308
|
+
error_details.append(f"预期类型: {type(expected_value).__name__}")
|
309
|
+
|
310
|
+
# 添加错误消息
|
311
|
+
if error_message:
|
312
|
+
error_details.append(f"错误信息: {error_message}")
|
313
|
+
|
314
|
+
return "\n".join(error_details)
|
315
|
+
|
316
|
+
def process_asserts(self, specific_asserts=None) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
239
317
|
"""处理响应断言
|
240
318
|
|
241
319
|
Args:
|
242
320
|
specific_asserts: 指定要处理的断言列表,如果为None则处理所有断言
|
243
321
|
|
244
322
|
Returns:
|
245
|
-
|
323
|
+
Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: 断言结果列表和失败需重试的断言列表
|
246
324
|
"""
|
247
|
-
|
248
|
-
|
249
|
-
|
325
|
+
self._ensure_response_exists("进行断言")
|
326
|
+
|
250
327
|
asserts_config = self.config.get('asserts', [])
|
251
328
|
assert_results = []
|
252
329
|
failed_retryable_assertions = []
|
330
|
+
failed_assertions = [] # 收集所有失败的断言
|
253
331
|
|
254
332
|
# 处理断言重试配置
|
255
333
|
# 1. 只使用独立的retry_assertions配置
|
@@ -303,37 +381,103 @@ class HTTPRequest:
|
|
303
381
|
is_retryable = True
|
304
382
|
|
305
383
|
# 处理断言参数
|
306
|
-
if len(assertion) == 2: #
|
384
|
+
if len(assertion) == 2: # 简单存在性断言 ["header", "Location"]
|
307
385
|
extraction_path = assertion[1]
|
308
386
|
assertion_type = "exists"
|
309
387
|
expected_value = None
|
310
388
|
compare_operator = "eq" # 默认比较操作符
|
311
389
|
elif len(assertion) == 3: # 简单断言 ["status", "eq", 200]
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
390
|
+
# 检查第二个元素是否是操作符或二者兼用的断言类型
|
391
|
+
if assertion[1] in COMPARISON_OPERATORS or assertion[1] in DUAL_PURPOSE:
|
392
|
+
# 这是操作符格式的断言 ["status", "eq", 200]
|
393
|
+
extraction_path = None if extractor_type in ["status", "body", "response_time"] else assertion[1]
|
394
|
+
|
395
|
+
# 特殊处理二者兼用的断言类型
|
396
|
+
if assertion[1] in DUAL_PURPOSE:
|
397
|
+
if extractor_type in ["jsonpath", "body", "header"]:
|
398
|
+
# 当提取器是jsonpath/body/header时,将contains当作断言类型处理
|
399
|
+
assertion_type = assertion[1]
|
400
|
+
compare_operator = assertion[1] # 匹配操作符和断言类型
|
401
|
+
else:
|
402
|
+
# 否则当作操作符处理
|
403
|
+
assertion_type = "value"
|
404
|
+
compare_operator = assertion[1]
|
405
|
+
else:
|
406
|
+
assertion_type = "value" # 标记为值比较
|
407
|
+
compare_operator = assertion[1]
|
408
|
+
|
316
409
|
expected_value = assertion[2] # 预期值
|
317
410
|
else:
|
411
|
+
# 这是断言类型格式 ["jsonpath", "$.id", "exists"]
|
318
412
|
extraction_path = assertion[1]
|
319
413
|
assertion_type = assertion[2]
|
320
|
-
|
321
|
-
|
322
|
-
|
414
|
+
|
415
|
+
# 检查断言类型是否有效
|
416
|
+
if assertion_type not in ASSERTION_TYPES:
|
417
|
+
raise ValueError(f"不支持的断言类型: {assertion_type} 在 {assertion}")
|
418
|
+
|
419
|
+
# 检查此断言类型是否需要预期值
|
420
|
+
if assertion_type not in ["exists", "not_exists"]:
|
421
|
+
# 特殊处理类型断言,它确实需要一个值但在这种格式中没有提供
|
422
|
+
if assertion_type == "type":
|
423
|
+
raise ValueError(f"断言类型 'type' 需要预期值(string/number/boolean/array/object/null),但未提供: {assertion}")
|
424
|
+
# 断言类型作为操作符
|
425
|
+
expected_value = None
|
426
|
+
compare_operator = assertion_type # 使用断言类型作为操作符
|
427
|
+
else:
|
428
|
+
expected_value = None
|
429
|
+
compare_operator = assertion_type # 存在性断言的操作符就是断言类型本身
|
430
|
+
elif len(assertion) == 4: # 带操作符的断言 ["jsonpath", "$.id", "eq", 1] 或特殊断言 ["jsonpath", "$.type", "type", "string"]
|
323
431
|
extraction_path = assertion[1]
|
324
|
-
|
325
|
-
|
432
|
+
|
433
|
+
# 检查第三个元素是否是操作符
|
434
|
+
if assertion[2] in COMPARISON_OPERATORS or assertion[2] in DUAL_PURPOSE:
|
435
|
+
# 这是操作符形式的断言
|
326
436
|
assertion_type = "value" # 标记为值比较
|
327
437
|
compare_operator = assertion[2] # 比较操作符
|
328
438
|
expected_value = assertion[3] # 预期值
|
329
439
|
else:
|
440
|
+
# 检查断言类型是否有效
|
441
|
+
if assertion[2] not in ASSERTION_TYPES:
|
442
|
+
raise ValueError(f"不支持的断言类型: {assertion[2]} 在 {assertion}")
|
443
|
+
|
330
444
|
# 其他类型的断言,比如特殊断言
|
331
445
|
assertion_type = assertion[2]
|
332
|
-
|
333
|
-
|
446
|
+
|
447
|
+
# 根据断言类型决定如何处理操作符和期望值
|
448
|
+
if assertion_type == "length":
|
449
|
+
# 对于长度断言,总是需要有比较操作符和期望值
|
450
|
+
if len(assertion) < 4:
|
451
|
+
raise ValueError(f"长度断言需要提供预期值: {assertion}")
|
452
|
+
compare_operator = "eq" # 默认使用相等比较
|
453
|
+
expected_value = assertion[3] # 预期长度值
|
454
|
+
else:
|
455
|
+
# 对于其他特殊断言,使用第四个元素作为期望值
|
456
|
+
compare_operator = assertion_type # 使用断言类型作为操作符
|
457
|
+
expected_value = assertion[3]
|
334
458
|
else: # 5个参数,例如 ["jsonpath", "$", "length", "eq", 10]
|
335
459
|
extraction_path = assertion[1]
|
336
460
|
assertion_type = assertion[2]
|
461
|
+
|
462
|
+
# 检查断言类型是否有效
|
463
|
+
if assertion_type not in ASSERTION_TYPES:
|
464
|
+
raise ValueError(f"不支持的断言类型: {assertion_type} 在 {assertion}")
|
465
|
+
|
466
|
+
# 特殊处理长度断言
|
467
|
+
if assertion_type == "length":
|
468
|
+
# 验证第四个元素是有效的比较操作符
|
469
|
+
if assertion[3] not in COMPARISON_OPERATORS:
|
470
|
+
raise ValueError(f"长度断言的比较操作符必须是 {', '.join(COMPARISON_OPERATORS)} 之一: {assertion}")
|
471
|
+
|
472
|
+
# 验证第五个元素是有效的长度值
|
473
|
+
try:
|
474
|
+
# 尝试将预期长度转换为整数
|
475
|
+
expected_length = int(assertion[4])
|
476
|
+
if expected_length < 0:
|
477
|
+
raise ValueError(f"长度断言的预期值必须是非负整数: {assertion}")
|
478
|
+
except (ValueError, TypeError):
|
479
|
+
raise ValueError(f"长度断言的预期值必须是有效的整数: {assertion}")
|
480
|
+
|
337
481
|
compare_operator = assertion[3]
|
338
482
|
expected_value = assertion[4]
|
339
483
|
|
@@ -344,10 +488,35 @@ class HTTPRequest:
|
|
344
488
|
original_actual_value = actual_value
|
345
489
|
if assertion_type == "length" and extractor_type != "response_time" and extractor_type != "status" and extractor_type != "body":
|
346
490
|
try:
|
491
|
+
# 记录长度断言的原始值信息
|
492
|
+
allure.attach(
|
493
|
+
f"提取器: {extractor_type}\n路径: {extraction_path}\n原始值: {original_actual_value}\n类型: {type(original_actual_value).__name__}",
|
494
|
+
name=f"长度断言原始值: {extractor_type}",
|
495
|
+
attachment_type=allure.attachment_type.TEXT
|
496
|
+
)
|
497
|
+
|
347
498
|
actual_value = len(actual_value)
|
499
|
+
|
500
|
+
# 记录计算结果
|
501
|
+
allure.attach(
|
502
|
+
f"提取器: {extractor_type}\n路径: {extraction_path}\n长度: {actual_value}",
|
503
|
+
name=f"长度断言计算结果: {extractor_type}",
|
504
|
+
attachment_type=allure.attachment_type.TEXT
|
505
|
+
)
|
348
506
|
except Exception as e:
|
349
|
-
#
|
350
|
-
|
507
|
+
# 记录长度计算失败的详细信息
|
508
|
+
error_msg = (
|
509
|
+
f"无法计算长度: {type(e).__name__}: {str(e)}\n"
|
510
|
+
f"类型: {type(original_actual_value).__name__}\n"
|
511
|
+
f"值: {original_actual_value}"
|
512
|
+
)
|
513
|
+
allure.attach(
|
514
|
+
error_msg,
|
515
|
+
name=f"长度计算失败: {extractor_type} {extraction_path}",
|
516
|
+
attachment_type=allure.attachment_type.TEXT
|
517
|
+
)
|
518
|
+
# 抛出更具体的异常,而不是用0替代
|
519
|
+
raise ValueError(f"断言类型'length'无法应用于值 '{original_actual_value}': {str(e)}")
|
351
520
|
|
352
521
|
# 执行断言
|
353
522
|
assertion_result = {
|
@@ -368,19 +537,69 @@ class HTTPRequest:
|
|
368
537
|
# 验证断言
|
369
538
|
result = self._perform_assertion(assertion_type, compare_operator, actual_value, expected_value)
|
370
539
|
assertion_result['result'] = result
|
371
|
-
assertion_result['passed'] = True
|
372
540
|
|
373
|
-
#
|
374
|
-
|
375
|
-
|
376
|
-
|
377
|
-
|
378
|
-
|
541
|
+
# 根据返回的布尔值确定断言是否通过
|
542
|
+
if result:
|
543
|
+
assertion_result['passed'] = True
|
544
|
+
|
545
|
+
# 使用Allure记录断言成功
|
546
|
+
allure.attach(
|
547
|
+
self._format_assertion_details(assertion_result),
|
548
|
+
name=f"断言成功: {extractor_type}",
|
549
|
+
attachment_type=allure.attachment_type.TEXT
|
550
|
+
)
|
551
|
+
else:
|
552
|
+
# 断言失败但没有抛出异常
|
553
|
+
assertion_result['passed'] = False
|
554
|
+
assertion_result['error'] = "断言失败"
|
555
|
+
|
556
|
+
# 使用Allure记录断言失败
|
557
|
+
allure.attach(
|
558
|
+
self._format_assertion_details(assertion_result) + "\n\n错误: 断言结果为False",
|
559
|
+
name=f"断言失败: {extractor_type}",
|
560
|
+
attachment_type=allure.attachment_type.TEXT
|
561
|
+
)
|
562
|
+
|
563
|
+
# 如果断言可重试,添加到失败且需要重试的断言列表
|
564
|
+
if is_retryable:
|
565
|
+
failed_retryable_assertions.append(assertion_result)
|
566
|
+
|
567
|
+
# 构建格式化的错误消息
|
568
|
+
formatted_error = self._format_error_details(
|
569
|
+
extractor_type=extractor_type,
|
570
|
+
extraction_path=extraction_path,
|
571
|
+
assertion_type=assertion_type,
|
572
|
+
compare_operator=compare_operator,
|
573
|
+
actual_value=actual_value,
|
574
|
+
expected_value=expected_value,
|
575
|
+
original_actual_value=original_actual_value
|
576
|
+
)
|
577
|
+
|
578
|
+
# 收集断言失败,而不是立即抛出异常
|
579
|
+
failed_assertions.append((assertion_idx, formatted_error))
|
580
|
+
|
379
581
|
except AssertionError as e:
|
582
|
+
# 处理上面可能抛出的断言错误,或者其他断言错误
|
380
583
|
assertion_result['result'] = False
|
381
584
|
assertion_result['passed'] = False
|
382
585
|
assertion_result['error'] = str(e)
|
383
586
|
|
587
|
+
# 如果异常已经是格式化的多行消息,直接使用
|
588
|
+
if "\n" in str(e):
|
589
|
+
formatted_error = str(e)
|
590
|
+
else:
|
591
|
+
# 使用辅助方法构建详细的错误消息
|
592
|
+
formatted_error = self._format_error_details(
|
593
|
+
extractor_type=extractor_type,
|
594
|
+
extraction_path=extraction_path,
|
595
|
+
assertion_type=assertion_type,
|
596
|
+
compare_operator=compare_operator,
|
597
|
+
actual_value=actual_value,
|
598
|
+
expected_value=expected_value,
|
599
|
+
original_actual_value=original_actual_value,
|
600
|
+
error_message=str(e)
|
601
|
+
)
|
602
|
+
|
384
603
|
# 使用Allure记录断言失败
|
385
604
|
allure.attach(
|
386
605
|
self._format_assertion_details(assertion_result) + f"\n\n错误: {str(e)}",
|
@@ -392,11 +611,71 @@ class HTTPRequest:
|
|
392
611
|
if is_retryable:
|
393
612
|
failed_retryable_assertions.append(assertion_result)
|
394
613
|
|
395
|
-
#
|
396
|
-
|
614
|
+
# 收集断言失败,而不是立即抛出异常
|
615
|
+
failed_assertions.append((assertion_idx, formatted_error))
|
616
|
+
|
617
|
+
except Exception as e:
|
618
|
+
# 处理其他类型的异常
|
619
|
+
assertion_result['result'] = False
|
620
|
+
assertion_result['passed'] = False
|
621
|
+
assertion_result['error'] = f"未预期的异常: {type(e).__name__}: {str(e)}"
|
622
|
+
|
623
|
+
# 使用辅助方法构建详细的错误消息
|
624
|
+
formatted_error = self._format_error_details(
|
625
|
+
extractor_type=extractor_type,
|
626
|
+
extraction_path=extraction_path,
|
627
|
+
assertion_type=assertion_type,
|
628
|
+
compare_operator=compare_operator,
|
629
|
+
actual_value=actual_value,
|
630
|
+
expected_value=expected_value,
|
631
|
+
original_actual_value=original_actual_value,
|
632
|
+
error_message=str(e),
|
633
|
+
additional_context=type(e).__name__
|
634
|
+
)
|
635
|
+
|
636
|
+
# 使用Allure记录断言错误
|
637
|
+
allure.attach(
|
638
|
+
self._format_assertion_details(assertion_result) + f"\n\n错误: {assertion_result['error']}",
|
639
|
+
name=f"断言执行错误: {extractor_type}",
|
640
|
+
attachment_type=allure.attachment_type.TEXT
|
641
|
+
)
|
642
|
+
|
643
|
+
# 收集断言失败,而不是立即抛出异常
|
644
|
+
failed_assertions.append((assertion_idx, formatted_error))
|
397
645
|
|
398
646
|
assert_results.append(assertion_result)
|
399
647
|
|
648
|
+
# 执行完所有断言后,如果有失败的断言,抛出异常
|
649
|
+
if failed_assertions:
|
650
|
+
# 检查是否只收集失败而不抛出异常(由重试机制设置)
|
651
|
+
collect_only = self.config.get('_collect_failed_assertions_only', False)
|
652
|
+
|
653
|
+
if not collect_only:
|
654
|
+
if len(failed_assertions) == 1:
|
655
|
+
# 只有一个断言失败时,直接使用该断言的错误消息
|
656
|
+
raise AssertionError(failed_assertions[0][1])
|
657
|
+
else:
|
658
|
+
# 多个断言失败时,汇总所有错误
|
659
|
+
error_summary = f"多个断言失败 ({len(failed_assertions)}/{len(process_asserts)}):\n"
|
660
|
+
for idx, (assertion_idx, error_msg) in enumerate(failed_assertions, 1):
|
661
|
+
# 从错误消息中提取关键部分
|
662
|
+
if "[" in error_msg and "]" in error_msg:
|
663
|
+
# 尝试提取提取器类型
|
664
|
+
extractor_type = error_msg.split("[", 1)[1].split("]")[0] if "[" in error_msg else "未知"
|
665
|
+
else:
|
666
|
+
extractor_type = "未知"
|
667
|
+
|
668
|
+
# 生成简短的断言标题
|
669
|
+
assertion_title = f"断言 #{assertion_idx+1} [{extractor_type}]"
|
670
|
+
|
671
|
+
# 添加分隔线使错误更容易辨别
|
672
|
+
error_summary += f"\n{'-' * 30}\n{idx}. {assertion_title}:\n{'-' * 30}\n{error_msg}"
|
673
|
+
|
674
|
+
# 添加底部分隔线
|
675
|
+
error_summary += f"\n{'-' * 50}"
|
676
|
+
|
677
|
+
raise AssertionError(error_summary)
|
678
|
+
|
400
679
|
# 返回断言结果和需要重试的断言
|
401
680
|
return assert_results, failed_retryable_assertions
|
402
681
|
|
@@ -456,15 +735,24 @@ class HTTPRequest:
|
|
456
735
|
elif extractor_type == "status":
|
457
736
|
return self.response.status_code
|
458
737
|
elif extractor_type == "body":
|
459
|
-
|
738
|
+
if isinstance(self.response.text, str):
|
739
|
+
return self.response.text
|
740
|
+
return str(self.response.text)
|
460
741
|
elif extractor_type == "response_time":
|
461
742
|
return self.response.elapsed.total_seconds() * 1000
|
462
743
|
else:
|
463
744
|
raise ValueError(f"不支持的提取器类型: {extractor_type}")
|
464
745
|
except Exception as e:
|
746
|
+
# 记录提取错误
|
747
|
+
error_message = f"提取值失败({extractor_type}, {extraction_path}): {type(e).__name__}: {str(e)}"
|
748
|
+
allure.attach(
|
749
|
+
error_message,
|
750
|
+
name=f"提取错误: {extractor_type}",
|
751
|
+
attachment_type=allure.attachment_type.TEXT
|
752
|
+
)
|
465
753
|
if default_value is not None:
|
466
754
|
return default_value
|
467
|
-
raise ValueError(
|
755
|
+
raise ValueError(error_message)
|
468
756
|
|
469
757
|
def _extract_jsonpath(self, path: str, default_value: Any = None) -> Any:
|
470
758
|
"""使用JSONPath从JSON响应提取值
|
@@ -590,28 +878,144 @@ class HTTPRequest:
|
|
590
878
|
Returns:
|
591
879
|
断言结果
|
592
880
|
"""
|
593
|
-
#
|
881
|
+
# 类型转换增强
|
594
882
|
if operator in ["eq", "neq", "lt", "lte", "gt", "gte"] and expected_value is not None:
|
595
|
-
|
596
|
-
|
597
|
-
|
598
|
-
if
|
599
|
-
|
600
|
-
|
601
|
-
|
883
|
+
# 尝试转换数字字符串为数字
|
884
|
+
try:
|
885
|
+
# 将预期值转换为适当的类型
|
886
|
+
if isinstance(expected_value, str):
|
887
|
+
# 去除空白字符和换行符
|
888
|
+
clean_expected = expected_value.strip()
|
889
|
+
|
890
|
+
# 判断是否是整数
|
891
|
+
if clean_expected.isdigit() or (clean_expected.startswith('-') and clean_expected[1:].isdigit()):
|
892
|
+
expected_value = int(clean_expected)
|
893
|
+
# 判断是否是浮点数(包括科学记数法)
|
894
|
+
elif (
|
895
|
+
# 标准小数
|
896
|
+
(clean_expected.replace('.', '', 1).isdigit()) or
|
897
|
+
# 负小数
|
898
|
+
(clean_expected.startswith('-') and clean_expected[1:].replace('.', '', 1).isdigit()) or
|
899
|
+
# 科学记数法 - 正
|
900
|
+
(('e' in clean_expected or 'E' in clean_expected) and
|
901
|
+
clean_expected.replace('e', '').replace('E', '').replace('+', '', 1).replace('-', '', 1).replace('.', '', 1).isdigit()) or
|
902
|
+
# 科学记数法 - 负
|
903
|
+
(clean_expected.startswith('-') and ('e' in clean_expected or 'E' in clean_expected) and
|
904
|
+
clean_expected[1:].replace('e', '').replace('E', '').replace('+', '', 1).replace('-', '', 1).replace('.', '', 1).isdigit())
|
905
|
+
):
|
906
|
+
expected_value = float(clean_expected)
|
602
907
|
|
603
|
-
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
|
908
|
+
# 将实际值转换为适当的类型
|
909
|
+
if isinstance(actual_value, str):
|
910
|
+
# 去除空白字符和换行符
|
911
|
+
clean_actual = actual_value.strip()
|
912
|
+
|
913
|
+
# 判断是否是整数
|
914
|
+
if clean_actual.isdigit() or (clean_actual.startswith('-') and clean_actual[1:].isdigit()):
|
915
|
+
actual_value = int(clean_actual)
|
916
|
+
# 判断是否是浮点数(包括科学记数法)
|
917
|
+
elif (
|
918
|
+
# 标准小数
|
919
|
+
(clean_actual.replace('.', '', 1).isdigit()) or
|
920
|
+
# 负小数
|
921
|
+
(clean_actual.startswith('-') and clean_actual[1:].replace('.', '', 1).isdigit()) or
|
922
|
+
# 科学记数法 - 正
|
923
|
+
(('e' in clean_actual or 'E' in clean_actual) and
|
924
|
+
clean_actual.replace('e', '').replace('E', '').replace('+', '', 1).replace('-', '', 1).replace('.', '', 1).isdigit()) or
|
925
|
+
# 科学记数法 - 负
|
926
|
+
(clean_actual.startswith('-') and ('e' in clean_actual or 'E' in clean_actual) and
|
927
|
+
clean_actual[1:].replace('e', '').replace('E', '').replace('+', '', 1).replace('-', '', 1).replace('.', '', 1).isdigit())
|
928
|
+
):
|
929
|
+
actual_value = float(clean_actual)
|
930
|
+
except (ValueError, TypeError) as e:
|
931
|
+
# 转换失败时记录日志但不抛出异常,保持原值进行比较
|
932
|
+
allure.attach(
|
933
|
+
f"类型转换失败: {str(e)}\n实际值: {actual_value} ({type(actual_value).__name__})\n预期值: {expected_value} ({type(expected_value).__name__})",
|
934
|
+
name="断言类型转换警告",
|
935
|
+
attachment_type=allure.attachment_type.TEXT
|
936
|
+
)
|
937
|
+
|
938
|
+
# 记录断言参数
|
939
|
+
allure.attach(
|
940
|
+
f"断言类型: {assertion_type}\n"
|
941
|
+
f"比较操作符: {operator}\n"
|
942
|
+
f"实际值: {actual_value} ({type(actual_value).__name__})\n"
|
943
|
+
f"期望值: {expected_value} ({type(expected_value).__name__ if expected_value is not None else 'None'})",
|
944
|
+
name="断言参数",
|
945
|
+
attachment_type=allure.attachment_type.TEXT
|
946
|
+
)
|
610
947
|
|
611
948
|
# 基于断言类型执行断言
|
612
|
-
if assertion_type == "value"
|
949
|
+
if assertion_type == "value":
|
613
950
|
# 直接使用操作符进行比较
|
614
951
|
return self._compare_values(actual_value, expected_value, operator)
|
952
|
+
# 特殊断言类型 - 这些类型的操作符与断言类型匹配
|
953
|
+
elif assertion_type in ["contains", "not_contains", "startswith", "endswith", "matches", "schema"]:
|
954
|
+
# 直接使用断言类型作为方法处理,实现特殊断言逻辑
|
955
|
+
if assertion_type == "contains":
|
956
|
+
# contains断言总是检查actual是否包含expected
|
957
|
+
if isinstance(actual_value, str):
|
958
|
+
return str(expected_value) in actual_value
|
959
|
+
elif isinstance(actual_value, (list, tuple, dict)):
|
960
|
+
return expected_value in actual_value
|
961
|
+
return False
|
962
|
+
elif assertion_type == "not_contains":
|
963
|
+
# not_contains断言总是检查actual是否不包含expected
|
964
|
+
if isinstance(actual_value, str):
|
965
|
+
return str(expected_value) not in actual_value
|
966
|
+
elif isinstance(actual_value, (list, tuple, dict)):
|
967
|
+
return expected_value not in actual_value
|
968
|
+
return True
|
969
|
+
elif assertion_type == "startswith":
|
970
|
+
return isinstance(actual_value, str) and actual_value.startswith(str(expected_value))
|
971
|
+
elif assertion_type == "endswith":
|
972
|
+
return isinstance(actual_value, str) and actual_value.endswith(str(expected_value))
|
973
|
+
elif assertion_type == "matches":
|
974
|
+
if not isinstance(actual_value, str):
|
975
|
+
actual_value = str(actual_value) if actual_value is not None else ""
|
976
|
+
try:
|
977
|
+
import re
|
978
|
+
pattern = str(expected_value)
|
979
|
+
match_result = bool(re.search(pattern, actual_value))
|
980
|
+
# 记录匹配结果
|
981
|
+
allure.attach(
|
982
|
+
f"正则表达式匹配结果: {'成功' if match_result else '失败'}\n"
|
983
|
+
f"模式: {pattern}\n"
|
984
|
+
f"目标字符串: {actual_value}",
|
985
|
+
name="正则表达式匹配",
|
986
|
+
attachment_type=allure.attachment_type.TEXT
|
987
|
+
)
|
988
|
+
return match_result
|
989
|
+
except Exception as e:
|
990
|
+
# 记录正则表达式匹配错误
|
991
|
+
allure.attach(
|
992
|
+
f"正则表达式匹配失败: {type(e).__name__}: {str(e)}\n"
|
993
|
+
f"模式: {expected_value}\n"
|
994
|
+
f"目标字符串: {actual_value}",
|
995
|
+
name="正则表达式错误",
|
996
|
+
attachment_type=allure.attachment_type.TEXT
|
997
|
+
)
|
998
|
+
return False
|
999
|
+
elif assertion_type == "schema":
|
1000
|
+
try:
|
1001
|
+
from jsonschema import validate
|
1002
|
+
validate(instance=actual_value, schema=expected_value)
|
1003
|
+
return True
|
1004
|
+
except Exception as e:
|
1005
|
+
# 记录JSON Schema验证错误
|
1006
|
+
allure.attach(
|
1007
|
+
f"JSON Schema验证失败: {type(e).__name__}: {str(e)}\n"
|
1008
|
+
f"Schema: {expected_value}\n"
|
1009
|
+
f"实例: {actual_value}",
|
1010
|
+
name="Schema验证错误",
|
1011
|
+
attachment_type=allure.attachment_type.TEXT
|
1012
|
+
)
|
1013
|
+
return False
|
1014
|
+
elif assertion_type == "length":
|
1015
|
+
# 长度比较
|
1016
|
+
# 使用实际的比较操作符进行比较,默认使用eq
|
1017
|
+
effective_operator = "eq" if operator == "length" else operator
|
1018
|
+
return self._compare_values(actual_value, expected_value, effective_operator)
|
615
1019
|
elif assertion_type == "exists":
|
616
1020
|
return actual_value is not None
|
617
1021
|
elif assertion_type == "not_exists":
|
@@ -630,38 +1034,9 @@ class HTTPRequest:
|
|
630
1034
|
elif expected_value == "null":
|
631
1035
|
return actual_value is None
|
632
1036
|
return False
|
633
|
-
elif assertion_type == "contains":
|
634
|
-
if isinstance(actual_value, str) and isinstance(expected_value, str):
|
635
|
-
return expected_value in actual_value
|
636
|
-
elif isinstance(actual_value, (list, tuple, dict)):
|
637
|
-
return expected_value in actual_value
|
638
|
-
return False
|
639
|
-
elif assertion_type == "startswith":
|
640
|
-
return isinstance(actual_value, str) and actual_value.startswith(expected_value)
|
641
|
-
elif assertion_type == "endswith":
|
642
|
-
return isinstance(actual_value, str) and actual_value.endswith(expected_value)
|
643
|
-
elif assertion_type == "matches":
|
644
|
-
if not isinstance(actual_value, str) or not isinstance(expected_value, str):
|
645
|
-
return False
|
646
|
-
try:
|
647
|
-
import re
|
648
|
-
return bool(re.search(expected_value, actual_value))
|
649
|
-
except:
|
650
|
-
return False
|
651
|
-
elif assertion_type == "in":
|
652
|
-
return actual_value in expected_value
|
653
|
-
elif assertion_type == "not_in":
|
654
|
-
return actual_value not in expected_value
|
655
|
-
elif assertion_type == "schema":
|
656
|
-
try:
|
657
|
-
from jsonschema import validate
|
658
|
-
validate(instance=actual_value, schema=expected_value)
|
659
|
-
return True
|
660
|
-
except:
|
661
|
-
return False
|
662
1037
|
else:
|
663
1038
|
raise ValueError(f"不支持的断言类型: {assertion_type}")
|
664
|
-
|
1039
|
+
|
665
1040
|
def _compare_values(self, actual_value: Any, expected_value: Any, operator: str) -> bool:
|
666
1041
|
"""比较两个值
|
667
1042
|
|
@@ -686,28 +1061,51 @@ class HTTPRequest:
|
|
686
1061
|
elif operator == "gte":
|
687
1062
|
return actual_value >= expected_value
|
688
1063
|
elif operator == "in":
|
1064
|
+
# in操作符检查actual是否在expected列表中
|
689
1065
|
return actual_value in expected_value
|
690
1066
|
elif operator == "not_in":
|
1067
|
+
# not_in操作符检查actual是否不在expected列表中
|
691
1068
|
return actual_value not in expected_value
|
692
1069
|
elif operator == "contains":
|
693
|
-
|
694
|
-
|
1070
|
+
# contains操作符检查actual是否包含expected
|
1071
|
+
if isinstance(actual_value, str):
|
1072
|
+
return str(expected_value) in actual_value
|
695
1073
|
elif isinstance(actual_value, (list, tuple, dict)):
|
696
1074
|
return expected_value in actual_value
|
697
1075
|
return False
|
698
1076
|
elif operator == "not_contains":
|
699
|
-
|
700
|
-
|
1077
|
+
# not_contains操作符检查actual是否不包含expected
|
1078
|
+
if isinstance(actual_value, str):
|
1079
|
+
return str(expected_value) not in actual_value
|
701
1080
|
elif isinstance(actual_value, (list, tuple, dict)):
|
702
1081
|
return expected_value not in actual_value
|
703
1082
|
return True
|
704
1083
|
elif operator == "matches":
|
705
|
-
|
706
|
-
|
1084
|
+
# matches操作符使用正则表达式进行匹配
|
1085
|
+
if not isinstance(actual_value, str):
|
1086
|
+
actual_value = str(actual_value) if actual_value is not None else ""
|
707
1087
|
try:
|
708
1088
|
import re
|
709
|
-
|
710
|
-
|
1089
|
+
pattern = str(expected_value)
|
1090
|
+
match_result = bool(re.search(pattern, actual_value))
|
1091
|
+
# 记录匹配结果
|
1092
|
+
allure.attach(
|
1093
|
+
f"正则表达式匹配结果: {'成功' if match_result else '失败'}\n"
|
1094
|
+
f"模式: {pattern}\n"
|
1095
|
+
f"目标字符串: {actual_value}",
|
1096
|
+
name="正则表达式匹配",
|
1097
|
+
attachment_type=allure.attachment_type.TEXT
|
1098
|
+
)
|
1099
|
+
return match_result
|
1100
|
+
except Exception as e:
|
1101
|
+
# 记录正则表达式匹配错误
|
1102
|
+
allure.attach(
|
1103
|
+
f"正则表达式匹配失败: {type(e).__name__}: {str(e)}\n"
|
1104
|
+
f"模式: {expected_value}\n"
|
1105
|
+
f"目标字符串: {actual_value}",
|
1106
|
+
name="正则表达式错误",
|
1107
|
+
attachment_type=allure.attachment_type.TEXT
|
1108
|
+
)
|
711
1109
|
return False
|
712
1110
|
else:
|
713
1111
|
raise ValueError(f"不支持的比较操作符: {operator}")
|