Skip to content

Commit 432e3fc

Browse files
authored
Merge pull request #114 from OpenTestSolar/feat/support-multilayer-mark-collection
feat(parser): 支持解析模块/类/函数多层 mark 并去重
2 parents b498e5d + a720d6b commit 432e3fc

10 files changed

Lines changed: 528 additions & 330 deletions

File tree

‎pytest/CHANGELOG.md‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- Support multi-layer marker collection for test case attributes (module/class/function level)
12+
- Improve parser to correctly extract tags with deduplication across layers
13+
- Support for HTTP request header injection during test execution
14+
- Add test case identification to API requests via X-Testsolar-Testcase header
15+
1016
### Changed
1117
- Update file reporting mode in run script
1218
- Refactor reporter implementation to use FileReporter instead of Reporter
19+
- Improve parse_case_attributes to use item.iter_markers() for complete marker collection
20+
- Remove getattr/hasattr usage in favor of direct attribute access with try/except fallback
1321

1422
### Fixed
15-
- Improve test case collection and execution with better file handling
16-
17-
### Added
18-
- Support for HTTP request header injection during test execution
19-
- Add test case identification to API requests via X-Testsolar-Testcase header
23+
- Improve test case collection and execution with better file handling

‎pytest/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "testsolar-pytestx"
3-
version = "0.1.72"
3+
version = "0.1.73"
44
description = "Pytest tool for TestSolar"
55
authors = [
66
{name = "asiazhang2002", email = "asiazhang2002@gmail.com"},

‎pytest/requirements.txt‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,49 @@
11
# This file was autogenerated by uv via the following command:
22
# uv export --no-hashes --no-dev --locked
33
colorama==0.4.6 ; sys_platform == 'win32'
4+
# via
5+
# loguru
6+
# pytest
47
coverage==7.2.7
8+
# via
9+
# pytest-cov
10+
# testsolar-pytestx
511
dacite==1.8.1
12+
# via testsolar-testtool-sdk
613
exceptiongroup==1.2.2 ; python_full_version < '3.11'
14+
# via pytest
715
importlib-metadata==6.7.0
16+
# via
17+
# pluggy
18+
# pytest
19+
# testsolar-pytestx
820
iniconfig==2.0.0
21+
# via pytest
922
loguru==0.7.2
23+
# via testsolar-pytestx
1024
packaging==24.0
25+
# via pytest
1126
pluggy==1.2.0
27+
# via pytest
1228
portalocker==2.7.0
29+
# via testsolar-testtool-sdk
1330
pytest==7.4.4
31+
# via
32+
# pytest-cov
33+
# testsolar-pytestx
1434
pytest-cov==4.1.0
35+
# via testsolar-pytestx
1536
pywin32==306 ; sys_platform == 'win32'
37+
# via portalocker
1638
testsolar-testtool-sdk==0.4.4
39+
# via testsolar-pytestx
1740
tomli==2.0.1 ; python_full_version <= '3.11'
41+
# via
42+
# coverage
43+
# pytest
1844
typing-extensions==4.7.1 ; python_full_version < '3.8'
45+
# via importlib-metadata
1946
win32-setctime==1.1.0 ; sys_platform == 'win32'
47+
# via loguru
2048
zipp==3.15.0
49+
# via importlib-metadata

‎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

‎pytest/testtool.yaml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ schemaVersion: 1.0
22
name: pytest
33
nameZh: pytest自动化测试
44
lang: python
5-
version: '0.1.72'
5+
version: '0.1.73'
66
langType: INTERPRETED
77
description: |-
88
pytest是一个成熟的全功能Python测试工具,可以帮助您编写更好的程序。此测试工具允许您在TestSolar上运行pytest。

0 commit comments

Comments
 (0)