iflow-mcp_galaxyxieyu_api-auto-test 0.1.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.
- atf/__init__.py +48 -0
- atf/assets/__init__.py +0 -0
- atf/assets/report.css +243 -0
- atf/auth.py +99 -0
- atf/case_generator.py +737 -0
- atf/conftest.py +65 -0
- atf/core/__init__.py +40 -0
- atf/core/assert_handler.py +336 -0
- atf/core/config_manager.py +111 -0
- atf/core/globals.py +52 -0
- atf/core/log_manager.py +52 -0
- atf/core/login_handler.py +60 -0
- atf/core/request_handler.py +189 -0
- atf/core/variable_resolver.py +212 -0
- atf/handlers/__init__.py +10 -0
- atf/handlers/notification_handler.py +101 -0
- atf/handlers/report_generator.py +160 -0
- atf/handlers/teardown_handler.py +106 -0
- atf/mcp/__init__.py +1 -0
- atf/mcp/executor.py +469 -0
- atf/mcp/models.py +532 -0
- atf/mcp/tools/__init__.py +1 -0
- atf/mcp/tools/health_tool.py +58 -0
- atf/mcp/tools/metrics_tools.py +132 -0
- atf/mcp/tools/runner_tools.py +380 -0
- atf/mcp/tools/testcase_tools.py +603 -0
- atf/mcp/tools/unittest_tools.py +189 -0
- atf/mcp/utils.py +376 -0
- atf/mcp_server.py +169 -0
- atf/runner.py +134 -0
- atf/unit_case_generator.py +337 -0
- atf/utils/__init__.py +2 -0
- atf/utils/helpers.py +155 -0
- iflow_mcp_galaxyxieyu_api_auto_test-0.1.0.dist-info/METADATA +409 -0
- iflow_mcp_galaxyxieyu_api_auto_test-0.1.0.dist-info/RECORD +37 -0
- iflow_mcp_galaxyxieyu_api_auto_test-0.1.0.dist-info/WHEEL +4 -0
- iflow_mcp_galaxyxieyu_api_auto_test-0.1.0.dist-info/entry_points.txt +2 -0
atf/case_generator.py
ADDED
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
# @time: 2024-08-13
|
|
2
|
+
# @author: xiaoqq
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import yaml
|
|
7
|
+
from atf.core.log_manager import log
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def sanitize_name(name: str) -> str:
|
|
11
|
+
"""
|
|
12
|
+
将名称转换为安全的 Python 标识符
|
|
13
|
+
- 空格/连字符 -> 下划线
|
|
14
|
+
- 中文 -> 保留中文,使用下划线连接
|
|
15
|
+
- 特殊字符 -> 移除
|
|
16
|
+
"""
|
|
17
|
+
# 替换空格和连字符为下划线
|
|
18
|
+
result = re.sub(r'[\s\-]+', '_', name)
|
|
19
|
+
# 保留中文,只移除其他非 ASCII 字符
|
|
20
|
+
result = re.sub(r'[^\w\u4e00-\u9fff]+', '', result)
|
|
21
|
+
# 移除非法字符(保留字母、数字、下划线、中文)
|
|
22
|
+
result = re.sub(r'[^\w]+', '', result)
|
|
23
|
+
# 移除开头的数字
|
|
24
|
+
result = re.sub(r'^[0-9]+', '', result)
|
|
25
|
+
# 移除连续下划线
|
|
26
|
+
result = re.sub(r'_+', '_', result)
|
|
27
|
+
# 移除首尾下划线
|
|
28
|
+
result = result.strip('_')
|
|
29
|
+
# 如果结果为空,使用默认名称
|
|
30
|
+
if not result:
|
|
31
|
+
result = 'unnamed_test'
|
|
32
|
+
return result.lower()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def to_class_name(name: str) -> str:
|
|
36
|
+
"""
|
|
37
|
+
将名称转换为 PascalCase 类名
|
|
38
|
+
"health check api" -> "HealthCheckApi"
|
|
39
|
+
"product_list_test" -> "ProductListTest"
|
|
40
|
+
"""
|
|
41
|
+
safe_name = sanitize_name(name)
|
|
42
|
+
# 按下划线分割,每个部分首字母大写
|
|
43
|
+
parts = safe_name.split('_')
|
|
44
|
+
return ''.join(part.capitalize() for part in parts if part)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def check_python_syntax(code: str) -> tuple[bool, list[str]]:
|
|
48
|
+
"""
|
|
49
|
+
检查 Python 代码语法
|
|
50
|
+
返回 (是否有效, 错误列表)
|
|
51
|
+
"""
|
|
52
|
+
import ast
|
|
53
|
+
try:
|
|
54
|
+
ast.parse(code)
|
|
55
|
+
return True, []
|
|
56
|
+
except SyntaxError as e:
|
|
57
|
+
error_msg = f"Line {e.lineno}: {e.msg}"
|
|
58
|
+
if e.text:
|
|
59
|
+
error_msg += f" -> {e.text.strip()}"
|
|
60
|
+
return False, [error_msg]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class CaseGenerator:
|
|
64
|
+
"""
|
|
65
|
+
测试用例文件生成器
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def generate_single(self, yaml_file: str, output_dir: str = None, base_dir: str = None, dry_run: bool = False) -> dict:
|
|
69
|
+
"""
|
|
70
|
+
生成单个测试用例,返回详细结果
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
yaml_file: YAML 文件路径
|
|
74
|
+
output_dir: 输出目录
|
|
75
|
+
base_dir: 基准目录
|
|
76
|
+
dry_run: 是否仅预览,不实际写入
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
{
|
|
80
|
+
"success": bool,
|
|
81
|
+
"file_path": str,
|
|
82
|
+
"name_mapping": {"original": str, "safe": str, "class": str},
|
|
83
|
+
"syntax_valid": bool,
|
|
84
|
+
"syntax_errors": list[str],
|
|
85
|
+
"code_preview": str (dry_run 模式),
|
|
86
|
+
"error": str (失败时)
|
|
87
|
+
}
|
|
88
|
+
"""
|
|
89
|
+
result = {
|
|
90
|
+
"success": False,
|
|
91
|
+
"file_path": None,
|
|
92
|
+
"name_mapping": None,
|
|
93
|
+
"syntax_valid": None,
|
|
94
|
+
"syntax_errors": None,
|
|
95
|
+
"code_preview": None,
|
|
96
|
+
"error": None
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
# 计算基准目录
|
|
101
|
+
if base_dir is None:
|
|
102
|
+
base_dir = 'tests/cases'
|
|
103
|
+
|
|
104
|
+
# 计算相对路径(用于生成的代码中)
|
|
105
|
+
relative_yaml_path = os.path.relpath(yaml_file, base_dir)
|
|
106
|
+
|
|
107
|
+
# 加载 YAML
|
|
108
|
+
test_data_raw = self.load_test_data(yaml_file)
|
|
109
|
+
if not test_data_raw:
|
|
110
|
+
result["error"] = f"无法加载 YAML 文件: {yaml_file}"
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
if not self.validate_test_data(test_data_raw):
|
|
114
|
+
result["error"] = "YAML 数据校验不通过"
|
|
115
|
+
return result
|
|
116
|
+
|
|
117
|
+
test_data = test_data_raw['testcase']
|
|
118
|
+
raw_name = test_data['name']
|
|
119
|
+
|
|
120
|
+
# 名称处理
|
|
121
|
+
safe_name = sanitize_name(raw_name)
|
|
122
|
+
class_name = to_class_name(raw_name)
|
|
123
|
+
result["name_mapping"] = {
|
|
124
|
+
"original": raw_name,
|
|
125
|
+
"safe": safe_name,
|
|
126
|
+
"class": class_name
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# 生成代码,使用相对路径
|
|
130
|
+
code = self._generate_code(test_data, relative_yaml_path)
|
|
131
|
+
|
|
132
|
+
# 语法校验
|
|
133
|
+
syntax_valid, syntax_errors = check_python_syntax(code)
|
|
134
|
+
result["syntax_valid"] = syntax_valid
|
|
135
|
+
result["syntax_errors"] = syntax_errors
|
|
136
|
+
|
|
137
|
+
if not syntax_valid:
|
|
138
|
+
result["error"] = f"生成的代码存在语法错误: {syntax_errors}"
|
|
139
|
+
result["code_preview"] = code[:500] + "..." if len(code) > 500 else code
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
# 计算输出路径
|
|
143
|
+
if base_dir is None:
|
|
144
|
+
base_dir = 'tests/cases'
|
|
145
|
+
relative_path = os.path.relpath(yaml_file, base_dir)
|
|
146
|
+
path_components = relative_path.split(os.sep)
|
|
147
|
+
if path_components:
|
|
148
|
+
path_components.pop()
|
|
149
|
+
directory_path = os.path.join(*path_components) if path_components else ""
|
|
150
|
+
|
|
151
|
+
file_name = f'test_{safe_name}.py'
|
|
152
|
+
if output_dir:
|
|
153
|
+
file_path = os.path.join(output_dir, directory_path, file_name)
|
|
154
|
+
else:
|
|
155
|
+
file_path = os.path.join('tests', 'scripts', directory_path, file_name)
|
|
156
|
+
|
|
157
|
+
result["file_path"] = file_path
|
|
158
|
+
|
|
159
|
+
if dry_run:
|
|
160
|
+
result["success"] = True
|
|
161
|
+
result["code_preview"] = code
|
|
162
|
+
return result
|
|
163
|
+
|
|
164
|
+
# 写入文件
|
|
165
|
+
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
166
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
167
|
+
f.write(code)
|
|
168
|
+
|
|
169
|
+
result["success"] = True
|
|
170
|
+
log.info(f"已生成测试用例文件: {file_path}")
|
|
171
|
+
|
|
172
|
+
except Exception as e:
|
|
173
|
+
result["error"] = str(e)
|
|
174
|
+
log.error(f"生成测试用例失败: {e}")
|
|
175
|
+
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
def _generate_code(self, test_data: dict, relative_yaml_path: str) -> str:
|
|
179
|
+
"""生成 Python 测试代码字符串,使用相对路径"""
|
|
180
|
+
import io
|
|
181
|
+
|
|
182
|
+
raw_name = test_data['name']
|
|
183
|
+
module_name = sanitize_name(raw_name)
|
|
184
|
+
module_class_name = to_class_name(raw_name)
|
|
185
|
+
description = test_data.get('description')
|
|
186
|
+
case_name = f"test_{module_name} ({description})" if description else f"test_{module_name}"
|
|
187
|
+
|
|
188
|
+
teardowns = test_data.get('teardowns')
|
|
189
|
+
validate_teardowns = self.validate_teardowns(teardowns)
|
|
190
|
+
|
|
191
|
+
# 计算 project_name(从相对路径中提取)
|
|
192
|
+
path_components = relative_yaml_path.split(os.sep)
|
|
193
|
+
if len(path_components) > 1:
|
|
194
|
+
project_name = path_components[0]
|
|
195
|
+
else:
|
|
196
|
+
project_name = "default"
|
|
197
|
+
|
|
198
|
+
allure_epic = test_data.get("allure", {}).get("epic", project_name)
|
|
199
|
+
allure_feature = test_data.get("allure", {}).get("feature")
|
|
200
|
+
allure_story = test_data.get("allure", {}).get("story", module_name)
|
|
201
|
+
testcase_host = test_data.get('host')
|
|
202
|
+
|
|
203
|
+
# 使用 StringIO 生成代码
|
|
204
|
+
f = io.StringIO()
|
|
205
|
+
|
|
206
|
+
f.write(f"# Auto-generated test module for {module_name}\n")
|
|
207
|
+
f.write(f"import os\n")
|
|
208
|
+
f.write(f"import re\n")
|
|
209
|
+
f.write(f"from atf.core.log_manager import log\n")
|
|
210
|
+
f.write(f"from atf.core.globals import Globals\n")
|
|
211
|
+
f.write(f"from atf.core.variable_resolver import VariableResolver\n")
|
|
212
|
+
f.write(f"from atf.core.request_handler import RequestHandler\n")
|
|
213
|
+
f.write(f"from atf.core.assert_handler import AssertHandler\n")
|
|
214
|
+
if validate_teardowns:
|
|
215
|
+
f.write(f"from atf.handlers.teardown_handler import TeardownHandler\n")
|
|
216
|
+
f.write(f"from atf.core.login_handler import LoginHandler\n")
|
|
217
|
+
f.write(f"import allure\n")
|
|
218
|
+
f.write(f"import yaml\n\n")
|
|
219
|
+
|
|
220
|
+
f.write(f"@allure.epic('{allure_epic}')\n")
|
|
221
|
+
if allure_feature:
|
|
222
|
+
f.write(f"@allure.feature('{allure_feature}')\n")
|
|
223
|
+
f.write(f"class Test{module_class_name}:\n")
|
|
224
|
+
|
|
225
|
+
f.write(f" @classmethod\n")
|
|
226
|
+
f.write(f" def setup_class(cls):\n")
|
|
227
|
+
f.write(f" log.info('========== 开始执行测试用例:{case_name} ==========')\n")
|
|
228
|
+
f.write(f" cls.test_case_data = cls.load_test_case_data()\n")
|
|
229
|
+
|
|
230
|
+
if validate_teardowns:
|
|
231
|
+
f.write(f" cls.login_handler = LoginHandler()\n")
|
|
232
|
+
f.write(f" cls.teardowns_dict = {{teardown['id']: teardown for teardown in cls.test_case_data['teardowns']}}\n")
|
|
233
|
+
f.write(f" for teardown in cls.test_case_data.get('teardowns', []):\n")
|
|
234
|
+
f.write(f" project = teardown.get('project')\n")
|
|
235
|
+
f.write(f" if project:\n")
|
|
236
|
+
f.write(f" cls.login_handler.check_and_login_project(project, Globals.get('env'))\n")
|
|
237
|
+
|
|
238
|
+
f.write(f" cls.steps_dict = {{step['id']: step for step in cls.test_case_data['steps']}}\n")
|
|
239
|
+
f.write(f" cls.session_vars = {{}}\n")
|
|
240
|
+
f.write(f" cls.global_vars = Globals.get_data()\n")
|
|
241
|
+
|
|
242
|
+
if testcase_host:
|
|
243
|
+
f.write(f" cls.testcase_host = '{testcase_host}'\n")
|
|
244
|
+
|
|
245
|
+
f.write(f" cls.VR = VariableResolver(global_vars=cls.global_vars, session_vars=cls.session_vars)\n")
|
|
246
|
+
f.write(f" log.info('Setup completed for Test{module_class_name}')\n\n")
|
|
247
|
+
|
|
248
|
+
f.write(f" @staticmethod\n")
|
|
249
|
+
f.write(f" def load_test_case_data():\n")
|
|
250
|
+
# 计算相对路径部分:tests/scripts/ -> tests/cases/
|
|
251
|
+
yaml_dir = os.path.dirname(relative_yaml_path)
|
|
252
|
+
yaml_basename = os.path.basename(relative_yaml_path)
|
|
253
|
+
if yaml_dir:
|
|
254
|
+
f.write(f" yaml_path = os.path.join(os.path.dirname(__file__), '..', 'cases', '{yaml_dir}', '{yaml_basename}')\n")
|
|
255
|
+
else:
|
|
256
|
+
f.write(f" yaml_path = os.path.join(os.path.dirname(__file__), '..', 'cases', '{yaml_basename}')\n")
|
|
257
|
+
f.write(f" with open(yaml_path, 'r', encoding='utf-8') as file:\n")
|
|
258
|
+
f.write(f" test_case_data = yaml.safe_load(file)['testcase']\n")
|
|
259
|
+
f.write(f" return test_case_data\n\n")
|
|
260
|
+
|
|
261
|
+
f.write(f" @allure.story('{allure_story}')\n")
|
|
262
|
+
f.write(f" def test_{module_name}(self):\n")
|
|
263
|
+
f.write(f" log.info('Starting test_{module_name}')\n")
|
|
264
|
+
|
|
265
|
+
for step in test_data['steps']:
|
|
266
|
+
step_id = step['id']
|
|
267
|
+
step_project = step.get("project")
|
|
268
|
+
f.write(f" # Step: {step_id}\n")
|
|
269
|
+
f.write(f" log.info(f'开始执行 step: {step_id}')\n")
|
|
270
|
+
f.write(f" {step_id} = self.steps_dict.get('{step_id}')\n")
|
|
271
|
+
|
|
272
|
+
if testcase_host:
|
|
273
|
+
f.write(f" step_host = self.testcase_host\n")
|
|
274
|
+
elif step_project:
|
|
275
|
+
f.write(f" project_config = self.global_vars.get('{step_project}')\n")
|
|
276
|
+
f.write(f" step_host = project_config['host'] if project_config else ''\n")
|
|
277
|
+
else:
|
|
278
|
+
f.write(f" project_config = self.global_vars.get('{project_name}')\n")
|
|
279
|
+
f.write(f" step_host = project_config['host'] if project_config else ''\n")
|
|
280
|
+
|
|
281
|
+
f.write(f" response = RequestHandler.send_request(\n")
|
|
282
|
+
f.write(f" method={step_id}['method'],\n")
|
|
283
|
+
f.write(f" url=step_host + self.VR.process_data({step_id}['path']),\n")
|
|
284
|
+
f.write(f" headers=self.VR.process_data({step_id}.get('headers')),\n")
|
|
285
|
+
f.write(f" data=self.VR.process_data({step_id}.get('data')),\n")
|
|
286
|
+
f.write(f" params=self.VR.process_data({step_id}.get('params')),\n")
|
|
287
|
+
f.write(f" files=self.VR.process_data({step_id}.get('files'))\n")
|
|
288
|
+
f.write(f" )\n")
|
|
289
|
+
f.write(f" log.info(f'{step_id} 请求结果为:{{response}}')\n")
|
|
290
|
+
f.write(f" self.session_vars['{step_id}'] = response\n")
|
|
291
|
+
|
|
292
|
+
if 'assert' in step:
|
|
293
|
+
if not testcase_host:
|
|
294
|
+
f.write(f" db_config = project_config.get('mysql')\n")
|
|
295
|
+
else:
|
|
296
|
+
f.write(f" db_config = None\n")
|
|
297
|
+
f.write(f" AssertHandler().handle_assertion(\n")
|
|
298
|
+
f.write(f" asserts=self.VR.process_data({step_id}['assert']),\n")
|
|
299
|
+
f.write(f" response=response,\n")
|
|
300
|
+
f.write(f" db_config=db_config\n")
|
|
301
|
+
f.write(f" )\n\n")
|
|
302
|
+
|
|
303
|
+
# teardown 处理
|
|
304
|
+
if validate_teardowns:
|
|
305
|
+
f.write(f" @classmethod\n")
|
|
306
|
+
f.write(f" def teardown_class(cls):\n")
|
|
307
|
+
f.write(f" log.info('Starting teardown for the Test{module_class_name}')\n")
|
|
308
|
+
for teardown_step in teardowns:
|
|
309
|
+
teardown_step_id = teardown_step['id']
|
|
310
|
+
teardown_step_project = teardown_step.get("project")
|
|
311
|
+
f.write(f" {teardown_step_id} = cls.teardowns_dict.get('{teardown_step_id}')\n")
|
|
312
|
+
|
|
313
|
+
if teardown_step_project:
|
|
314
|
+
f.write(f" project_config = cls.global_vars.get('{teardown_step_project}')\n")
|
|
315
|
+
else:
|
|
316
|
+
f.write(f" project_config = cls.global_vars.get('{project_name}')\n")
|
|
317
|
+
|
|
318
|
+
if teardown_step['operation_type'] == 'api':
|
|
319
|
+
f.write(f" response = RequestHandler.send_request(\n")
|
|
320
|
+
f.write(f" method={teardown_step_id}['method'],\n")
|
|
321
|
+
f.write(f" url=project_config['host'] + cls.VR.process_data({teardown_step_id}['path']),\n")
|
|
322
|
+
f.write(f" headers=cls.VR.process_data({teardown_step_id}.get('headers')),\n")
|
|
323
|
+
f.write(f" data=cls.VR.process_data({teardown_step_id}.get('data')),\n")
|
|
324
|
+
f.write(f" params=cls.VR.process_data({teardown_step_id}.get('params')),\n")
|
|
325
|
+
f.write(f" files=cls.VR.process_data({teardown_step_id}.get('files'))\n")
|
|
326
|
+
f.write(f" )\n")
|
|
327
|
+
f.write(f" log.info(f'{teardown_step_id} 请求结果为:{{response}}')\n")
|
|
328
|
+
f.write(f" cls.session_vars['{teardown_step_id}'] = response\n")
|
|
329
|
+
|
|
330
|
+
if 'assert' in teardown_step:
|
|
331
|
+
f.write(f" db_config = project_config.get('mysql')\n")
|
|
332
|
+
f.write(f" AssertHandler().handle_assertion(\n")
|
|
333
|
+
f.write(f" asserts=cls.VR.process_data({teardown_step_id}['assert']),\n")
|
|
334
|
+
f.write(f" response=response,\n")
|
|
335
|
+
f.write(f" db_config=db_config\n")
|
|
336
|
+
f.write(f" )\n\n")
|
|
337
|
+
elif teardown_step['operation_type'] == 'db':
|
|
338
|
+
f.write(f" db_config = project_config.get('mysql')\n")
|
|
339
|
+
f.write(f" TeardownHandler().handle_teardown(\n")
|
|
340
|
+
f.write(f" asserts=cls.VR.process_data({teardown_step_id}),\n")
|
|
341
|
+
f.write(f" db_config=db_config\n")
|
|
342
|
+
f.write(f" )\n\n")
|
|
343
|
+
f.write(f" pass\n")
|
|
344
|
+
else:
|
|
345
|
+
f.write(f" pass\n")
|
|
346
|
+
|
|
347
|
+
f.write(f" log.info('Teardown completed for Test{module_class_name}.')\n")
|
|
348
|
+
|
|
349
|
+
f.write(f"\n log.info(f\"Test case test_{module_name} completed.\")\n")
|
|
350
|
+
|
|
351
|
+
return f.getvalue()
|
|
352
|
+
|
|
353
|
+
def generate_test_cases(self, project_yaml_list=None, output_dir=None, base_dir=None):
|
|
354
|
+
"""
|
|
355
|
+
根据YAML文件生成测试用例并保存到指定目录
|
|
356
|
+
:param project_yaml_list: 列表形式,项目名称或YAML文件路径
|
|
357
|
+
:param output_dir: 测试用例文件生成目录,默认 'tests/scripts'
|
|
358
|
+
:param base_dir: 基准目录,用于计算相对路径,默认 'tests/cases'
|
|
359
|
+
"""
|
|
360
|
+
# 如果没有传入project_yaml_list,默认遍历 tests/cases 目录下所有 YAML
|
|
361
|
+
if not project_yaml_list:
|
|
362
|
+
project_yaml_list = ["tests/cases/"]
|
|
363
|
+
|
|
364
|
+
# 基准目录,用于计算相对路径
|
|
365
|
+
if base_dir is None:
|
|
366
|
+
base_dir = 'tests/cases'
|
|
367
|
+
|
|
368
|
+
# 遍历传入的project_yaml_list
|
|
369
|
+
for item in project_yaml_list:
|
|
370
|
+
if os.path.isdir(item): # 如果是项目目录,如 tests/merchant
|
|
371
|
+
self._process_project_dir(item, output_dir, base_dir)
|
|
372
|
+
elif os.path.isfile(item) and item.endswith('.yaml'): # 如果是单个YAML文件
|
|
373
|
+
self._process_single_yaml(item, output_dir, base_dir)
|
|
374
|
+
else: # 如果是项目名称,如 merchant
|
|
375
|
+
project_dir = os.path.join("tests", "cases", item)
|
|
376
|
+
self._process_project_dir(project_dir, output_dir, base_dir)
|
|
377
|
+
|
|
378
|
+
log.info("Test automation framework execution completed")
|
|
379
|
+
|
|
380
|
+
def _process_project_dir(self, project_dir, output_dir, base_dir='tests/cases'):
|
|
381
|
+
"""
|
|
382
|
+
处理项目目录,遍历项目下所有YAML文件生成测试用例
|
|
383
|
+
:param project_dir: 项目目录路径
|
|
384
|
+
:param output_dir: 测试用例文件生成目录
|
|
385
|
+
:param base_dir: 基准目录,用于计算相对路径
|
|
386
|
+
"""
|
|
387
|
+
for root, dirs, files in os.walk(project_dir):
|
|
388
|
+
for file in files:
|
|
389
|
+
if file.endswith('.yaml'):
|
|
390
|
+
yaml_file = os.path.join(root, file)
|
|
391
|
+
self._process_single_yaml(yaml_file, output_dir, base_dir)
|
|
392
|
+
|
|
393
|
+
def _process_single_yaml(self, yaml_file, output_dir, base_dir='tests/cases'):
|
|
394
|
+
"""
|
|
395
|
+
处理单个YAML文件,生成对应的测试用例文件
|
|
396
|
+
:param yaml_file: YAML文件路径
|
|
397
|
+
:param output_dir: 测试用例文件生成目录
|
|
398
|
+
:param base_dir: 基准目录,用于计算相对路径
|
|
399
|
+
"""
|
|
400
|
+
log.info(f"[CaseGenerator] _process_single_yaml called:")
|
|
401
|
+
log.info(f"[CaseGenerator] yaml_file={yaml_file}")
|
|
402
|
+
log.info(f"[CaseGenerator] output_dir={output_dir}")
|
|
403
|
+
log.info(f"[CaseGenerator] base_dir={base_dir}")
|
|
404
|
+
|
|
405
|
+
# 读取YAML文件内容
|
|
406
|
+
_test_data = self.load_test_data(yaml_file)
|
|
407
|
+
validate_test_data = self.validate_test_data(_test_data)
|
|
408
|
+
if not validate_test_data:
|
|
409
|
+
log.warning(f"{yaml_file} 数据校验不通过,跳过生成测试用例。")
|
|
410
|
+
return
|
|
411
|
+
test_data = _test_data['testcase']
|
|
412
|
+
teardowns = test_data.get('teardowns')
|
|
413
|
+
validate_teardowns = self.validate_teardowns(teardowns)
|
|
414
|
+
|
|
415
|
+
# 计算 relative_path(YAML 相对于 base_dir 的路径)
|
|
416
|
+
relative_path = os.path.relpath(yaml_file, base_dir)
|
|
417
|
+
path_components = relative_path.split(os.sep)
|
|
418
|
+
|
|
419
|
+
# 分离目录和文件名
|
|
420
|
+
if len(path_components) > 1:
|
|
421
|
+
# YAML 在子目录中,如 backend/settings_api.yaml
|
|
422
|
+
relative_dir = os.path.join(*path_components[:-1]) # backend/
|
|
423
|
+
yaml_filename = path_components[-1] # settings_api.yaml
|
|
424
|
+
project_name = path_components[0] # 使用第一级目录名作为 project_name
|
|
425
|
+
else:
|
|
426
|
+
# YAML 在 base_dir 根目录
|
|
427
|
+
relative_dir = ""
|
|
428
|
+
yaml_filename = path_components[0] if path_components else os.path.basename(yaml_file)
|
|
429
|
+
project_name = "default" # 使用默认 project_name
|
|
430
|
+
# 移除最后一个组件(文件名)
|
|
431
|
+
if path_components:
|
|
432
|
+
path_components.pop() # 移除最后一个元素
|
|
433
|
+
directory_path = os.path.join(*path_components) if path_components else ""
|
|
434
|
+
directory_path = directory_path.rstrip(os.sep) # 确保路径不以斜杠结尾
|
|
435
|
+
|
|
436
|
+
log.info(f"[CaseGenerator] Path calculation:")
|
|
437
|
+
log.info(f"[CaseGenerator] relative_path={relative_path}")
|
|
438
|
+
log.info(f"[CaseGenerator] path_components={path_components}")
|
|
439
|
+
log.info(f"[CaseGenerator] project_name={project_name}")
|
|
440
|
+
log.info(f"[CaseGenerator] directory_path={directory_path}")
|
|
441
|
+
|
|
442
|
+
raw_name = test_data['name']
|
|
443
|
+
description = test_data.get('description')
|
|
444
|
+
|
|
445
|
+
# 安全处理名称:支持中文、空格等
|
|
446
|
+
module_name = sanitize_name(raw_name)
|
|
447
|
+
module_class_name = to_class_name(raw_name)
|
|
448
|
+
|
|
449
|
+
# 日志记录中的测试用例名称
|
|
450
|
+
case_name = f"test_{module_name} ({description})" if description is not None else f"test_{module_name}"
|
|
451
|
+
file_name = f'test_{module_name}.py'
|
|
452
|
+
|
|
453
|
+
log.info(f"[CaseGenerator] Name processing: '{raw_name}' -> module='{module_name}', class='{module_class_name}'")
|
|
454
|
+
|
|
455
|
+
# 生成文件路径
|
|
456
|
+
if output_dir:
|
|
457
|
+
file_path = os.path.join(output_dir, directory_path, file_name)
|
|
458
|
+
else:
|
|
459
|
+
file_path = os.path.join('tests', 'scripts', directory_path, file_name)
|
|
460
|
+
|
|
461
|
+
log.info(f"[CaseGenerator] File path generation:")
|
|
462
|
+
log.info(f"[CaseGenerator] file_name={file_name}")
|
|
463
|
+
log.info(f"[CaseGenerator] file_path={file_path}")
|
|
464
|
+
log.info(f"[CaseGenerator] dirname={os.path.dirname(file_path)}")
|
|
465
|
+
|
|
466
|
+
# 检查test_cases中对应的.py文件是否存在,存在则跳过生成
|
|
467
|
+
if os.path.exists(file_path):
|
|
468
|
+
log.info(f"测试用例文件已存在,跳过生成: {file_path}")
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
# 创建目录
|
|
472
|
+
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
|
473
|
+
|
|
474
|
+
allure_epic = test_data.get("allure", {}).get("epic", project_name)
|
|
475
|
+
allure_feature = test_data.get("allure", {}).get("feature")
|
|
476
|
+
allure_story = test_data.get("allure", {}).get("story", module_name)
|
|
477
|
+
|
|
478
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
479
|
+
f.write(f"# Auto-generated test module for {module_name}\n")
|
|
480
|
+
f.write(f"import os\n")
|
|
481
|
+
f.write(f"import re\n")
|
|
482
|
+
f.write(f"from atf.core.log_manager import log\n")
|
|
483
|
+
f.write(f"from atf.core.globals import Globals\n")
|
|
484
|
+
f.write(f"from atf.core.variable_resolver import VariableResolver\n")
|
|
485
|
+
f.write(f"from atf.core.request_handler import RequestHandler\n")
|
|
486
|
+
f.write(f"from atf.core.assert_handler import AssertHandler\n")
|
|
487
|
+
if validate_teardowns:
|
|
488
|
+
f.write(f"from atf.handlers.teardown_handler import TeardownHandler\n")
|
|
489
|
+
f.write(f"from atf.core.login_handler import LoginHandler\n")
|
|
490
|
+
f.write(f"import allure\n")
|
|
491
|
+
f.write(f"import yaml\n\n")
|
|
492
|
+
|
|
493
|
+
f.write(f"@allure.epic('{allure_epic}')\n")
|
|
494
|
+
if allure_feature:
|
|
495
|
+
f.write(f"@allure.feature('{allure_feature}')\n")
|
|
496
|
+
f.write(f"class Test{module_class_name}:\n")
|
|
497
|
+
|
|
498
|
+
f.write(f" @classmethod\n")
|
|
499
|
+
f.write(f" def setup_class(cls):\n")
|
|
500
|
+
f.write(f" log.info('========== 开始执行测试用例:{case_name} ==========')\n")
|
|
501
|
+
f.write(f" cls.test_case_data = cls.load_test_case_data()\n") # 获取测试数据
|
|
502
|
+
# 如果存在teardowns,则将步骤列表转换为字典, 在下面的测试方法中通过 id 查找步骤的信息
|
|
503
|
+
if validate_teardowns:
|
|
504
|
+
f.write(f" cls.login_handler = LoginHandler()\n")
|
|
505
|
+
f.write(f" cls.teardowns_dict = {{teardown['id']: teardown for teardown in cls.test_case_data['teardowns']}}\n")
|
|
506
|
+
f.write(f" for teardown in cls.test_case_data.get('teardowns', []):\n")
|
|
507
|
+
f.write(f" project = teardown.get('project')\n")
|
|
508
|
+
f.write(f" if project:\n")
|
|
509
|
+
f.write(f" cls.login_handler.check_and_login_project(project, Globals.get('env'))\n")
|
|
510
|
+
|
|
511
|
+
# 将步骤列表转换为字典, 在下面的测试方法中通过 id 查找步骤的信息
|
|
512
|
+
f.write(f" cls.steps_dict = {{step['id']: step for step in cls.test_case_data['steps']}}\n")
|
|
513
|
+
|
|
514
|
+
f.write(f" cls.session_vars = {{}}\n")
|
|
515
|
+
f.write(f" cls.global_vars = Globals.get_data()\n") # 获取全局变量
|
|
516
|
+
# f.write(f" cls.db_config = cls.global_vars.get('mysql')\n") # 获取数据库配置
|
|
517
|
+
|
|
518
|
+
# 处理 testcase 级别的 host 配置
|
|
519
|
+
testcase_host = test_data.get('host')
|
|
520
|
+
if testcase_host:
|
|
521
|
+
f.write(f" cls.testcase_host = '{testcase_host}'\n")
|
|
522
|
+
|
|
523
|
+
# 创建VariableResolver实例并保存在类变量中
|
|
524
|
+
f.write(f" cls.VR = VariableResolver(global_vars=cls.global_vars, session_vars=cls.session_vars)\n")
|
|
525
|
+
|
|
526
|
+
f.write(f" log.info('Setup completed for Test{module_class_name}')\n\n")
|
|
527
|
+
|
|
528
|
+
f.write(f" @staticmethod\n")
|
|
529
|
+
f.write(f" def load_test_case_data():\n")
|
|
530
|
+
if relative_dir:
|
|
531
|
+
f.write(f" yaml_path = os.path.join(os.path.dirname(__file__), '..', 'cases', '{relative_dir}', '{yaml_filename}')\n")
|
|
532
|
+
else:
|
|
533
|
+
f.write(f" yaml_path = os.path.join(os.path.dirname(__file__), '..', 'cases', '{yaml_filename}')\n")
|
|
534
|
+
f.write(f" with open(yaml_path, 'r', encoding='utf-8') as file:\n")
|
|
535
|
+
f.write(f" test_case_data = yaml.safe_load(file)['testcase']\n")
|
|
536
|
+
f.write(f" return test_case_data\n\n")
|
|
537
|
+
|
|
538
|
+
f.write(f" @allure.story('{allure_story}')\n")
|
|
539
|
+
f.write(f" def test_{module_name}(self):\n")
|
|
540
|
+
f.write(f" log.info('Starting test_{module_name}')\n")
|
|
541
|
+
|
|
542
|
+
for step in test_data['steps']:
|
|
543
|
+
step_id = step['id']
|
|
544
|
+
step_project = step.get("project") # 场景测试用例可能会请求不同项目的接口,需要在每个step中指定对应的project
|
|
545
|
+
f.write(f" # Step: {step_id}\n")
|
|
546
|
+
f.write(f" log.info(f'开始执行 step: {step_id}')\n")
|
|
547
|
+
f.write(f" {step_id} = self.steps_dict.get('{step_id}')\n")
|
|
548
|
+
|
|
549
|
+
# 如果 testcase 有 host 配置,使用它;否则使用 project 配置
|
|
550
|
+
if testcase_host:
|
|
551
|
+
f.write(f" step_host = self.testcase_host\n")
|
|
552
|
+
elif step_project:
|
|
553
|
+
f.write(f" project_config = self.global_vars.get('{step_project}')\n")
|
|
554
|
+
f.write(f" step_host = project_config['host'] if project_config else ''\n")
|
|
555
|
+
else:
|
|
556
|
+
f.write(f" project_config = self.global_vars.get('{project_name}')\n")
|
|
557
|
+
f.write(f" step_host = project_config['host'] if project_config else ''\n")
|
|
558
|
+
|
|
559
|
+
f.write(f" response = RequestHandler.send_request(\n")
|
|
560
|
+
f.write(f" method={step_id}['method'],\n")
|
|
561
|
+
f.write(f" url=step_host + self.VR.process_data({step_id}['path']),\n")
|
|
562
|
+
f.write(f" headers=self.VR.process_data({step_id}.get('headers')),\n")
|
|
563
|
+
f.write(f" data=self.VR.process_data({step_id}.get('data')),\n")
|
|
564
|
+
f.write(f" params=self.VR.process_data({step_id}.get('params')),\n")
|
|
565
|
+
f.write(f" files=self.VR.process_data({step_id}.get('files'))\n")
|
|
566
|
+
f.write(f" )\n")
|
|
567
|
+
f.write(f" log.info(f'{step_id} 请求结果为:{{response}}')\n")
|
|
568
|
+
f.write(f" self.session_vars['{step_id}'] = response\n")
|
|
569
|
+
|
|
570
|
+
if 'assert' in step:
|
|
571
|
+
# 只有在真正使用 project_config 时才获取 db_config
|
|
572
|
+
if not testcase_host:
|
|
573
|
+
f.write(f" db_config = project_config.get('mysql')\n")
|
|
574
|
+
else:
|
|
575
|
+
f.write(f" db_config = None\n")
|
|
576
|
+
f.write(f" AssertHandler().handle_assertion(\n")
|
|
577
|
+
f.write(f" asserts=self.VR.process_data({step_id}['assert']),\n")
|
|
578
|
+
f.write(f" response=response,\n")
|
|
579
|
+
f.write(f" db_config=db_config\n")
|
|
580
|
+
f.write(f" )\n\n")
|
|
581
|
+
|
|
582
|
+
# teardown处理
|
|
583
|
+
if validate_teardowns:
|
|
584
|
+
f.write(f" @classmethod\n")
|
|
585
|
+
f.write(f" def teardown_class(cls):\n")
|
|
586
|
+
f.write(f" log.info('Starting teardown for the Test{module_class_name}')\n")
|
|
587
|
+
for teardown_step in teardowns:
|
|
588
|
+
teardown_step_id = teardown_step['id']
|
|
589
|
+
teardown_step_project = teardown_step.get("project")
|
|
590
|
+
f.write(f" {teardown_step_id} = cls.teardowns_dict.get('{teardown_step_id}')\n")
|
|
591
|
+
|
|
592
|
+
if teardown_step_project:
|
|
593
|
+
f.write(f" project_config = cls.global_vars.get('{teardown_step_project}')\n")
|
|
594
|
+
else:
|
|
595
|
+
f.write(f" project_config = cls.global_vars.get('{project_name}')\n")
|
|
596
|
+
|
|
597
|
+
# 如果是请求接口操作
|
|
598
|
+
if teardown_step['operation_type'] == 'api':
|
|
599
|
+
f.write(f" response = RequestHandler.send_request(\n")
|
|
600
|
+
f.write(f" method={teardown_step_id}['method'],\n")
|
|
601
|
+
f.write(f" url=project_config['host'] + cls.VR.process_data({teardown_step_id}['path']),\n")
|
|
602
|
+
f.write(f" headers=cls.VR.process_data({teardown_step_id}.get('headers')),\n")
|
|
603
|
+
f.write(f" data=cls.VR.process_data({teardown_step_id}.get('data')),\n")
|
|
604
|
+
f.write(f" params=cls.VR.process_data({teardown_step_id}.get('params')),\n")
|
|
605
|
+
f.write(f" files=cls.VR.process_data({teardown_step_id}.get('files'))\n")
|
|
606
|
+
f.write(f" )\n")
|
|
607
|
+
f.write(f" log.info(f'{teardown_step_id} 请求结果为:{{response}}')\n")
|
|
608
|
+
f.write(f" cls.session_vars['{teardown_step_id}'] = response\n")
|
|
609
|
+
|
|
610
|
+
if 'assert' in teardown_step:
|
|
611
|
+
# if any(assertion['type'].startswith('mysql') for assertion in teardown_step['assert']):
|
|
612
|
+
# f.write(f" db_config = project_config.get('mysql')\n")
|
|
613
|
+
f.write(f" db_config = project_config.get('mysql')\n")
|
|
614
|
+
f.write(f" AssertHandler().handle_assertion(\n")
|
|
615
|
+
f.write(f" asserts=cls.VR.process_data({teardown_step_id}['assert']),\n")
|
|
616
|
+
f.write(f" response=response,\n")
|
|
617
|
+
f.write(f" db_config=db_config\n")
|
|
618
|
+
f.write(f" )\n\n")
|
|
619
|
+
|
|
620
|
+
# 如果是数据库操作,暂时未补充逻辑
|
|
621
|
+
elif teardown_step['operation_type'] == 'db':
|
|
622
|
+
f.write(f" db_config = project_config.get('mysql')\n")
|
|
623
|
+
f.write(f" TeardownHandler().handle_teardown(\n")
|
|
624
|
+
f.write(f" asserts=cls.VR.process_data({teardown_step_id}),\n")
|
|
625
|
+
f.write(f" db_config=db_config\n")
|
|
626
|
+
f.write(f" )\n\n")
|
|
627
|
+
f.write(f" pass\n")
|
|
628
|
+
else:
|
|
629
|
+
log.info(f"未知的 operation_type: {teardown_step['operation_type']}")
|
|
630
|
+
f.write(f" pass\n")
|
|
631
|
+
|
|
632
|
+
f.write(f" log.info('Teardown completed for Test{module_class_name}.')\n")
|
|
633
|
+
|
|
634
|
+
f.write(f"\n log.info(f\"Test case test_{module_name} completed.\")\n")
|
|
635
|
+
|
|
636
|
+
log.info(f"已生成测试用例文件: {file_path}")
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@staticmethod
|
|
640
|
+
def load_test_data(test_data_file):
|
|
641
|
+
try:
|
|
642
|
+
with open(test_data_file, 'r', encoding='utf-8') as file:
|
|
643
|
+
test_data = yaml.safe_load(file)
|
|
644
|
+
return test_data
|
|
645
|
+
except FileNotFoundError:
|
|
646
|
+
log.error(f"未找到测试数据文件: {test_data_file}")
|
|
647
|
+
except yaml.YAMLError as e:
|
|
648
|
+
log.error(f"YAML配置文件解析错误: {e},{test_data_file} 跳过生成测试用例。")
|
|
649
|
+
|
|
650
|
+
@staticmethod
|
|
651
|
+
def validate_test_data(test_data):
|
|
652
|
+
"""
|
|
653
|
+
校验测试数据是否符合基本要求
|
|
654
|
+
:param test_data: 测试数据
|
|
655
|
+
:return:
|
|
656
|
+
"""
|
|
657
|
+
if not test_data:
|
|
658
|
+
log.error("test_data 不能为空.")
|
|
659
|
+
return False
|
|
660
|
+
|
|
661
|
+
if not test_data.get('testcase'):
|
|
662
|
+
log.error("test_data 必须包含 'testcase' 键.")
|
|
663
|
+
return False
|
|
664
|
+
|
|
665
|
+
if not test_data['testcase'].get('name'):
|
|
666
|
+
log.error("'testcase' 下的 'name' 字段不能为空.")
|
|
667
|
+
return False
|
|
668
|
+
|
|
669
|
+
steps = test_data['testcase'].get('steps')
|
|
670
|
+
if not steps:
|
|
671
|
+
log.error("'testcase' 下的 'steps' 字段不能为空.")
|
|
672
|
+
return False
|
|
673
|
+
|
|
674
|
+
for step in steps:
|
|
675
|
+
if not all(key in step for key in ['id', 'path', 'method']):
|
|
676
|
+
log.error("每个步骤必须包含 'id', 'path', 和 'method' 字段.")
|
|
677
|
+
return False
|
|
678
|
+
|
|
679
|
+
if not step['id']:
|
|
680
|
+
log.error("步骤中的 'id' 字段不能为空.")
|
|
681
|
+
return False
|
|
682
|
+
if not step['path']:
|
|
683
|
+
log.error("步骤中的 'path' 字段不能为空.")
|
|
684
|
+
return False
|
|
685
|
+
if not step['method']:
|
|
686
|
+
log.error("步骤中的 'method' 字段不能为空.")
|
|
687
|
+
return False
|
|
688
|
+
|
|
689
|
+
return True
|
|
690
|
+
|
|
691
|
+
@staticmethod
|
|
692
|
+
def validate_teardowns(teardowns):
|
|
693
|
+
"""
|
|
694
|
+
验证 teardowns 数据是否符合要求
|
|
695
|
+
:param teardowns: teardowns 列表
|
|
696
|
+
:return: True 如果验证成功,否则 False
|
|
697
|
+
"""
|
|
698
|
+
if not teardowns:
|
|
699
|
+
# log.warning("testcase 下的 'teardowns' 字段为空.")
|
|
700
|
+
return False
|
|
701
|
+
|
|
702
|
+
for teardown in teardowns:
|
|
703
|
+
if not all(key in teardown for key in ['id', 'operation_type']):
|
|
704
|
+
log.warning("teardown 必须包含 'id' 和 'operation_type' 字段.")
|
|
705
|
+
return False
|
|
706
|
+
|
|
707
|
+
if not teardown['id']:
|
|
708
|
+
log.warning("teardown 中的 'id' 字段为空.")
|
|
709
|
+
return False
|
|
710
|
+
if not teardown['operation_type']:
|
|
711
|
+
log.warning("teardown 中的 'operation_type' 字段为空.")
|
|
712
|
+
return False
|
|
713
|
+
|
|
714
|
+
if teardown['operation_type'] == 'api':
|
|
715
|
+
required_api_keys = ['path', 'method', 'headers', 'data']
|
|
716
|
+
if not all(key in teardown for key in required_api_keys):
|
|
717
|
+
log.warning("对于 API 类型的 teardown,必须包含 'path', 'method', 'headers', 'data' 字段.")
|
|
718
|
+
return False
|
|
719
|
+
|
|
720
|
+
if not teardown['path']:
|
|
721
|
+
log.warning("teardown 中的 'path' 字段为空.")
|
|
722
|
+
return False
|
|
723
|
+
if not teardown['method']:
|
|
724
|
+
log.warning("teardown 中的 'method' 字段为空.")
|
|
725
|
+
return False
|
|
726
|
+
|
|
727
|
+
elif teardown['operation_type'] == 'db':
|
|
728
|
+
if 'query' not in teardown or not teardown['query']:
|
|
729
|
+
log.warning("对于数据库类型的 teardown,'query' 字段不能为空.")
|
|
730
|
+
return False
|
|
731
|
+
|
|
732
|
+
return True
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
if __name__ == '__main__':
|
|
736
|
+
CG = CaseGenerator()
|
|
737
|
+
CG.generate_test_cases(project_yaml_list=["tests/cases/"])
|