Repository: devolo/pytest-adaptavist (language: python)

Issue:
Use decorators on test cases to specify test_key  and step vs renaming test function with test_key and step
feature request 

Use decorators on test cases to specify test_key  and step vs renaming test function with test_key and step

Instead of this 

```
@pytest.mark.project(project_key="myproject")
class TestAPI(unittest.TestCase):
    def test_T1_1(self):
        ...
```

this

```
@pytest.mark.project(project_key="myproject")
class TestAPI(unittest.TestCase):
    @pytest.mark.test(test_key="T1", test_step=1)
    def test_api_get_obj(self):
        ...
```



Discussion / hints:
Hi,

Seems like a quick win. We will take a look into it. 

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fb3f223..05438bd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [5.5.0] - 2022/04/26
+
+### Added
+
+* Add support for test case decorator
+
 ## [5.4.0] - 2022/04/04
 
 ### Added
diff --git a/README.md b/README.md
index 63323e3..af665a2 100644
--- a/README.md
+++ b/README.md
@@ -77,7 +77,7 @@ restrict_branch_name=development
 
 ## Getting Started
 
-1. pytest-adaptavist searches for test methods named like ```test_<test_case_key>``` or ```test_<test_case_key>_<step>``` where ```test_case_key``` is the key of the Jira test case excluding the project key (e.g. "T1") and ```step``` defines a single test script step (if existing). In order to build real test case key strings from test methods, the corresponding project key needs to be specified for each relevant class or single test methods by using markers (see examples below). Alternatively, ```test_case_key``` can be given as it appears in Adaptavist, but with hyphens replaced by underscores (e.g. "TEST_T1"). Each of these kind of test methods is marked as Adaptavist test case for reporting appropriate results into Adaptavist test management. Any other test methods are processed as usual.
+1. pytest-adaptavist searches for test methods named like ```test_<test_case_key>``` or ```test_<test_case_key>_<step>``` where ```test_case_key``` is the key of the Jira test case excluding the project key (e.g. "T1") and ```step``` defines a single test script step (if existing). In order to build real test case key strings from test methods, the corresponding project key needs to be specified for each relevant class or single test methods by using markers (see [examples](#examples) below). Alternatively, ```test_case_key``` can be given as it appears in Adaptavist, but with hyphens replaced by underscores (e.g. "TEST_T1"). Each of these kind of test methods is marked as Adaptavist test case for reporting appropriate results into Adaptavist test management. Any other test methods are processed as usual. If you don't want to stick to this convention, you can decorate any test method with the testcase marker and specify ```project_key```, ```test_case_key``` and ```test_step_key``` there.
 
 1. Finally, pytest-adaptavist needs either ```test_run_key``` to use an existing test run or ```project_key``` to create a new test run every time with collected test cases linked to it. In order to work properly, either of these parameters need to be specified at the very start of the test session. If both parameters are empty, neither test runs nor test results are created in Adaptavist test management. Please also note that any of these parameters mentioned here and in the following documentation can either be set programmatically by storing them in the environment or be provided as part of json config file (./config/global_config.json).
 
@@ -174,6 +174,27 @@ class TestClass(object):
 
 However, the final status would be ```Fail``` because not all test steps passed.
 
+The same example using the testcase marker:
+
+```python
+@pytest.mark.project(project_key="myproject")
+class TestClass(object):
+
+    @pytest.mark.testcase(test_case_key="T1", test_step_key=2)
+    def test_unexpected_result(self, meta_data):
+        meta_data["comment"] = "unexpected result"
+        attachment = io.StringIO()
+        attachment.write("this is just a simple attachment")
+        meta_data["attachment"] = attachment
+        meta_data["filename"] = "content.txt"
+        assert False
+
+    @pytest.mark.testcase(test_case_key="T1")
+    def test_all_good(self, meta_data):
+        meta_data["comment"] = "all good"
+        assert True
+```
+
 ### Context reporting
 
 In addition to implementing test methods for single test steps, it is possible to combine them within the appropriate test case method by using context blocks:


Test patch:
diff --git a/pytest_adaptavist/_pytest_adaptavist.py b/pytest_adaptavist/_pytest_adaptavist.py
index 26d8fbe..4cae1fa 100644
--- a/pytest_adaptavist/_pytest_adaptavist.py
+++ b/pytest_adaptavist/_pytest_adaptavist.py
@@ -693,17 +693,23 @@ def _setup_item_collection(self, items: list[pytest.Item], collected_project_key
             self.item_status_info[fullname] = {}
             # initialize item's test result data (see meta_data function down below)
             self.test_result_data[fullname] = {"comment": None, "attachment": None}
-
+            test_case_key = ""
+            marker = item.get_closest_marker("testcase")
             # check for valid test case method signature test_[<project>_]T<test case>[_<test step>]
             # (project key and step index are optional)
             if result := re.search("^test_(([A-Z]+[A-Z0-9_]*[^_])_)?(T[1-9]+[0-9]*)(_([1-9]+[0-9]*))*", item.name, re.IGNORECASE):
                 _, project_key, test_case_key, _, test_step_key = result.groups()
+            elif marker:
+                test_case_key = marker.kwargs["test_case_key"]
+                test_step_key = marker.kwargs.get("test_step_key")
+                project_key = marker.kwargs.get("project_key")
 
+            if test_case_key:
                 if not project_key:
                     project_key = getattr(item.cls, "project_key", None)  # type:ignore
 
-                    if marker := item.get_closest_marker("project"):
-                        project_key = marker.kwargs["project_key"]
+                    if project_marker := item.get_closest_marker("project"):
+                        project_key = project_marker.kwargs["project_key"]
 
                 if not project_key:
                     project_key = self.project_key or "TEST"
@@ -711,17 +717,21 @@ def _setup_item_collection(self, items: list[pytest.Item], collected_project_key
                 if project_key not in collected_project_keys:
                     collected_project_keys.append(project_key)
 
+                if marker and project_key not in marker.kwargs["test_case_key"]:
+                    test_case_key = marker.kwargs["test_case_key"]
+                    marker.kwargs["test_case_key"] = f"{project_key}-{test_case_key}"  # type: ignore
+
                 # initialize refresh info
                 specs = get_spec(fullname)
-                self.test_refresh_info[project_key + "-" + test_case_key + (specs or "")] = None
+                self.test_refresh_info[f"{project_key}-{test_case_key}{specs or ''}"] = None
 
                 # mark this item with appropriate info (easier to read from when creating test results)
-                if (project_key + "-" + test_case_key) in test_case_keys or not test_case_keys:
+                if f'{project_key}-{test_case_key}' in test_case_keys or not test_case_keys:
                     item.add_marker(pytest.mark.testcase(project_key=project_key, test_case_key=project_key + "-" + test_case_key, test_step_key=test_step_key))
-                if (test_case_keys and (project_key + "-" + test_case_key) not in test_case_keys):
+                if test_case_keys and f'{project_key}-{test_case_key}' not in test_case_keys:
                     item.add_marker(pytest.mark.skip(reason="skipped as requested"))
                 else:
-                    collected_items.setdefault(project_key + "-" + test_case_key, []).append(item)
+                    collected_items.setdefault(f'{project_key}-{test_case_key}', []).append(item)
             elif self.cfg.get_bool("skip_ntc_methods", False):
                 # skip methods that are no test case methods
                 item.add_marker(pytest.mark.skip)
diff --git a/tests/test_decorators.py b/tests/test_decorators.py
index a82ed5e..bbfc5c5 100644
--- a/tests/test_decorators.py
+++ b/tests/test_decorators.py
@@ -72,7 +72,7 @@ def test_block_decorator(self, pytester: pytest.Pytester):
             import pytest
 
             @pytest.mark.block()
-            def test_dummy():
+            def test_T121():
                 assert True
         """)
         outcome = pytester.runpytest().parseoutcomes()
@@ -145,6 +145,25 @@ def test_dummy(self):
         assert outcome["blocked"] == 1
         assert "passed" not in outcome
 
+    @pytest.mark.usefixtures("configure")
+    def test_testcase_decorator(self, pytester: pytest.Pytester, adaptavist_mock):
+        """docstring."""
+        pytester.makepyfile("""
+            import pytest
+
+            class TestClass:
+
+                @pytest.mark.testcase(test_case_key="T121", test_step_key=1)
+                def test_dasda(self):
+                    assert True
+        """)
+        _, etrs, _ = adaptavist_mock
+        report = pytester.runpytest("--adaptavist")
+        outcome = report.parseoutcomes()
+        assert outcome["passed"] == 1
+        assert etrs.call_count == 1
+        assert etrs.call_args.kwargs["test_case_key"] == "TEST-T121"
+
     @pytest.mark.usefixtures("adaptavist_mock", "configure")
     def test_decorator_combination_blocked(self, pytester: pytest.Pytester):
         """Test class block decorator is useful combined with method decorator."""