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.
Files changed (35) hide show
  1. pytest_dsl/core/__init__.py +7 -0
  2. pytest_dsl/core/auth_provider.py +50 -10
  3. pytest_dsl/core/custom_keyword_manager.py +213 -0
  4. pytest_dsl/core/dsl_executor.py +39 -2
  5. pytest_dsl/core/http_client.py +11 -6
  6. pytest_dsl/core/http_request.py +517 -119
  7. pytest_dsl/core/lexer.py +14 -1
  8. pytest_dsl/core/parser.py +45 -2
  9. pytest_dsl/core/parsetab.py +50 -38
  10. pytest_dsl/core/variable_utils.py +1 -1
  11. pytest_dsl/examples/custom/test_advanced_keywords.auto +31 -0
  12. pytest_dsl/examples/custom/test_custom_keywords.auto +37 -0
  13. pytest_dsl/examples/custom/test_default_values.auto +34 -0
  14. pytest_dsl/examples/http/http_retry_assertions.auto +2 -2
  15. pytest_dsl/examples/http/http_retry_assertions_enhanced.auto +2 -2
  16. pytest_dsl/examples/quickstart/api_basics.auto +55 -0
  17. pytest_dsl/examples/quickstart/assertions.auto +31 -0
  18. pytest_dsl/examples/quickstart/loops.auto +24 -0
  19. pytest_dsl/examples/test_custom_keyword.py +9 -0
  20. pytest_dsl/examples/test_http.py +0 -139
  21. pytest_dsl/examples/test_quickstart.py +14 -0
  22. pytest_dsl/keywords/http_keywords.py +290 -102
  23. pytest_dsl/parsetab.py +69 -0
  24. pytest_dsl-0.2.0.dist-info/METADATA +504 -0
  25. {pytest_dsl-0.1.0.dist-info → pytest_dsl-0.2.0.dist-info}/RECORD +29 -24
  26. {pytest_dsl-0.1.0.dist-info → pytest_dsl-0.2.0.dist-info}/WHEEL +1 -1
  27. pytest_dsl/core/custom_auth_example.py +0 -425
  28. pytest_dsl/examples/http/csrf_auth_test.auto +0 -64
  29. pytest_dsl/examples/http/custom_auth_test.auto +0 -76
  30. pytest_dsl/examples/http_clients.yaml +0 -48
  31. pytest_dsl/examples/keyword_example.py +0 -70
  32. pytest_dsl-0.1.0.dist-info/METADATA +0 -537
  33. {pytest_dsl-0.1.0.dist-info → pytest_dsl-0.2.0.dist-info}/entry_points.txt +0 -0
  34. {pytest_dsl-0.1.0.dist-info → pytest_dsl-0.2.0.dist-info}/licenses/LICENSE +0 -0
  35. {pytest_dsl-0.1.0.dist-info → pytest_dsl-0.2.0.dist-info}/top_level.txt +0 -0
@@ -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 process_captures(self) -> Dict[str, Any]:
126
- """处理响应捕获
142
+ def _ensure_response_exists(self, operation: str = "处理"):
143
+ """确保响应对象存在
127
144
 
128
- Returns:
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 = f"变量名: {var_name}\n提取器: {extractor_type}\n路径: {extraction_path}\n错误: 无法计算长度: {str(e)}"
183
-
184
- # 添加请求信息
185
- error_msg += "\n\n=== 请求信息 ==="
186
- error_msg += f"\nMethod: {self.config.get('method', 'GET')}"
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"捕获长度失败: {var_name}",
217
+ name=f"长度计算失败: {extractor_type} {extraction_path}",
210
218
  attachment_type=allure.attachment_type.TEXT
211
219
  )
212
- captured_value = 0 # 默认长度
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 process_asserts(self, specific_asserts=None) -> List[Dict[str, Any]]:
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
- if not self.response:
248
- raise ValueError("需要先执行请求才能进行断言")
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: # 存在性断言 ["header", "Location", "exists"]
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
- if extractor_type in ["status", "body", "response_time"]:
313
- extraction_path = None
314
- assertion_type = "value" # 标记为简单值比较
315
- compare_operator = assertion[1] # 比较操作符
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
- compare_operator = "eq" # 默认比较操作符
321
- expected_value = None
322
- elif len(assertion) == 4: # 带操作符的断言 ["jsonpath", "$.id", "eq", 1]
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
- if assertion[2] in ["eq", "neq", "lt", "lte", "gt", "gte"]:
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
- compare_operator = "eq" # 默认比较操作符
333
- expected_value = assertion[3]
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
- # 长度计算失败的信息已在_extract_value中记录
350
- actual_value = 0
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
- # 使用Allure记录断言成功
374
- allure.attach(
375
- self._format_assertion_details(assertion_result),
376
- name=f"断言成功: {extractor_type}",
377
- attachment_type=allure.attachment_type.TEXT
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
- raise AssertionError(f"断言失败 [{extractor_type}]: {str(e)}")
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
- return self.response.text
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(f"提取值失败({extractor_type}, {extraction_path}): {str(e)}")
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
- if isinstance(expected_value, str):
596
- # 去除空白字符和换行符后再判断
597
- clean_expected = expected_value.strip()
598
- if clean_expected.isdigit():
599
- expected_value = int(clean_expected)
600
- elif clean_expected.replace('.', '', 1).isdigit():
601
- expected_value = float(clean_expected)
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
- if isinstance(actual_value, str):
604
- # 去除空白字符和换行符后再判断
605
- clean_actual = actual_value.strip()
606
- if clean_actual.isdigit():
607
- actual_value = int(clean_actual)
608
- elif clean_actual.replace('.', '', 1).isdigit():
609
- actual_value = float(clean_actual)
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" or assertion_type == "length":
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
- if isinstance(actual_value, str) and isinstance(expected_value, str):
694
- return expected_value in actual_value
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
- if isinstance(actual_value, str) and isinstance(expected_value, str):
700
- return expected_value not in actual_value
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
- if not isinstance(actual_value, str) or not isinstance(expected_value, str):
706
- return False
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
- return bool(re.search(expected_value, actual_value))
710
- except:
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}")