pytest-api-framework-alpha 0.1.0__tar.gz → 0.1.2__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.
Files changed (30) hide show
  1. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/PKG-INFO +2 -1
  2. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/base_class.py +15 -2
  3. pytest_api_framework_alpha-0.1.2/framework/conftest.py +422 -0
  4. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/db/mysql_db.py +2 -2
  5. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/extract.py +6 -6
  6. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/global_attribute.py +9 -2
  7. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/http_client.py +10 -4
  8. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/render_data.py +30 -1
  9. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/report.py +1 -1
  10. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/pytest_api_framework_alpha.egg-info/PKG-INFO +2 -1
  11. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/pytest_api_framework_alpha.egg-info/requires.txt +1 -0
  12. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/setup.py +3 -2
  13. pytest_api_framework_alpha-0.1.0/framework/conftest.py +0 -523
  14. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/__init__.py +0 -0
  15. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/allure_report.py +0 -0
  16. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/db/__init__.py +0 -0
  17. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/db/redis_db.py +0 -0
  18. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/exit_code.py +0 -0
  19. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/startapp.py +0 -0
  20. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/utils/__init__.py +0 -0
  21. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/utils/common.py +0 -0
  22. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/utils/encrypt.py +0 -0
  23. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/utils/log_util.py +0 -0
  24. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/utils/teams_util.py +0 -0
  25. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/utils/yaml_util.py +0 -0
  26. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/framework/validate.py +0 -0
  27. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/pytest_api_framework_alpha.egg-info/SOURCES.txt +0 -0
  28. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/pytest_api_framework_alpha.egg-info/dependency_links.txt +0 -0
  29. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/pytest_api_framework_alpha.egg-info/top_level.txt +0 -0
  30. {pytest_api_framework_alpha-0.1.0 → pytest_api_framework_alpha-0.1.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-api-framework-alpha
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Author: alpha
5
5
  Author-email:
6
6
  Requires-Python: >=3.6
@@ -24,6 +24,7 @@ Requires-Dist: requests-toolbelt==1.0.0
24
24
  Requires-Dist: retry==0.9.2
25
25
  Requires-Dist: pytest-rerunfailures==11.1
26
26
  Requires-Dist: pytest-timeout==2.2.0
27
+ Requires-Dist: dill==0.3.8
27
28
  Dynamic: author
28
29
  Dynamic: requires-dist
29
30
  Dynamic: requires-python
@@ -3,10 +3,10 @@ import traceback
3
3
  import pytest
4
4
  from box import Box
5
5
 
6
- from framework.utils.log_util import logger
6
+ from framework.exit_code import ExitCode
7
7
  from framework.db.mysql_db import MysqlDB
8
8
  from framework.db.redis_db import RedisDB
9
- from framework.exit_code import ExitCode
9
+ from framework.utils.log_util import logger
10
10
  from framework.http_client import ResponseUtil
11
11
  from framework.global_attribute import CONFIG, GlobalAttribute
12
12
 
@@ -16,6 +16,7 @@ class BaseTestCase(object):
16
16
  config: GlobalAttribute = None
17
17
  http = None
18
18
  data: Box = None
19
+ scenario: Box = None
19
20
  belong_app = None
20
21
  response: ResponseUtil = None
21
22
 
@@ -57,5 +58,17 @@ class BaseTestCase(object):
57
58
  traceback.print_exc()
58
59
  pytest.exit(ExitCode.LOAD_DATABASE_INFO_ERROR)
59
60
 
61
+ def context_set(self, app=None, *, key, value):
62
+ app = self.default_app(app)
63
+ self.context.set(app=app, key=key, value=value)
64
+
65
+ def context_get(self, app=None, *, key):
66
+ app = self.default_app(app)
67
+ return self.context.get(app=app, key=key)
68
+
69
+ def config_get(self, app=None, *, key):
70
+ app = self.default_app(app)
71
+ return self.config.get(app=app, key=key)
72
+
60
73
  def default_app(self, app):
61
74
  return app or self.belong_app
@@ -0,0 +1,422 @@
1
+ import os
2
+ import re
3
+ import sys
4
+ import copy
5
+ import importlib
6
+ import traceback
7
+ from pathlib import Path
8
+ from itertools import chain
9
+ from urllib.parse import urljoin
10
+ from collections import OrderedDict
11
+
12
+ import dill
13
+ import retry
14
+ import allure
15
+ import pytest
16
+ from box import Box
17
+
18
+ from framework.exit_code import ExitCode
19
+ from framework.utils.log_util import logger
20
+ from framework.render_data import RenderData
21
+ from framework.utils.yaml_util import YamlUtil
22
+ from framework.allure_report import generate_report
23
+ from framework.utils.common import snake_to_pascal, get_apps
24
+ from framework.global_attribute import CONTEXT, CONFIG, _FRAMEWORK_CONTEXT
25
+ from config.settings import DATA_DIR, CASES_DIR
26
+
27
+ all_app = get_apps()
28
+
29
+
30
+ def pytest_configure(config):
31
+ """
32
+ 初始化时被调用,可以用于设置全局状态或配置
33
+ :param config:
34
+ :return:
35
+ """
36
+
37
+ for app in all_app:
38
+ # 将所有app对应环境的基础测试数据加到全局
39
+ CONTEXT.set_from_yaml(f"config/{app}/context.yaml", CONTEXT.env, app)
40
+ # 将所有app对应环境的中间件配置加到全局
41
+ CONFIG.set_from_yaml(f"config/{app}/config.yaml", CONTEXT.env, app)
42
+ CONTEXT.set(key="all_app", value=all_app)
43
+ sys.path.append(CASES_DIR)
44
+
45
+
46
+ def pytest_addoption(parser):
47
+ parser.addini(name="ignore_error_and_continue", help="是否忽略失败case,继续执行")
48
+
49
+
50
+ def find_data_path_by_case(app, case_file_name):
51
+ """
52
+ 基于case文件名称查找与之对应的yml文件路径
53
+ :param app:
54
+ :param case_file_name:
55
+ :return:
56
+ """
57
+ for file_path in Path(os.path.join(DATA_DIR, app)).rglob(f"{case_file_name}.y*"):
58
+ if file_path:
59
+ return file_path
60
+
61
+
62
+ def __init_allure(params):
63
+ """设置allure中case的 title, description, level"""
64
+ case_level_map = {
65
+ "p0": allure.severity_level.BLOCKER,
66
+ "p1": allure.severity_level.CRITICAL,
67
+ "p2": allure.severity_level.NORMAL,
68
+ "p3": allure.severity_level.MINOR,
69
+ "p4": allure.severity_level.TRIVIAL,
70
+ }
71
+ allure.dynamic.title(params.get("title"))
72
+ allure.dynamic.description(params.get("describe"))
73
+ allure.dynamic.severity(case_level_map.get(params.get("level")))
74
+ allure.dynamic.feature(params.get("module"))
75
+ allure.dynamic.story(params.get("describe"))
76
+
77
+
78
+ def pytest_generate_tests(metafunc):
79
+ """
80
+ 生成(多个)对测试函数的参数化调用
81
+ :param metafunc:
82
+ :return:
83
+ """
84
+ # 获取当前待执行用例的文件名
85
+ module_name = metafunc.module.__name__.split('.')[-1]
86
+ func_file_path = metafunc.module.__file__
87
+ # 获取当前待执行用例的函数名
88
+ func_name = metafunc.function.__name__
89
+ if func_name in ["test_setup", "test_teardown"]:
90
+ return
91
+ # 获取测试用例所属app
92
+ belong_app = Path(func_file_path).relative_to(CASES_DIR).parts[0]
93
+ # 获取当前用例对应的测试数据路径
94
+ data_path = find_data_path_by_case(belong_app, module_name)
95
+
96
+ if not data_path:
97
+ logger.error(f"测试数据文件: {func_file_path} 不存在")
98
+ traceback.print_exc()
99
+ pytest.exit(ExitCode.CASE_YAML_NOT_EXIST)
100
+ test_data = YamlUtil(data_path).load_yml()
101
+ # 测试用例公共数据
102
+ case_common = test_data.get("case_common")
103
+ scenarios = case_common.get("scenarios")
104
+ # 测试用例数据
105
+ case_data = test_data.get(func_name)
106
+ if not case_data:
107
+ logger.error(f"测试方法: {func_name} 对应的测试数据不存在")
108
+ traceback.print_exc()
109
+ pytest.exit(ExitCode.CASE_DATA_NOT_EXIST)
110
+ if case_data.get("request") is None:
111
+ case_data["request"] = dict()
112
+ if case_data.get("request").get("headers") is None:
113
+ case_data["request"]["headers"] = dict()
114
+
115
+ # 合并测试数据
116
+ case_data.setdefault("module", case_common.get("module"))
117
+ case_data.setdefault("describe", case_common.get("describe"))
118
+ case_data["_belong_app"] = belong_app
119
+
120
+ domain = CONTEXT.get(key="domain", app=belong_app)
121
+ domain = domain if domain.startswith("http") else f"https://{domain}"
122
+ url = case_data.get("request").get("url")
123
+ method = case_data.get("request").get("method")
124
+ if not url:
125
+ if not case_common.get("url"):
126
+ logger.error(f"测试数据request中缺少必填字段: url", case_data)
127
+ pytest.exit(ExitCode.YAML_MISSING_FIELDS)
128
+ if case_common.get("url").strip().startswith("${"):
129
+ case_data["request"]["url"] = case_common.get("url")
130
+ else:
131
+ case_data["request"]["url"] = urljoin(domain, case_common.get("url"))
132
+ else:
133
+ if url.strip().startswith("${"):
134
+ case_data["request"]["url"] = url
135
+ else:
136
+ case_data["request"]["url"] = urljoin(domain, url)
137
+
138
+ if not method:
139
+ if not case_common.get("method"):
140
+ logger.error(f"测试数据request中缺少必填字段: method", case_data)
141
+ pytest.exit(ExitCode.YAML_MISSING_FIELDS)
142
+ case_data["request"]["method"] = case_common.get("method")
143
+
144
+ for key in ["title", "level"]:
145
+ if key not in case_data:
146
+ logger.error(f"测试数据{func_name}中缺少必填字段: {key}", case_data)
147
+ pytest.exit(ExitCode.YAML_MISSING_FIELDS)
148
+
149
+ if case_data.get("mark"):
150
+ metafunc.function.marks = [case_data.get("mark"), case_data.get("level")]
151
+ else:
152
+ metafunc.function.marks = [case_data.get("level")]
153
+
154
+ case_data_list = list()
155
+ if scenarios:
156
+ ids = list()
157
+ for index, item in enumerate(scenarios):
158
+ if case_common.get("ignore"):
159
+ continue
160
+ _mark = CONTEXT.get("mark")
161
+ if _mark:
162
+ flag = item.get("scenario").get("flag")
163
+ if flag != _mark:
164
+ continue
165
+ deep_copied_case_data = copy.deepcopy(case_data)
166
+ try:
167
+ deep_copied_case_data["_scenario"] = item.get("scenario")
168
+ case_data_list.append(deep_copied_case_data)
169
+ ids.append(case_data.get("title") + f"#{index + 1}")
170
+ except KeyError as e:
171
+ logger.error(f"parametrize参数化格式不正确:{e}")
172
+ traceback.print_exc()
173
+ pytest.exit(ExitCode.PARAMETRIZE_ATTRIBUTE_NOT_EXIT)
174
+ metafunc.parametrize("data", case_data_list, ids=ids, scope="function")
175
+ else:
176
+ if not case_common.get("ignore"):
177
+ case_data_list = [case_data]
178
+ # 进行参数化生成用例
179
+ metafunc.parametrize("data", case_data_list, ids=[f'{case_data.get("title")}#1'], scope="function")
180
+
181
+
182
+ def pytest_collection_modifyitems(items):
183
+ for item in items:
184
+ try:
185
+ marks = item.function.marks
186
+ for mark in marks:
187
+ if isinstance(mark, list):
188
+ for _ in mark:
189
+ item.add_marker(_)
190
+ else:
191
+ item.add_marker(mark)
192
+ except Exception:
193
+ pass
194
+
195
+
196
+ def pytest_collection_finish(session):
197
+ """获取最终排序后的 items 列表"""
198
+ # 过滤掉item名称是test_setup或test_teardown的
199
+ session.items = [item for item in session.items if item.name not in ["test_setup", "test_teardown"]]
200
+
201
+ # 1. 筛选出带井号 名称带'#' 的item,并记录原始索引
202
+ hash_items_with_index = [(index, item) for index, item in enumerate(session.items) if "#" in item.name]
203
+
204
+ # 2. 按照 'cls' 对带井号的元素进行分组
205
+ grouped_by_cls = {}
206
+ for index, item in hash_items_with_index:
207
+ cls = item.cls.__module__ + item.parent.name
208
+ if cls not in grouped_by_cls:
209
+ grouped_by_cls[cls] = []
210
+ grouped_by_cls[cls].append((index, item)) # 记录索引和元素
211
+
212
+ # 3. 对每个 cls 分组内的带井号的元素进行排序
213
+ for cls, group in grouped_by_cls.items():
214
+ group_values = [x[1] for x in group]
215
+ # 获取item#号后面的数字
216
+ pattern = r"#(\d+)]"
217
+ grouped_data = OrderedDict()
218
+ # 按照#号后面的数字进行排序并分组
219
+ for item in group_values:
220
+ index = re.search(pattern, item.name).group(1)
221
+ grouped_data.setdefault(index, []).append(item)
222
+ # 标记每个分组的第一个和最后一个
223
+ for group2 in grouped_data.values():
224
+ group2[0].funcargs["first"] = True
225
+ group2[-1].funcargs["last"] = True
226
+
227
+ group_values = list(chain.from_iterable(grouped_data.values()))
228
+
229
+ # 4. 将排序后的items放回原列表
230
+ for (original_index, _), val in zip(group, group_values):
231
+ session.items[original_index] = val # 将反转后的元素替换回原位置
232
+
233
+
234
+ def pytest_runtestloop(session):
235
+ _FRAMEWORK_CONTEXT.set(key="_http", value=login())
236
+
237
+
238
+ def pytest_runtest_setup(item):
239
+ if item.funcargs.get("first"):
240
+ test_object = item.instance
241
+ test_object.context = CONTEXT
242
+ test_object.config = CONFIG
243
+ test_object.http = _FRAMEWORK_CONTEXT.get(key="_http")
244
+ data = item.callspec.params.get("data")
245
+ test_object.data = Box(data)
246
+ test_object.scenario = Box(data.get("_scenario").get("data"))
247
+ test_object.belong_app = data.get("_belong_app")
248
+ test_setup = getattr(test_object, "test_setup", None)
249
+ if test_setup:
250
+ try:
251
+ test_setup()
252
+ item.funcargs["setup_success"] = True
253
+ except Exception as e:
254
+ item.funcargs["setup_success"] = False
255
+ traceback.print_exc()
256
+ logger.error(f"{item.location[0]} test_setup方法执行异常:{e}")
257
+
258
+
259
+ def pytest_runtest_call(item):
260
+ """
261
+ 模版渲染,运行用例
262
+ :param item:
263
+ :return:
264
+ """
265
+ ignore_error_and_continue = item.config.getini("ignore_error_and_continue")
266
+ if ignore_error_and_continue == "false":
267
+ # setup方法执行失败,则主动标记用例执行失败,不会执行用例
268
+ if item.funcargs.get("setup_success") is False:
269
+ pytest.skip(f"{item.nodeid} test_setup execute error")
270
+ # 判断上一个用例是否执行失败,如果上一个用例执行失败,则主动标记用例执行失败,不会执行用例(解决场景性用例,有一个失败则后续用例判为失败)
271
+ index = item.session.items.index(item)
272
+ current_cls_name = item.parent.name
273
+ # 向前遍历,找到属于同一个类的用例
274
+ pattern = r"#(\d+)]"
275
+ current_turn = re.search(pattern, item.name)
276
+ if current_turn:
277
+ for prev_item in reversed(item.session.items[:index]): # 只遍历当前 item 之前的
278
+ if prev_item.parent.name == current_cls_name and re.search(pattern, prev_item.name).group(
279
+ 1) == current_turn.group(1): # 确保是同一个类
280
+ status = getattr(prev_item, "status", None) # 访问 _status 属性
281
+ if status == "skipped":
282
+ pytest.skip(f"the test_setup method execution error")
283
+ elif status == "failed":
284
+ pytest.skip(f"the previous method execution failed")
285
+
286
+ # 获取原始测试数据
287
+ origin_data = item.funcargs.get("data")
288
+ __init_allure(origin_data)
289
+ logger.info(f"执行用例: {item.nodeid}")
290
+ # 对原始请求数据进行渲染替换
291
+ rendered_data = RenderData(origin_data).render()
292
+ # 函数式测试用例添加参数data, belong_app
293
+ http = item.funcargs.get("http")
294
+ item.funcargs["data"] = Box(rendered_data)
295
+ item.funcargs["scenario"] = Box(rendered_data.get("_scenario").get("data"))
296
+ item.funcargs["belong_app"] = origin_data.get("_belong_app")
297
+ item.funcargs["config"] = CONFIG
298
+ item.funcargs["context"] = CONTEXT
299
+ # 类式测试用例添加参数http,data, belong_app
300
+ item.instance.http = http
301
+ item.instance.data = Box(rendered_data)
302
+ item.instance.scenario = Box(rendered_data.get("_scenario").get("data"))
303
+ item.instance.belong_app = origin_data.get("_belong_app")
304
+ item.instance.context = CONTEXT
305
+ item.instance.config = CONFIG
306
+
307
+ # 获取测试函数体内容
308
+ func_source = re.sub(r'(?<!["\'])#.*', '', dill.source.getsource(item.function))
309
+ # 校验测试用例中是否有断言
310
+ if "assert" not in func_source:
311
+ logger.error(f"测试方法:{item.originalname}缺少断言")
312
+ pytest.exit(ExitCode.MISSING_ASSERTIONS)
313
+
314
+
315
+ def pytest_runtest_teardown(item):
316
+ if item.funcargs.get("last") and getattr(item, "status", None) not in ["skipped", "failed"]:
317
+ test_object = item.instance
318
+ test_teardown = getattr(test_object, "test_teardown", None)
319
+ if test_teardown:
320
+ try:
321
+ test_teardown()
322
+ except Exception as e:
323
+ pytest.fail(f"the test_teardown method execution error: {e}")
324
+
325
+
326
+ @pytest.hookimpl(tryfirst=True, hookwrapper=True)
327
+ def pytest_runtest_makereport(item, call):
328
+ """拦截 pytest 生成测试报告,移除特定用例的统计"""
329
+ outcome = yield
330
+ report = outcome.get_result()
331
+ # 将测试结果存储到 item 对象的自定义属性 `_test_status`
332
+ if report.when == "call": # 只记录测试执行阶段的状态,不包括 setup/teardown
333
+ item.status = report.outcome # 'passed', 'failed', or 'skipped'
334
+
335
+
336
+ def pytest_terminal_summary(terminalreporter, exitstatus, config):
337
+ """在 pytest 结束后修改统计数据或添加自定义报告"""
338
+ stats = terminalreporter.stats
339
+ # 统计各种测试结果
340
+ passed = len(stats.get("passed", []))
341
+ failed = len(stats.get("failed", []))
342
+ skipped = len(stats.get("skipped", []))
343
+ total = passed + failed + skipped
344
+ try:
345
+ pass_rate = round(passed / (total - skipped) * 100, 2)
346
+ except ZeroDivisionError:
347
+ pass_rate = 0
348
+ # 打印自定义统计信息
349
+ terminalreporter.write("\n============ 执行结果统计 ============\n", blue=True, bold=True)
350
+ terminalreporter.write(f"执行用例总数: {passed + failed + skipped}\n", bold=True)
351
+ terminalreporter.write(f"通过用例数: {passed}\n", green=True, bold=True)
352
+ terminalreporter.write(f"失败用例数: {failed}\n", red=True, bold=True)
353
+ terminalreporter.write(f"跳过用例数: {skipped}\n", yellow=True, bold=True)
354
+ terminalreporter.write(f"用例通过率: {pass_rate}%\n", green=True, bold=True)
355
+ terminalreporter.write("====================================\n", blue=True, bold=True)
356
+ # 生成allure测试报告
357
+ generate_report()
358
+
359
+
360
+ def pytest_exception_interact(node, call, report):
361
+ """
362
+ 用例执行抛出异常时,将异常记录到日志
363
+ :param node:
364
+ :param call:
365
+ :param report:
366
+ :return:
367
+ """
368
+ if call.excinfo.type is AssertionError:
369
+ logger.error(f"{node.nodeid} failed: {call.excinfo.value}\n")
370
+
371
+
372
+ @pytest.fixture(autouse=True)
373
+ def response():
374
+ response = None
375
+ yield response
376
+
377
+
378
+ @pytest.fixture(autouse=True)
379
+ def data():
380
+ data: dict = dict()
381
+ yield data
382
+
383
+
384
+ @pytest.fixture(autouse=True)
385
+ def belong_app():
386
+ app = None
387
+ yield app
388
+
389
+
390
+ @pytest.fixture(autouse=True)
391
+ def config():
392
+ config = None
393
+ yield config
394
+
395
+
396
+ @pytest.fixture(autouse=True)
397
+ def context():
398
+ context = None
399
+ yield context
400
+
401
+
402
+ @retry.retry(tries=3, delay=1)
403
+ @pytest.fixture(scope="session", autouse=True)
404
+ def http():
405
+ yield Http
406
+
407
+
408
+ class Http(object):
409
+ ...
410
+
411
+
412
+ def login():
413
+ module = importlib.import_module("conftest")
414
+ try:
415
+ for app in all_app:
416
+ setattr(Http, app, getattr(module, f"{snake_to_pascal(app)}Login")(app))
417
+ return Http
418
+ except Exception as e:
419
+ logger.error(f"登录{app}异常:{e}")
420
+ traceback.print_exc()
421
+ pytest.exit(ExitCode.LOGIN_ERROR)
422
+ return None
@@ -13,9 +13,9 @@ class MysqlDB(object):
13
13
 
14
14
  self.pool = PooledDB(
15
15
  creator=pymysql,
16
- maxconnections=1,
16
+ maxconnections=10,
17
17
  mincached=1,
18
- maxcached=1,
18
+ maxcached=2,
19
19
  blocking=True,
20
20
  maxusage=None,
21
21
  setsession=[],
@@ -7,9 +7,9 @@ import pytest
7
7
  from box import Box
8
8
  from jsonpath import jsonpath
9
9
 
10
+ from framework.exit_code import ExitCode
10
11
  from framework.utils.common import is_digit
11
12
  from framework.utils.log_util import logger
12
- from framework.exit_code import ExitCode
13
13
  from framework.global_attribute import CONTEXT
14
14
 
15
15
 
@@ -55,7 +55,7 @@ class Extract(object):
55
55
 
56
56
  except Exception as e:
57
57
  logger.error(f"jsonpath表达式错误或响应内容异常{e} 表达式: {expression};响应内容: {self.response.json()}")
58
- raise Exception(f"jsonpath表达式错误或响应内容异常{e} 表达式: {expression};响应内容: {self.response.json()}")
58
+ logger.error(f"后置提取变量{key}失败")
59
59
 
60
60
  def extract_by_regex(self, key, reg_expression):
61
61
  try:
@@ -69,9 +69,9 @@ class Extract(object):
69
69
  self.context.set(key, extract_value, self.belong_app)
70
70
  logger.info(f"后置提取变量: {key}: {extract_value}")
71
71
 
72
- except Exception:
73
- logger.error(f"正则表达式或响应内容异常。表达式: {reg_expression}; 响应内容: {self.response.text}")
74
- raise Exception(f"正则表达式或响应内容异常。表达式: {reg_expression}; 响应内容: {self.response.text}")
72
+ except Exception as e:
73
+ logger.error(f"正则表达式或响应内容异常{e} 表达式: {reg_expression}; 响应内容: {self.response.text}")
74
+ logger.error(f"后置提取变量{key}失败")
75
75
 
76
76
  def extract_by_box(self, key, expression):
77
77
  if key.startswith(tuple(f"{app}." for app in self.context.all_app)):
@@ -79,7 +79,7 @@ class Extract(object):
79
79
  extract_value = self.get_nested_value(Box(self.response.json()), expression)
80
80
  if not extract_value:
81
81
  logger.error(f"box表达式或响应内容异常 表达式: {expression}; 响应内容: {self.response.json()}")
82
- raise Exception(f"box表达式或响应内容异常 表达式: {expression}; 响应内容: {self.response.json()}")
82
+ logger.error(f"后置提取变量{key}失败")
83
83
  else:
84
84
  with allure.step(f"后置提取变量: {key}: {extract_value}"):
85
85
  self.context.set(key, extract_value, self.belong_app)
@@ -5,10 +5,10 @@ import pytest
5
5
  from box import Box
6
6
  from box.exceptions import BoxError
7
7
 
8
- from framework.utils.log_util import logger
9
- from framework.utils.common import singleton
10
8
  from config.settings import ROOT_DIR
11
9
  from framework.exit_code import ExitCode
10
+ from framework.utils.log_util import logger
11
+ from framework.utils.common import singleton
12
12
 
13
13
 
14
14
  class GlobalAttribute(object):
@@ -94,7 +94,14 @@ class Config(GlobalAttribute):
94
94
  ...
95
95
 
96
96
 
97
+ @singleton
98
+ class FrameworkContext(GlobalAttribute):
99
+ ...
100
+
101
+
97
102
  # 创建管理变量的全局对象,用于存储临时变量
98
103
  CONTEXT = Context()
99
104
  # 创建配置内容管理的全局对象
100
105
  CONFIG = Config()
106
+
107
+ _FRAMEWORK_CONTEXT = FrameworkContext()
@@ -4,8 +4,8 @@ from urllib.parse import urljoin
4
4
 
5
5
  import allure
6
6
  import requests
7
- from box import Box
8
7
  import json as built_json
8
+ from box import Box, BoxList
9
9
  from jsonpath import jsonpath as jp
10
10
  from requests.models import Response
11
11
  from framework.extract import Extract
@@ -17,7 +17,12 @@ from requests_toolbelt import MultipartEncoder
17
17
  class ResponseUtil(object):
18
18
  def __init__(self, response: Response):
19
19
  self.response = response
20
- self.box = Box(response.json())
20
+ if isinstance(self.response.json(), dict):
21
+ self.box = Box(self.response.json())
22
+ elif isinstance(self.response.json(), list):
23
+ self.box = BoxList(self.response.json())
24
+ else:
25
+ self.box = self.response.text
21
26
 
22
27
  def __getattr__(self, name):
23
28
  try:
@@ -94,7 +99,8 @@ class HttpClient(object):
94
99
  if _is_multipart:
95
100
  m = MultipartEncoder(fields=request_obj["data"])
96
101
  request_obj["data"] = m
97
- request_obj["headers"] = {'Content-Type': m.content_type}
102
+ request_obj["headers"].update(self.headers)
103
+ request_obj["headers"]['Content-Type'] = m.content_type
98
104
  else:
99
105
  request_obj["headers"].update(self.headers)
100
106
  self.response = ResponseUtil(requests.request(**request_obj))
@@ -105,7 +111,7 @@ class HttpClient(object):
105
111
  logger.info(f"请求method: {request_obj.get('method')}")
106
112
  with allure.step(f"请求headers: {request_obj.get('headers')}"):
107
113
  logger.info(f"请求headers: {request_obj.get('headers')}")
108
- if request_obj.get('request'):
114
+ if request_obj.get('params'):
109
115
  with allure.step(f"请求参数params: {request_obj.get('params')}"):
110
116
  logger.info(f"请求参数params: {request_obj.get('params')}")
111
117
  if request_obj.get('data'):
@@ -6,8 +6,8 @@ import traceback
6
6
  import allure
7
7
  import pytest
8
8
  from faker import Faker
9
- from framework.utils.log_util import logger
10
9
  from framework.exit_code import ExitCode
10
+ from framework.utils.log_util import logger
11
11
  from framework.global_attribute import CONTEXT
12
12
  from config.settings import FAKER_LANGUAGE, DATA_DIR
13
13
 
@@ -31,6 +31,7 @@ class RenderData(object):
31
31
  def __init__(self, data):
32
32
  self.data = data
33
33
  self.request = data.get("request")
34
+ self.scenario = data.get("_scenario").get("data")
34
35
  self.context = CONTEXT
35
36
  self.faker = SingletonFaker(locale=FAKER_LANGUAGE).faker
36
37
  self._is_multipart = False
@@ -41,11 +42,39 @@ class RenderData(object):
41
42
  :return:
42
43
  """
43
44
  with allure.step("渲染数据"):
45
+ if self.scenario:
46
+ self.request = self.replace_variables_recursive(self.request, self.scenario)
44
47
  self.replace_attribute(self.request)
45
48
  self.data["request"] = self.request
46
49
  self.data["_is_multipart"] = self._is_multipart
47
50
  return self.data
48
51
 
52
+ def replace_variables_recursive(self, data, params):
53
+ """
54
+ 递归遍历 data 并用 params[key] 替换 ${key} 变量,保持数据类型
55
+ """
56
+
57
+ if isinstance(data, dict): # 处理字典
58
+ new_dict = {}
59
+ for key, value in data.items():
60
+ new_dict[key] = self.replace_variables_recursive(value, params) # 递归处理
61
+ return new_dict
62
+
63
+ elif isinstance(data, list): # 处理列表
64
+ return [self.replace_variables_recursive(item, params) for item in data]
65
+
66
+ elif isinstance(data, str) and data.startswith("${") and data.endswith("}"):
67
+ # 仅当字符串是完整的 "${key}" 格式时才替换
68
+ key = data[2:-1] # 提取 key 名称
69
+ if key in params.keys():
70
+ value = params[key]
71
+ logger.info(f"前置读取变量: {key}: {value}")
72
+ return value # 直接赋值(不存在则返回原值)
73
+ return data
74
+
75
+ else: # 其他类型(int, float, bool, None)直接返回
76
+ return data
77
+
49
78
  def replace_attribute(self, data):
50
79
  pattern = re.compile(r"\$\{([\w.\[\]0-9]+(?:\(\w*(?:,\w*)*\))?)}")
51
80
  file_path_pattern = re.compile(
@@ -1,8 +1,8 @@
1
1
  import json
2
2
  import argparse
3
- from pathlib import Path
4
3
  import datetime
5
4
  import traceback
5
+ from pathlib import Path
6
6
  from framework.db.mysql_db import MysqlDB
7
7
  from config.settings import DATABASE_HOST, DATABASE_PASSWORD, DATABASE_DB, DATABASE_USERNAME, DATABASE_PORT
8
8
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-api-framework-alpha
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Author: alpha
5
5
  Author-email:
6
6
  Requires-Python: >=3.6
@@ -24,6 +24,7 @@ Requires-Dist: requests-toolbelt==1.0.0
24
24
  Requires-Dist: retry==0.9.2
25
25
  Requires-Dist: pytest-rerunfailures==11.1
26
26
  Requires-Dist: pytest-timeout==2.2.0
27
+ Requires-Dist: dill==0.3.8
27
28
  Dynamic: author
28
29
  Dynamic: requires-dist
29
30
  Dynamic: requires-python
@@ -18,3 +18,4 @@ requests-toolbelt==1.0.0
18
18
  retry==0.9.2
19
19
  pytest-rerunfailures==11.1
20
20
  pytest-timeout==2.2.0
21
+ dill==0.3.8
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="pytest-api-framework-alpha", # 包名(必须唯一)
5
- version="0.1.0",
5
+ version="0.1.2",
6
6
  packages=find_packages(),
7
7
  author="alpha",
8
8
  author_email="",
@@ -28,7 +28,8 @@ setup(
28
28
  "requests-toolbelt==1.0.0",
29
29
  "retry==0.9.2",
30
30
  "pytest-rerunfailures==11.1",
31
- "pytest-timeout==2.2.0"
31
+ "pytest-timeout==2.2.0",
32
+ "dill==0.3.8"
32
33
  ]
33
34
 
34
35
  )
@@ -1,523 +0,0 @@
1
- import os
2
- import re
3
- import sys
4
- import copy
5
- import inspect
6
- import platform
7
- import importlib
8
- import traceback
9
- from pathlib import Path
10
- from urllib.parse import urljoin
11
-
12
- import retry
13
- import allure
14
- import pytest
15
- from box import Box
16
- from itertools import groupby
17
- from framework.utils.log_util import logger
18
- from framework.utils.yaml_util import YamlUtil
19
- from framework.utils.encrypt import RsaByPubKey
20
- from framework.utils.teams_util import TeamsUtil
21
- from framework.utils.common import generate_2fa_code, snake_to_pascal, get_apps
22
- from framework.exit_code import ExitCode
23
- from framework.http_client import HttpClient
24
- from framework.render_data import RenderData
25
- from framework.allure_report import generate_report
26
- from framework.global_attribute import CONTEXT, CONFIG
27
- from config.settings import DATA_DIR, CASES_DIR, ROOT_DIR
28
-
29
- test_results = {"total": 0, "passed": 0, "failed": 0, "skipped": 0}
30
- # 获取当前系统的文件分隔符
31
- file_separator = os.sep
32
-
33
- # 判断用例是否需要被跳过
34
- should_skip = False
35
- all_app = get_apps()
36
-
37
-
38
- def pytest_configure(config):
39
- """
40
- 初始化时被调用,可以用于设置全局状态或配置
41
- :param config:
42
- :return:
43
- """
44
-
45
- for app in all_app:
46
- # 将所有app对应环境的基础测试数据加到全局
47
- CONTEXT.set_from_yaml(f"config/{app}/context.yaml", CONTEXT.env, app)
48
- # 将所有app对应环境的中间件配置加到全局
49
- CONFIG.set_from_yaml(f"config/{app}/config.yaml", CONTEXT.env, app)
50
- CONTEXT.set(key="all_app", value=all_app)
51
- CONTEXT.init_test_case_data_dict()
52
- sys.path.append(CASES_DIR)
53
-
54
-
55
- def find_data_path_by_case(app, case_file_name):
56
- """
57
- 基于case文件名称查找与之对应的yml文件路径
58
- :param app:
59
- :param case_file_name:
60
- :return:
61
- """
62
- for file_path in Path(os.path.join(DATA_DIR, app)).rglob(f"{case_file_name}.y*"):
63
- if file_path:
64
- return file_path
65
-
66
-
67
- def __init_allure(params):
68
- """设置allure中case的 title, description, level"""
69
- case_level_map = {
70
- "p0": allure.severity_level.BLOCKER,
71
- "p1": allure.severity_level.CRITICAL,
72
- "p2": allure.severity_level.NORMAL,
73
- "p3": allure.severity_level.MINOR,
74
- "p4": allure.severity_level.TRIVIAL,
75
- }
76
- allure.dynamic.title(params.get("title"))
77
- allure.dynamic.description(params.get("describe"))
78
- allure.dynamic.severity(case_level_map.get(params.get("level")))
79
- allure.dynamic.feature(params.get("module"))
80
- allure.dynamic.story(params.get("describe"))
81
-
82
-
83
- def pytest_generate_tests(metafunc):
84
- """
85
- 生成(多个)对测试函数的参数化调用
86
- :param metafunc:
87
- :return:
88
- """
89
- # 获取测试函数所属的模块
90
- module = metafunc.module
91
- # 获取模块的文件路径
92
- class_path = os.path.abspath(module.__file__)
93
- root_path = ROOT_DIR + file_separator
94
- class_id = class_path.replace(root_path, "").replace(file_separator, '.')
95
- # 获取测试函数名
96
- function_name = metafunc.function.__name__
97
- # 构建全路径
98
- full_id = f"{class_id}::{function_name}"
99
-
100
- # print(f"测试用例全路径: {full_id}")
101
- # 获取当前待执行用例的文件名
102
- module_name = metafunc.module.__name__.split('.')[-1]
103
- # 获取当前待执行用例的函数名
104
- func_name = metafunc.function.__name__
105
- # 获取测试用例所属app
106
- belong_app = Path(class_path).relative_to(CASES_DIR).parts[0]
107
- # 获取当前用例对应的测试数据路径
108
- data_path = find_data_path_by_case(belong_app, module_name)
109
-
110
- if not data_path:
111
- logger.error(f"未找到{metafunc.module.__file__}对应的测试数据文件")
112
- traceback.print_exc()
113
- pytest.exit(ExitCode.CASE_YAML_NOT_EXIST)
114
- test_data = YamlUtil(data_path).load_yml()
115
- # 测试用例公共数据
116
- case_common = test_data.get("case_common")
117
- # 标记用例是否跳过
118
- metafunc.function.ignore = case_common.get("ignore")
119
-
120
- scenario_list = case_common.get('scenarios')
121
-
122
- test_case_datas = []
123
-
124
- if scenario_list is None:
125
- test_case_datas = [{'casedata': {}}]
126
- else:
127
- for scenario in scenario_list:
128
- if hasattr(CONTEXT, "mark"):
129
- if scenario.get('scenario') is not None and str(scenario.get('scenario').get('flag')) == CONTEXT.mark:
130
- test_case_datas.append({'casedata': scenario.get('scenario').get('data')})
131
- else:
132
- if scenario.get('scenario') is not None and scenario.get('scenario').get('data') is not None:
133
- test_case_datas.append({'casedata': scenario.get('scenario').get('data')})
134
-
135
- if len(scenario_list) == 0:
136
- test_case_datas = [{'casedata': {}}]
137
-
138
- case_data_list = []
139
- ids = []
140
- case_suite_index = 0
141
-
142
- test_case_data_dict = dict()
143
-
144
- # 是否是@test_suite_setup标识的setup方法
145
- is_test_setup = 'test_setup' in metafunc.definition.keywords
146
- is_test_teardown = 'test_teardown' in metafunc.definition.keywords
147
- if is_test_setup or is_test_teardown:
148
- for test_case_data in test_case_datas:
149
- case_suite_index += 1
150
- test_case_data_dict[f'{full_id}#{case_suite_index}'] = test_case_data
151
- case_data_list.append(test_case_data)
152
- ids.append(f'{full_id}#{str(case_suite_index)}')
153
-
154
- else:
155
-
156
- for test_case_data in test_case_datas:
157
- case_suite_index += 1
158
- test_case_data_dict[f'{full_id}#{case_suite_index}'] = test_case_data
159
-
160
- # 测试用例数据
161
- case_data = test_data.get(func_name)
162
-
163
- if not is_test_setup and not case_data:
164
- logger.error(f"未找到用例{func_name}对应的数据")
165
- pytest.exit(ExitCode.CASE_DATA_NOT_EXIST)
166
-
167
- if case_data.get("request") is None:
168
- case_data["request"] = dict()
169
- if case_data.get("request").get("headers") is None:
170
- case_data["request"]["headers"] = dict()
171
-
172
- # 合并测试数据
173
- case_data.setdefault("module", case_common.get("module"))
174
- case_data.setdefault("describe", case_common.get("describe"))
175
- case_data["_belong_app"] = belong_app
176
-
177
- if case_data.get("request").get("url") is not None or case_common.get("url") is not None:
178
- # 是一个带请求的case 反之 表示这条case不是一个带请求的case
179
- domain = CONTEXT.get(key="domain", app=belong_app)
180
- domain = domain if domain.startswith("http") else f"https://{domain}"
181
- url = case_data.get("request").get("url")
182
- if url.startswith('${'):
183
- placeholder = url[2:len(url) - 1]
184
- actual_url = CONTEXT.get(key=placeholder, app=belong_app)
185
- if actual_url:
186
- url = actual_url
187
- method = case_data.get("request").get("method")
188
- if not url:
189
- if not case_common.get("url"):
190
- logger.error(f"测试数据request中缺少必填字段: url", case_data)
191
- pytest.exit(ExitCode.YAML_MISSING_FIELDS)
192
- case_data["request"]["url"] = urljoin(domain, case_common.get("url"))
193
- else:
194
- case_data["request"]["url"] = urljoin(domain, url)
195
-
196
- if not method:
197
- if not case_common.get("method"):
198
- logger.error(f"测试数据request中缺少必填字段: method", case_data)
199
- pytest.exit(ExitCode.YAML_MISSING_FIELDS)
200
- case_data["request"]["method"] = case_common.get("method")
201
-
202
- for key in ["title"]:
203
- if key not in case_data:
204
- logger.error(f"测试数据{func_name}中缺少必填字段: {key}", case_data)
205
- pytest.exit(ExitCode.YAML_MISSING_FIELDS)
206
-
207
- # 给用例打order标记
208
- if case_data.get("order", None):
209
- metafunc.function.order = int(case_data.get("order"))
210
- else:
211
- metafunc.function.order = None
212
-
213
- # 给用例打mark标记
214
- if case_data.get("mark", None):
215
- metafunc.function.marks = [case_data.get("mark"), case_data.get("level")]
216
- else:
217
- metafunc.function.marks = [case_data.get("level")]
218
-
219
- case_data_list.append(case_data)
220
- ids.append(f'{full_id}#{case_suite_index}')
221
-
222
- CONTEXT.set(key=f'{full_id}#test_case_datas', value=test_case_data_dict)
223
-
224
- metafunc.parametrize("data", case_data_list, ids=ids, scope='function')
225
-
226
-
227
- @pytest.hookimpl
228
- def pytest_runtest_setup(item):
229
- allure.dynamic.sub_suite(item.allure_suite_mark)
230
-
231
-
232
- def pytest_collection_modifyitems(items):
233
- # 重新排序
234
- new_items = sort(items)
235
- # Demo: new_items = [items[0],items[2],items[1],items[3]]
236
- items[:] = new_items
237
- for item in items:
238
- # 用例打标记
239
- try:
240
- marks = item.function.marks
241
- for mark in marks:
242
- item.add_marker(mark)
243
- # 用例排序
244
- order = item.function.order
245
- if order:
246
- item.add_marker(pytest.mark.order(order))
247
- except Exception: # 忽略test_setup,test_teardown方法
248
- pass
249
-
250
-
251
- def __get_group_key__(item):
252
- return '::'.join(item.nodeid.split('::')[:2])
253
-
254
-
255
- def sort(case_items):
256
- # 按测试类全路径分类,同一个类文件的用例归集到一起
257
- # 使用 groupby 函数进行分组
258
- item_group_list = [list(group) for _, group in groupby(case_items, key=__get_group_key__)]
259
-
260
- all_item_list = []
261
- clase_id = None
262
- for items in item_group_list:
263
- # 找出被test_setup标记的方法
264
- custom_scope_setup_items = [item for item in items if 'test_setup' in item.keywords]
265
- custom_scope_teardown_items = [item for item in items if 'test_teardown' in item.keywords]
266
- # 未被test_setup/test_teardown 标记的test方法
267
- non_custom_scope_items = [item for item in items if
268
- 'test_setup' not in item.keywords and 'test_teardown' not in item.keywords]
269
- item_list = []
270
- # 用例的组数
271
- case_suite_num = 0
272
- # 生成每个组当前的索引
273
- ori_name_temp = None
274
- ori_name_list = []
275
-
276
- for item in non_custom_scope_items:
277
- clase_id = item.cls.__name__
278
- original_name = item.originalname
279
-
280
- if ori_name_temp is None or ori_name_temp == original_name:
281
- ori_name_temp = original_name
282
- case_suite_num += 1
283
- ori_name_list.append([original_name, item])
284
- else:
285
- break
286
-
287
- # 根据组数 创建各组的数组 并插入第一个case
288
- case_dict = dict()
289
- for i in range(case_suite_num):
290
- item = ori_name_list[i][1]
291
- id = item.callspec.id
292
-
293
- first_part = id.split('#', 1)[-1]
294
- index = first_part.split(']')[0]
295
- case_dict[index] = [item]
296
-
297
- new_start_index = case_suite_num
298
- # 以new_start_index为起点 重新遍历items
299
- for i in range(new_start_index, len(non_custom_scope_items)):
300
- item = non_custom_scope_items[i]
301
- id = item.callspec.id
302
- first_part = id.split('#', 1)[-1]
303
- index = first_part.split(']')[0]
304
- case_dict.get(index).append(item)
305
-
306
- setup_dict = dict()
307
- for item in custom_scope_setup_items:
308
- id = item.callspec.id
309
- first_part = id.split('#', 1)[-1]
310
- index = first_part.split(']')[0]
311
- setup_dict[index] = [item]
312
-
313
- teardown_dict = dict()
314
- for item in custom_scope_teardown_items:
315
- id = item.callspec.id
316
- first_part = id.split('#', 1)[-1]
317
- index = first_part.split(']')[0]
318
- teardown_dict[index] = [item]
319
-
320
- index = 0
321
- for id in case_dict:
322
- index += 1
323
- if setup_dict:
324
- setup_item_list = setup_dict.get(id)
325
- for item in setup_item_list:
326
- allure_suite_mark = f'{clase_id}#{index}'
327
- setattr(item, 'allure_suite_mark', allure_suite_mark)
328
-
329
- item_list += setup_item_list
330
-
331
- case_item_list = case_dict.get(id)
332
- for item in case_item_list:
333
- allure_suite_mark = f'{clase_id}#{index}'
334
- setattr(item, 'allure_suite_mark', allure_suite_mark)
335
- item_list += case_item_list
336
-
337
- if teardown_dict:
338
- teardown_item_list = teardown_dict.get(id)
339
- for item in teardown_item_list:
340
- allure_suite_mark = f'{clase_id}#{index}'
341
- setattr(item, 'allure_suite_mark', allure_suite_mark)
342
- item_list += teardown_item_list
343
-
344
- all_item_list += item_list
345
-
346
- return all_item_list
347
-
348
-
349
- def pytest_runtest_call(item):
350
- """
351
- 模版渲染,运行用例
352
- :param item:
353
- :return:
354
- """
355
- # 是否是test—setup 或 test-teardown方法
356
- is_around_function = item.get_closest_marker("test_setup") or item.get_closest_marker("test_teardown")
357
- if not is_around_function:
358
- if item.function.ignore:
359
- # 如果是普通方法 而且由于之前步骤出错则skiped
360
- pytest.skip('skiped')
361
-
362
- id = item.callspec.id
363
-
364
- # 支持本地调试的标记
365
- CONTEXT.set(key='run#index', value=id.split('::')[1])
366
-
367
- # 获取参数化数据 放入上下文中
368
- full_id = id.split('#')[0]
369
- test_case_datas = CONTEXT.get(key=f'{full_id}#test_case_datas')
370
- CONTEXT.set_from_dict(test_case_datas.get(id))
371
-
372
- # 获取原始测试数据
373
- origin_data = item.funcargs.get("data")
374
- # 深拷贝这份数据
375
- deep_copied_origin_data = copy.deepcopy(origin_data)
376
- item.funcargs["origin_data"] = Box(origin_data)
377
-
378
- __init_allure(origin_data)
379
- logger.info(f"执行用例: {item.nodeid}")
380
- # 对原始请求数据进行渲染替换
381
- rendered_data = RenderData(deep_copied_origin_data).render()
382
- # 测试用例函数添加参数data
383
- http = item.funcargs.get("http")
384
- item.funcargs["data"] = Box(rendered_data)
385
- item.funcargs["belong_app"] = origin_data.get("_belong_app")
386
- item.funcargs["config"] = CONFIG
387
- item.funcargs["context"] = CONTEXT
388
- if item.cls:
389
- item.cls.http = http
390
- item.cls.data = Box(rendered_data)
391
- item.cls.origin_data = Box(origin_data)
392
- item.cls.belong_app = origin_data.get("_belong_app")
393
- item.cls.context = CONTEXT
394
- item.cls.config = config
395
-
396
- # 获取测试函数体内容
397
- func_source = re.sub(r'(?<!["\'])#.*', '', inspect.getsource(item.function))
398
- # 校验测试用例中是否有断言
399
- if 'test_setup' not in item.keywords and 'test_teardown' not in item.keywords and "assert" not in func_source:
400
- logger.error(f"测试方法:{item.originalname}缺少断言")
401
- pytest.exit(ExitCode.MISSING_ASSERTIONS)
402
-
403
- # # 检查函数体内容是否包含语句self.request,如果没有则自动发送请求
404
- # if "self.request" not in func_source:
405
- # # 测试用例函数添加参数response
406
- # response = client.request(rendered_data)
407
- # item.funcargs["response"] = response
408
- # if item.cls:
409
- # item.cls.response = response
410
-
411
-
412
- def pytest_runtest_logreport(report):
413
- """收集测试结果"""
414
- if report.when == "call": # 确保是测试调用阶段(忽略setup和teardown)
415
- test_results["total"] += 1
416
- if report.passed:
417
- test_results["passed"] += 1
418
- elif report.failed:
419
- test_results["failed"] += 1
420
- elif report.skipped:
421
- test_results["skipped"] += 1
422
-
423
-
424
- @pytest.hookimpl(tryfirst=True, hookwrapper=True)
425
- def pytest_runtest_makereport(item, call):
426
- # 获取测试报告
427
- outcome = yield
428
- report = outcome.get_result()
429
-
430
- # 如果测试失败并且是执行阶段
431
- if report.when == "call" and report.failed:
432
- # 获取断言失败的消息
433
- logger.error(f"断言失败: {report.longrepr.reprcrash}")
434
- # 用于控制失败步骤后是否继续的标志
435
- failStepContinuationFlag = item.callspec.params.get('data').get('failStepContinuationFlag')
436
-
437
- global should_skip
438
- if failStepContinuationFlag is None:
439
- should_skip = True
440
- else:
441
- if failStepContinuationFlag == True:
442
- should_skip = False
443
- else:
444
- should_skip = True
445
-
446
-
447
- def pytest_terminal_summary(terminalreporter, exitstatus, config):
448
- """在 pytest 结束后修改统计数据或添加自定义报告"""
449
- stats = terminalreporter.stats
450
- # 统计各种测试结果
451
- passed = len(stats.get("passed", []))
452
- failed = len(stats.get("failed", []))
453
- skipped = len(stats.get("skipped", []))
454
- total = passed + failed + skipped
455
- try:
456
- pass_rate = round(passed / (total - skipped) * 100, 2)
457
- except ZeroDivisionError:
458
- pass_rate = 0
459
- # 打印自定义统计信息
460
- terminalreporter.write("\n============ 执行结果统计 ============\n", blue=True, bold=True)
461
- terminalreporter.write(f"执行用例总数: {passed + failed + skipped}\n", bold=True)
462
- terminalreporter.write(f"通过用例数: {passed}\n", green=True, bold=True)
463
- terminalreporter.write(f"失败用例数: {failed}\n", red=True, bold=True)
464
- terminalreporter.write(f"跳过用例数: {skipped}\n", yellow=True, bold=True)
465
- terminalreporter.write(f"用例通过率: {pass_rate}%\n", green=True, bold=True)
466
- terminalreporter.write("====================================\n", blue=True, bold=True)
467
- # 生成allure测试报告
468
- generate_report()
469
-
470
-
471
- @pytest.fixture(autouse=True)
472
- def response():
473
- response = None
474
- yield response
475
-
476
-
477
- @pytest.fixture(autouse=True)
478
- def data():
479
- data: dict = dict()
480
- yield data
481
-
482
-
483
- @pytest.fixture(autouse=True)
484
- def origin_data():
485
- origin_data: dict = dict()
486
- yield origin_data
487
-
488
-
489
- @pytest.fixture(autouse=True)
490
- def belong_app():
491
- app = None
492
- yield app
493
-
494
-
495
- @pytest.fixture(autouse=True)
496
- def config():
497
- config = None
498
- yield config
499
-
500
-
501
- @pytest.fixture(autouse=True)
502
- def context():
503
- context = None
504
- yield context
505
-
506
-
507
- class Http(object):
508
- pass
509
-
510
-
511
- @retry.retry(tries=3, delay=1)
512
- @pytest.fixture(scope="session", autouse=True)
513
- def http():
514
- module = importlib.import_module("conftest")
515
- try:
516
- for app in all_app:
517
- setattr(Http, app, getattr(module, f"{snake_to_pascal(app)}Login")(app))
518
- return Http
519
- except Exception as e:
520
- logger.error(f"登录{app}异常:{e}")
521
- traceback.print_exc()
522
- pytest.exit(ExitCode.LOGIN_ERROR)
523
- return None