Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions pytest/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- Support multi-layer marker collection for test case attributes (module/class/function level)
- Improve parser to correctly extract tags with deduplication across layers
- Support for HTTP request header injection during test execution
- Add test case identification to API requests via X-Testsolar-Testcase header

### Changed
- Update file reporting mode in run script
- Refactor reporter implementation to use FileReporter instead of Reporter
- Improve parse_case_attributes to use item.iter_markers() for complete marker collection
- Remove getattr/hasattr usage in favor of direct attribute access with try/except fallback

### Fixed
- Improve test case collection and execution with better file handling

### Added
- Support for HTTP request header injection during test execution
- Add test case identification to API requests via X-Testsolar-Testcase header
- Improve test case collection and execution with better file handling
2 changes: 1 addition & 1 deletion pytest/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "testsolar-pytestx"
version = "0.1.72"
version = "0.1.73"
description = "Pytest tool for TestSolar"
authors = [
{name = "asiazhang2002", email = "asiazhang2002@gmail.com"},
Expand Down
29 changes: 29 additions & 0 deletions pytest/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,20 +1,49 @@
# This file was autogenerated by uv via the following command:
# uv export --no-hashes --no-dev --locked
colorama==0.4.6 ; sys_platform == 'win32'
# via
# loguru
# pytest
coverage==7.2.7
# via
# pytest-cov
# testsolar-pytestx
dacite==1.8.1
# via testsolar-testtool-sdk
exceptiongroup==1.2.2 ; python_full_version < '3.11'
# via pytest
importlib-metadata==6.7.0
# via
# pluggy
# pytest
# testsolar-pytestx
iniconfig==2.0.0
# via pytest
loguru==0.7.2
# via testsolar-pytestx
packaging==24.0
# via pytest
pluggy==1.2.0
# via pytest
portalocker==2.7.0
# via testsolar-testtool-sdk
pytest==7.4.4
# via
# pytest-cov
# testsolar-pytestx
pytest-cov==4.1.0
# via testsolar-pytestx
pywin32==306 ; sys_platform == 'win32'
# via portalocker
testsolar-testtool-sdk==0.4.4
# via testsolar-pytestx
tomli==2.0.1 ; python_full_version <= '3.11'
# via
# coverage
# pytest
typing-extensions==4.7.1 ; python_full_version < '3.8'
# via importlib-metadata
win32-setctime==1.1.0 ; sys_platform == 'win32'
# via loguru
zipp==3.15.0
# via importlib-metadata
4 changes: 3 additions & 1 deletion pytest/src/testsolar_pytestx/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ def collect_testcases(
run_all_cases = os.getenv("TESTSOLAR_TTP_RUNALLCASES", "").lower() in ["1", "true"]
if run_all_cases:
logger.info("[Load] 执行一条命令运行当前项目的所有用例")
load_result.Tests.extend([TestCase(Name=selector, Attributes={}) for selector in entry_param.TestSelectors])
load_result.Tests.extend(
[TestCase(Name=selector, Attributes={}) for selector in entry_param.TestSelectors]
)
reporter = FileReporter(Path(entry_param.FileReportPath))
reporter.report_load_result(load_result)
return
Expand Down
152 changes: 126 additions & 26 deletions pytest/src/testsolar_pytestx/parser.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import json
import re
from typing import Dict, List, Optional
from typing import Dict, Iterable, List, Optional, Set

from _pytest.mark.structures import Mark
from pytest import Item


Expand All @@ -17,37 +19,135 @@
# - tag
# - owner
# - extra_attributes


def _get_desc(item: Item) -> str:
try:
doc = item.function.__doc__ # type: ignore
except AttributeError:
return ""
return str(doc).strip() if doc else ""


def _iter_markers(item: Item) -> List[Mark]:
try:
return list(item.iter_markers())
except AttributeError:
return list(item.own_markers or [])


def _is_tag_mark(mark: Mark) -> bool:
if mark.name in {"attributes"}:
return False
if mark.args or mark.kwargs:
return False
return True


def _collect_tags(markers: Iterable[Mark]) -> List[str]:
"""从 markers 中提取 tags(去重),只收集“无 args 且无 kwargs”的 mark.name。

示例:
pytestmark = [pytest.mark.mod_tag]

@pytest.mark.class_tag
class TestX:
@pytest.mark.func_tag
def test_ok(self):
pass

=> tags 包含:mod_tag / class_tag / func_tag

反例(不会进 tags):
@pytest.mark.slow(reason="too slow") # 有 kwargs
@pytest.mark.owner("alice") # 有 args
@pytest.mark.attributes({"k": "v"}) # 显式排除
"""
tags: List[str] = []
seen: Set[str] = set()
for mark in markers:
name_str = str(mark.name)
if not _is_tag_mark(mark) or name_str in seen:
continue
seen.add(name_str)
tags.append(name_str)
return tags


def _try_set_owner(attributes: Dict[str, str], mark: Mark) -> None:
"""解析 owner:仅支持 `@pytest.mark.owner("alice")`。

示例:
@pytest.mark.owner("alice")
def test_ok():
pass

=> attributes["owner"] == "alice"
"""
if "owner" in attributes:
return
if mark.name != "owner" or not mark.args:
return
attributes["owner"] = str(mark.args[0])


def _try_set_extra_attributes(attributes: Dict[str, str], mark: Mark) -> None:
"""解析 extra_attributes:仅支持 `@pytest.mark.extra_attributes({"k": "v"})`。

示例:
@pytest.mark.extra_attributes({"env": "AA", "skip": None})
def test_ok():
pass

=> attributes["extra_attributes"] == '[{"env": "AA"}]' # None 会被过滤
"""
if mark.name != "extra_attributes" or not mark.args:
return
data = mark.args[0]
if not isinstance(data, dict):
return
attr_list = [{k: v} for k, v in data.items() if v is not None]
attributes["extra_attributes"] = json.dumps(attr_list)


def _try_set_coding_testcase_id(attributes: Dict[str, str], item: Item, mark: Mark) -> None:
"""解析 coding_testcase_id:面向参数化用例,通过参数 id 映射到外部用例 ID。

示例:
@pytest.mark.coding_testcase_id({"case1": "CID-001"})
@pytest.mark.parametrize("x", [pytest.param(1, id="case1")])
def test_p(x):
pass

=> item.name == "test_p[case1]"
=> attributes["coding_testcase_id"] == "CID-001"
"""
if mark.name != "coding_testcase_id" or not mark.args:
return
if "[" not in item.name:
return
data = mark.args[0]
if not isinstance(data, dict):
return
case_data_name = item.name.split("[", 1)[1][:-1]
if case_data_name in data:
attributes["coding_testcase_id"] = str(data[case_data_name])


def parse_case_attributes(item: Item, comment_fields: Optional[List[str]] = None) -> Dict[str, str]:
"""parse testcase attributes"""
desc: str = (str(item.function.__doc__) if item.function.__doc__ else "").strip() # type: ignore
desc = _get_desc(item)
attributes: Dict[str, str] = {"description": desc}
if comment_fields:
attributes.update(scan_comment_fields(desc, comment_fields))

if not item.own_markers:
return attributes
tags = []
for mark in item.own_markers:
if not mark.args and mark.name != "attributes":
tags.append(mark.name)
elif mark.args and mark.name == "owner":
attributes["owner"] = str(mark.args[0])
elif mark.name == "extra_attributes":
extra_attr = {}
attr_list = []
for key in mark.args[0]:
if mark.args[0][key] is None:
continue
extra_attr[key] = mark.args[0][key]
attr_list.append(extra_attr)
attributes["extra_attributes"] = json.dumps(attr_list)
elif mark.name == "coding_testcase_id":
case_data_name = item.name.split("[")[1][:-1]
for data_name in mark.args[0]:
if data_name == case_data_name:
attributes["coding_testcase_id"] = mark.args[0][data_name]

attributes["tags"] = json.dumps(tags)
markers = _iter_markers(item)
for mark in markers:
_try_set_owner(attributes, mark)
_try_set_extra_attributes(attributes, mark)
_try_set_coding_testcase_id(attributes, item, mark)

attributes["tags"] = json.dumps(_collect_tags(markers))
return attributes


Expand Down
23 changes: 23 additions & 0 deletions pytest/testdata/test_mark_layers_case.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import pytest

pytestmark = [
pytest.mark.mod_tag,
pytest.mark.slow(reason="ignored for tags"),
pytest.mark.owner("module_owner"),
pytest.mark.extra_attributes({"layer": "module", "none": None}),
]


@pytest.mark.class_tag
@pytest.mark.owner("class_owner")
class TestMarkLayers:
@pytest.mark.func_tag
@pytest.mark.owner("func_owner")
def test_layers(self):
assert True


@pytest.mark.coding_testcase_id({"case1": "CID-001"})
@pytest.mark.parametrize("x", [pytest.param(1, id="case1")])
def test_coding_id_layers(x):
assert x == 1
43 changes: 41 additions & 2 deletions pytest/tests/test_collector.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import tempfile
import unittest
from pathlib import Path
Expand Down Expand Up @@ -257,8 +258,7 @@ def test_collect_testcases_with_coding_testcase_id(self):
)
self.assertEqual(re.Tests[1].Attributes["coding_testcase_id"], "789")

def test_collect_testcases_file_mode_with_directory(self):
"""测试文件模式:扫描目录"""
def test_collect_testcases_with_mark_layers(self):
with tempfile.TemporaryDirectory() as tmpdir:
report_file = Path(tmpdir) / "result.json"
entry = EntryParam(
Expand Down Expand Up @@ -418,3 +418,42 @@ def test_scan_pytest_files_excludes_hidden_and_cache_dirs(self):
# 验证只包含有效的测试文件
self.assertEqual(len(test_files), 1)
self.assertIn("test_valid.py", test_files)

def test_collect_testcases_file_mode_with_directory(self):
"""测试文件模式:扫描目录"""
with tempfile.TemporaryDirectory() as tmpdir:
report_file = Path(tmpdir) / "result.json"
entry = EntryParam(
TaskId="aa",
ProjectPath=self.testdata_dir,
TestSelectors=[
"test_mark_layers_case.py",
],
FileReportPath=str(report_file),
)

collect_testcases(entry)

re = read_file_load_result(report_file)
self.assertEqual(len(re.LoadErrors), 0)
self.assertEqual(len(re.Tests), 2)

tests_by_name = {it.Name: it for it in re.Tests}

class_case_name = "test_mark_layers_case.py?TestMarkLayers/test_layers"
self.assertIn(class_case_name, tests_by_name)
class_attrs = tests_by_name[class_case_name].Attributes
self.assertEqual(class_attrs["owner"], "func_owner")
self.assertEqual(class_attrs["extra_attributes"], '[{"layer": "module"}]')
self.assertEqual(
set(json.loads(class_attrs["tags"])),
{"mod_tag", "class_tag", "func_tag"},
)

coding_case_name = "test_mark_layers_case.py?test_coding_id_layers/[case1]"
self.assertIn(coding_case_name, tests_by_name)
coding_attrs = tests_by_name[coding_case_name].Attributes
self.assertEqual(coding_attrs["owner"], "module_owner")
self.assertEqual(coding_attrs["extra_attributes"], '[{"layer": "module"}]')
self.assertEqual(coding_attrs["coding_testcase_id"], "CID-001")
self.assertEqual(set(json.loads(coding_attrs["tags"])), {"mod_tag"})
4 changes: 2 additions & 2 deletions pytest/tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ def test_handle_str_param():
"owner": "test_owner",
"tag": "test_tag",
}
result = handle_str_param(desc)
assert result == expected
assert handle_str_param(desc) == expected


def test_scan_comment_fields():
Expand Down Expand Up @@ -54,6 +53,7 @@ def test_parse_case_attributes():
"description": "This is a test function",
"owner": "test_owner",
"coding_testcase_id": '["123", "456"]',
"tags": "[]",
}
assert parse_case_attributes(item, comment_fields) == expected

Expand Down
2 changes: 1 addition & 1 deletion pytest/testtool.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ schemaVersion: 1.0
name: pytest
nameZh: pytest自动化测试
lang: python
version: '0.1.72'
version: '0.1.73'
langType: INTERPRETED
description: |-
pytest是一个成熟的全功能Python测试工具,可以帮助您编写更好的程序。此测试工具允许您在TestSolar上运行pytest。
Expand Down
Loading
Loading