Skip to content

Commit 9e58505

Browse files
committed
feat(parser): 支持解析模块/类/函数多层 mark 并去重
- 改进 parse_case_attributes 使用 item.iter_markers() 包含全层级 mark - 实现 tags 的多层级收集(模块/类/函数)与去重逻辑 - 支持 owner/extra_attributes/coding_testcase_id 的正确解析 - 移除 getattr/hasattr,改用 try/except 与直接属性访问 - 新增测试用例验证多层级 mark 解析功能 - 添加集成测试在 test_collector.py 中验证实际解析结果
1 parent b498e5d commit 9e58505

5 files changed

Lines changed: 195 additions & 31 deletions

File tree

‎pytest/src/testsolar_pytestx/collector.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,9 @@ def collect_testcases(
8888
run_all_cases = os.getenv("TESTSOLAR_TTP_RUNALLCASES", "").lower() in ["1", "true"]
8989
if run_all_cases:
9090
logger.info("[Load] 执行一条命令运行当前项目的所有用例")
91-
load_result.Tests.extend([TestCase(Name=selector, Attributes={}) for selector in entry_param.TestSelectors])
91+
load_result.Tests.extend(
92+
[TestCase(Name=selector, Attributes={}) for selector in entry_param.TestSelectors]
93+
)
9294
reporter = FileReporter(Path(entry_param.FileReportPath))
9395
reporter.report_load_result(load_result)
9496
return

‎pytest/src/testsolar_pytestx/parser.py‎

Lines changed: 126 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import json
22
import re
3-
from typing import Dict, List, Optional
3+
from typing import Dict, Iterable, List, Optional, Set
4+
5+
from _pytest.mark.structures import Mark
46
from pytest import Item
57

68

@@ -17,37 +19,135 @@
1719
# - tag
1820
# - owner
1921
# - extra_attributes
22+
23+
24+
def _get_desc(item: Item) -> str:
25+
try:
26+
doc = item.function.__doc__ # type: ignore
27+
except AttributeError:
28+
return ""
29+
return str(doc).strip() if doc else ""
30+
31+
32+
def _iter_markers(item: Item) -> List[Mark]:
33+
try:
34+
return list(item.iter_markers())
35+
except AttributeError:
36+
return list(item.own_markers or [])
37+
38+
39+
def _is_tag_mark(mark: Mark) -> bool:
40+
if mark.name in {"attributes"}:
41+
return False
42+
if mark.args or mark.kwargs:
43+
return False
44+
return True
45+
46+
47+
def _collect_tags(markers: Iterable[Mark]) -> List[str]:
48+
"""从 markers 中提取 tags(去重),只收集“无 args 且无 kwargs”的 mark.name。
49+
50+
示例:
51+
pytestmark = [pytest.mark.mod_tag]
52+
53+
@pytest.mark.class_tag
54+
class TestX:
55+
@pytest.mark.func_tag
56+
def test_ok(self):
57+
pass
58+
59+
=> tags 包含:mod_tag / class_tag / func_tag
60+
61+
反例(不会进 tags):
62+
@pytest.mark.slow(reason="too slow") # 有 kwargs
63+
@pytest.mark.owner("alice") # 有 args
64+
@pytest.mark.attributes({"k": "v"}) # 显式排除
65+
"""
66+
tags: List[str] = []
67+
seen: Set[str] = set()
68+
for mark in markers:
69+
name_str = str(mark.name)
70+
if not _is_tag_mark(mark) or name_str in seen:
71+
continue
72+
seen.add(name_str)
73+
tags.append(name_str)
74+
return tags
75+
76+
77+
def _try_set_owner(attributes: Dict[str, str], mark: Mark) -> None:
78+
"""解析 owner:仅支持 `@pytest.mark.owner("alice")`。
79+
80+
示例:
81+
@pytest.mark.owner("alice")
82+
def test_ok():
83+
pass
84+
85+
=> attributes["owner"] == "alice"
86+
"""
87+
if "owner" in attributes:
88+
return
89+
if mark.name != "owner" or not mark.args:
90+
return
91+
attributes["owner"] = str(mark.args[0])
92+
93+
94+
def _try_set_extra_attributes(attributes: Dict[str, str], mark: Mark) -> None:
95+
"""解析 extra_attributes:仅支持 `@pytest.mark.extra_attributes({"k": "v"})`。
96+
97+
示例:
98+
@pytest.mark.extra_attributes({"env": "AA", "skip": None})
99+
def test_ok():
100+
pass
101+
102+
=> attributes["extra_attributes"] == '[{"env": "AA"}]' # None 会被过滤
103+
"""
104+
if mark.name != "extra_attributes" or not mark.args:
105+
return
106+
data = mark.args[0]
107+
if not isinstance(data, dict):
108+
return
109+
attr_list = [{k: v} for k, v in data.items() if v is not None]
110+
attributes["extra_attributes"] = json.dumps(attr_list)
111+
112+
113+
def _try_set_coding_testcase_id(attributes: Dict[str, str], item: Item, mark: Mark) -> None:
114+
"""解析 coding_testcase_id:面向参数化用例,通过参数 id 映射到外部用例 ID。
115+
116+
示例:
117+
@pytest.mark.coding_testcase_id({"case1": "CID-001"})
118+
@pytest.mark.parametrize("x", [pytest.param(1, id="case1")])
119+
def test_p(x):
120+
pass
121+
122+
=> item.name == "test_p[case1]"
123+
=> attributes["coding_testcase_id"] == "CID-001"
124+
"""
125+
if mark.name != "coding_testcase_id" or not mark.args:
126+
return
127+
if "[" not in item.name:
128+
return
129+
data = mark.args[0]
130+
if not isinstance(data, dict):
131+
return
132+
case_data_name = item.name.split("[", 1)[1][:-1]
133+
if case_data_name in data:
134+
attributes["coding_testcase_id"] = str(data[case_data_name])
135+
136+
20137
def parse_case_attributes(item: Item, comment_fields: Optional[List[str]] = None) -> Dict[str, str]:
21138
"""parse testcase attributes"""
22-
desc: str = (str(item.function.__doc__) if item.function.__doc__ else "").strip() # type: ignore
139+
desc = _get_desc(item)
23140
attributes: Dict[str, str] = {"description": desc}
24141
if comment_fields:
25142
attributes.update(scan_comment_fields(desc, comment_fields))
26143

27-
if not item.own_markers:
28-
return attributes
29-
tags = []
30-
for mark in item.own_markers:
31-
if not mark.args and mark.name != "attributes":
32-
tags.append(mark.name)
33-
elif mark.args and mark.name == "owner":
34-
attributes["owner"] = str(mark.args[0])
35-
elif mark.name == "extra_attributes":
36-
extra_attr = {}
37-
attr_list = []
38-
for key in mark.args[0]:
39-
if mark.args[0][key] is None:
40-
continue
41-
extra_attr[key] = mark.args[0][key]
42-
attr_list.append(extra_attr)
43-
attributes["extra_attributes"] = json.dumps(attr_list)
44-
elif mark.name == "coding_testcase_id":
45-
case_data_name = item.name.split("[")[1][:-1]
46-
for data_name in mark.args[0]:
47-
if data_name == case_data_name:
48-
attributes["coding_testcase_id"] = mark.args[0][data_name]
49-
50-
attributes["tags"] = json.dumps(tags)
144+
markers = _iter_markers(item)
145+
for mark in markers:
146+
_try_set_owner(attributes, mark)
147+
_try_set_extra_attributes(attributes, mark)
148+
_try_set_coding_testcase_id(attributes, item, mark)
149+
150+
attributes["tags"] = json.dumps(_collect_tags(markers))
51151
return attributes
52152

53153

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import pytest
2+
3+
pytestmark = [
4+
pytest.mark.mod_tag,
5+
pytest.mark.slow(reason="ignored for tags"),
6+
pytest.mark.owner("module_owner"),
7+
pytest.mark.extra_attributes({"layer": "module", "none": None}),
8+
]
9+
10+
11+
@pytest.mark.class_tag
12+
@pytest.mark.owner("class_owner")
13+
class TestMarkLayers:
14+
@pytest.mark.func_tag
15+
@pytest.mark.owner("func_owner")
16+
def test_layers(self):
17+
assert True
18+
19+
20+
@pytest.mark.coding_testcase_id({"case1": "CID-001"})
21+
@pytest.mark.parametrize("x", [pytest.param(1, id="case1")])
22+
def test_coding_id_layers(x):
23+
assert x == 1

‎pytest/tests/test_collector.py‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import tempfile
23
import unittest
34
from pathlib import Path
@@ -257,8 +258,7 @@ def test_collect_testcases_with_coding_testcase_id(self):
257258
)
258259
self.assertEqual(re.Tests[1].Attributes["coding_testcase_id"], "789")
259260

260-
def test_collect_testcases_file_mode_with_directory(self):
261-
"""测试文件模式:扫描目录"""
261+
def test_collect_testcases_with_mark_layers(self):
262262
with tempfile.TemporaryDirectory() as tmpdir:
263263
report_file = Path(tmpdir) / "result.json"
264264
entry = EntryParam(
@@ -418,3 +418,42 @@ def test_scan_pytest_files_excludes_hidden_and_cache_dirs(self):
418418
# 验证只包含有效的测试文件
419419
self.assertEqual(len(test_files), 1)
420420
self.assertIn("test_valid.py", test_files)
421+
422+
def test_collect_testcases_file_mode_with_directory(self):
423+
"""测试文件模式:扫描目录"""
424+
with tempfile.TemporaryDirectory() as tmpdir:
425+
report_file = Path(tmpdir) / "result.json"
426+
entry = EntryParam(
427+
TaskId="aa",
428+
ProjectPath=self.testdata_dir,
429+
TestSelectors=[
430+
"test_mark_layers_case.py",
431+
],
432+
FileReportPath=str(report_file),
433+
)
434+
435+
collect_testcases(entry)
436+
437+
re = read_file_load_result(report_file)
438+
self.assertEqual(len(re.LoadErrors), 0)
439+
self.assertEqual(len(re.Tests), 2)
440+
441+
tests_by_name = {it.Name: it for it in re.Tests}
442+
443+
class_case_name = "test_mark_layers_case.py?TestMarkLayers/test_layers"
444+
self.assertIn(class_case_name, tests_by_name)
445+
class_attrs = tests_by_name[class_case_name].Attributes
446+
self.assertEqual(class_attrs["owner"], "func_owner")
447+
self.assertEqual(class_attrs["extra_attributes"], '[{"layer": "module"}]')
448+
self.assertEqual(
449+
set(json.loads(class_attrs["tags"])),
450+
{"mod_tag", "class_tag", "func_tag"},
451+
)
452+
453+
coding_case_name = "test_mark_layers_case.py?test_coding_id_layers/[case1]"
454+
self.assertIn(coding_case_name, tests_by_name)
455+
coding_attrs = tests_by_name[coding_case_name].Attributes
456+
self.assertEqual(coding_attrs["owner"], "module_owner")
457+
self.assertEqual(coding_attrs["extra_attributes"], '[{"layer": "module"}]')
458+
self.assertEqual(coding_attrs["coding_testcase_id"], "CID-001")
459+
self.assertEqual(set(json.loads(coding_attrs["tags"])), {"mod_tag"})

‎pytest/tests/test_parser.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,7 @@ def test_handle_str_param():
1818
"owner": "test_owner",
1919
"tag": "test_tag",
2020
}
21-
result = handle_str_param(desc)
22-
assert result == expected
21+
assert handle_str_param(desc) == expected
2322

2423

2524
def test_scan_comment_fields():
@@ -54,6 +53,7 @@ def test_parse_case_attributes():
5453
"description": "This is a test function",
5554
"owner": "test_owner",
5655
"coding_testcase_id": '["123", "456"]',
56+
"tags": "[]",
5757
}
5858
assert parse_case_attributes(item, comment_fields) == expected
5959

0 commit comments

Comments
 (0)