From cd8f7b0527b49a998720c984ad8fb81951ee0b96 Mon Sep 17 00:00:00 2001 From: Carlos Martinez Date: Sat, 18 Apr 2026 22:38:30 -0500 Subject: [PATCH 1/8] Added docstring as test case text --- tcms_pytest_plugin/__init__.py | 19 +++++ tests/test_docstring_reporting.py | 123 ++++++++++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 tests/test_docstring_reporting.py diff --git a/tcms_pytest_plugin/__init__.py b/tcms_pytest_plugin/__init__.py index 77a1301..0b11481 100644 --- a/tcms_pytest_plugin/__init__.py +++ b/tcms_pytest_plugin/__init__.py @@ -8,6 +8,8 @@ # pylint: disable=unused-argument, no-self-use +import inspect + import pytest from tcms_api import plugin_helpers @@ -35,6 +37,10 @@ class Backend(plugin_helpers.Backend): name = "kiwitcms-pytest-plugin" version = __version__ + def update_test_case_text(self, test_case_id, text): + """Set the body text of a TestCase from its docstring.""" + self.rpc.TestCase.update(test_case_id, {"text": text}) + class KiwiTCMSPlugin: executions = [] @@ -44,6 +50,15 @@ class KiwiTCMSPlugin: def __init__(self, verbose=False): self.backend = Backend(prefix="[pytest]", verbose=verbose) + self._docstrings = {} + + def pytest_collection_finish(self, session): + for item in session.items: + if not hasattr(item, "function"): + continue + doc = getattr(item.function, "__doc__", None) + if doc: + self._docstrings[item.nodeid] = inspect.cleandoc(doc) def pytest_runtestloop(self, session): self.backend.configure() @@ -58,6 +73,10 @@ def pytest_runtest_logstart(self, nodeid, location): test_case["id"], self.backend.run_id ) + docstring = self._docstrings.get(nodeid) + if docstring: + self.backend.update_test_case_text(test_case["id"], docstring) + @pytest.hookimpl(hookwrapper=True) def pytest_report_teststatus(self, report, config): yield diff --git a/tests/test_docstring_reporting.py b/tests/test_docstring_reporting.py new file mode 100644 index 0000000..870ec45 --- /dev/null +++ b/tests/test_docstring_reporting.py @@ -0,0 +1,123 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2024 +# Licensed under the GPLv3: https://www.gnu.org/licenses/gpl.html +# +# pylint: disable=redefined-outer-name + +from unittest.mock import MagicMock, patch + +import pytest + +from tcms_pytest_plugin import Backend, KiwiTCMSPlugin + + +@pytest.fixture +def plugin(): + with patch("tcms_pytest_plugin.Backend") as mock_backend_cls: + instance = MagicMock() + instance.plan_id = 1 + instance.run_id = 1 + instance.test_case_get_or_create.return_value = ({"id": 99}, True) + instance.add_test_case_to_run.return_value = [{"id": 42}] + mock_backend_cls.return_value = instance + + p = KiwiTCMSPlugin() + p.backend = instance + yield p + + +def _make_item(nodeid, docstring): + """Create a minimal mock pytest item with a function docstring.""" + item = MagicMock() + item.nodeid = nodeid + item.function.__doc__ = docstring + return item + + +def _simulate_collection(plugin, items): + session = MagicMock() + session.items = items + plugin.pytest_collection_finish(session) + + +def _simulate_logstart(plugin, nodeid): + plugin.pytest_runtest_logstart(nodeid=nodeid, location=("", 0, "")) + + +def test_docstring_is_sent_as_test_case_text(plugin): + """Test case body must be set from the function docstring when present.""" + items = [_make_item("tests/test_foo.py::test_intent", "Verify the widget resets on power-up.")] + _simulate_collection(plugin, items) + _simulate_logstart(plugin, "tests/test_foo.py::test_intent") + + plugin.backend.update_test_case_text.assert_called_once_with( + 99, "Verify the widget resets on power-up." + ) + + +def test_no_update_when_docstring_is_absent(plugin): + """update_test_case_text must not be called when the test has no docstring.""" + item = MagicMock() + item.nodeid = "tests/test_foo.py::test_no_doc" + item.function.__doc__ = None + _simulate_collection(plugin, [item]) + _simulate_logstart(plugin, "tests/test_foo.py::test_no_doc") + + plugin.backend.update_test_case_text.assert_not_called() + + +def test_no_update_when_docstring_is_empty_string(plugin): + """update_test_case_text must not be called when the docstring is an empty string.""" + items = [_make_item("tests/test_foo.py::test_empty_doc", "")] + _simulate_collection(plugin, items) + _simulate_logstart(plugin, "tests/test_foo.py::test_empty_doc") + + plugin.backend.update_test_case_text.assert_not_called() + + +def test_multiline_docstring_is_cleandoc_normalised(plugin): + """inspect.cleandoc must be used so internal indentation is normalised.""" + raw_doc = ( + "\n Verify the widget resets on power-up." + "\n\n Precondition: widget is powered.\n " + ) + items = [_make_item("tests/test_foo.py::test_multiline", raw_doc)] + _simulate_collection(plugin, items) + _simulate_logstart(plugin, "tests/test_foo.py::test_multiline") + + expected = "Verify the widget resets on power-up.\n\nPrecondition: widget is powered." + plugin.backend.update_test_case_text.assert_called_once_with(99, expected) + + +def test_item_without_function_attribute_is_skipped(plugin): + """Items without a .function attribute (e.g. doctest items) must not crash collection.""" + item = MagicMock(spec=[]) # no attributes at all + item.nodeid = "tests/test_foo.py::test_synthetic" + session = MagicMock() + session.items = [item] + + plugin.pytest_collection_finish(session) # must not raise + + plugin.backend.update_test_case_text.assert_not_called() + + +def test_docstring_not_sent_for_different_nodeid(plugin): + """Docstring harvested for test A must not be sent when test B runs.""" + item_a = _make_item("tests/test_foo.py::test_a", "Intention A") + item_b = _make_item("tests/test_foo.py::test_b", None) + item_b.function.__doc__ = None + _simulate_collection(plugin, [item_a, item_b]) + + _simulate_logstart(plugin, "tests/test_foo.py::test_b") + + plugin.backend.update_test_case_text.assert_not_called() + + +def test_backend_update_test_case_text_calls_rpc(): + """Backend.update_test_case_text must call TestCase.update with the text field.""" + backend = Backend.__new__(Backend) + backend.rpc = MagicMock() + + backend.update_test_case_text(test_case_id=7, text="Some intention.") + + backend.rpc.TestCase.update.assert_called_once_with(7, {"text": "Some intention."}) From 0f47391431cb66f3c2a96e3c6868753d38a57ed5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 19 Apr 2026 03:40:26 +0000 Subject: [PATCH 2/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/test_docstring_reporting.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_docstring_reporting.py b/tests/test_docstring_reporting.py index 870ec45..df1012e 100644 --- a/tests/test_docstring_reporting.py +++ b/tests/test_docstring_reporting.py @@ -46,7 +46,11 @@ def _simulate_logstart(plugin, nodeid): def test_docstring_is_sent_as_test_case_text(plugin): """Test case body must be set from the function docstring when present.""" - items = [_make_item("tests/test_foo.py::test_intent", "Verify the widget resets on power-up.")] + items = [ + _make_item( + "tests/test_foo.py::test_intent", "Verify the widget resets on power-up." + ) + ] _simulate_collection(plugin, items) _simulate_logstart(plugin, "tests/test_foo.py::test_intent") @@ -85,7 +89,9 @@ def test_multiline_docstring_is_cleandoc_normalised(plugin): _simulate_collection(plugin, items) _simulate_logstart(plugin, "tests/test_foo.py::test_multiline") - expected = "Verify the widget resets on power-up.\n\nPrecondition: widget is powered." + expected = ( + "Verify the widget resets on power-up.\n\nPrecondition: widget is powered." + ) plugin.backend.update_test_case_text.assert_called_once_with(99, expected) From a8d2983668d40a22057938608f202a08aea2e395 Mon Sep 17 00:00:00 2001 From: jcm <33233445+jcm-mx@users.noreply.github.com> Date: Tue, 21 Apr 2026 09:00:53 -0500 Subject: [PATCH 3/8] Apply suggestion from @atodorov Ignore if text hasn't changed Co-authored-by: Alexander Todorov --- tcms_pytest_plugin/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tcms_pytest_plugin/__init__.py b/tcms_pytest_plugin/__init__.py index 0b11481..83ec538 100644 --- a/tcms_pytest_plugin/__init__.py +++ b/tcms_pytest_plugin/__init__.py @@ -74,7 +74,7 @@ def pytest_runtest_logstart(self, nodeid, location): ) docstring = self._docstrings.get(nodeid) - if docstring: + if docstring != test_case['text']: self.backend.update_test_case_text(test_case["id"], docstring) @pytest.hookimpl(hookwrapper=True) From b5236036aa428e0dd9a29649b7e81a25c2a3c694 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 14:01:01 +0000 Subject: [PATCH 4/8] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tcms_pytest_plugin/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tcms_pytest_plugin/__init__.py b/tcms_pytest_plugin/__init__.py index 83ec538..5bfd70e 100644 --- a/tcms_pytest_plugin/__init__.py +++ b/tcms_pytest_plugin/__init__.py @@ -74,7 +74,7 @@ def pytest_runtest_logstart(self, nodeid, location): ) docstring = self._docstrings.get(nodeid) - if docstring != test_case['text']: + if docstring != test_case["text"]: self.backend.update_test_case_text(test_case["id"], docstring) @pytest.hookimpl(hookwrapper=True) From 58d2c5d1e929e01578df2230e2422e12d847df83 Mon Sep 17 00:00:00 2001 From: Carlos Martinez Date: Tue, 21 Apr 2026 09:07:22 -0500 Subject: [PATCH 5/8] Moved getting docstring where it makes more logical sense --- tcms_pytest_plugin/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tcms_pytest_plugin/__init__.py b/tcms_pytest_plugin/__init__.py index 5bfd70e..168230e 100644 --- a/tcms_pytest_plugin/__init__.py +++ b/tcms_pytest_plugin/__init__.py @@ -68,15 +68,14 @@ def pytest_runtest_logstart(self, nodeid, location): self.comment = "" test_case, _ = self.backend.test_case_get_or_create(nodeid) + docstring = self._docstrings.get(nodeid) + if docstring != test_case["text"]: + self.backend.update_test_case_text(test_case["id"], docstring) self.backend.add_test_case_to_plan(test_case["id"], self.backend.plan_id) self.executions = self.backend.add_test_case_to_run( test_case["id"], self.backend.run_id ) - docstring = self._docstrings.get(nodeid) - if docstring != test_case["text"]: - self.backend.update_test_case_text(test_case["id"], docstring) - @pytest.hookimpl(hookwrapper=True) def pytest_report_teststatus(self, report, config): yield From 98374e0f965402c9750fc588fbd7995572b9fb34 Mon Sep 17 00:00:00 2001 From: Carlos Martinez Date: Tue, 21 Apr 2026 09:26:36 -0500 Subject: [PATCH 6/8] Fixed incorrect copyright year --- tests/test_docstring_reporting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_docstring_reporting.py b/tests/test_docstring_reporting.py index df1012e..8c3eb9c 100644 --- a/tests/test_docstring_reporting.py +++ b/tests/test_docstring_reporting.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2024 +# Copyright (c) 2026 # Licensed under the GPLv3: https://www.gnu.org/licenses/gpl.html # # pylint: disable=redefined-outer-name From b11c024647a1afc4d9957a193ab03fbf1ecd9fbf Mon Sep 17 00:00:00 2001 From: Carlos Martinez Date: Tue, 21 Apr 2026 09:29:07 -0500 Subject: [PATCH 7/8] Fixed mock that had no text attribute --- tests/test_docstring_reporting.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_docstring_reporting.py b/tests/test_docstring_reporting.py index 8c3eb9c..85b4078 100644 --- a/tests/test_docstring_reporting.py +++ b/tests/test_docstring_reporting.py @@ -17,7 +17,7 @@ def plugin(): instance = MagicMock() instance.plan_id = 1 instance.run_id = 1 - instance.test_case_get_or_create.return_value = ({"id": 99}, True) + instance.test_case_get_or_create.return_value = ({"id": 99, "text": None}, True) instance.add_test_case_to_run.return_value = [{"id": 42}] mock_backend_cls.return_value = instance From 9d5b4769ac563497639d0759dd95af43ad35a0fa Mon Sep 17 00:00:00 2001 From: Carlos Martinez Date: Tue, 21 Apr 2026 09:29:40 -0500 Subject: [PATCH 8/8] Improved assertion to make sure we check for updated text for test_a but not for test_b --- tests/test_docstring_reporting.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_docstring_reporting.py b/tests/test_docstring_reporting.py index 85b4078..c8fc2bb 100644 --- a/tests/test_docstring_reporting.py +++ b/tests/test_docstring_reporting.py @@ -108,14 +108,17 @@ def test_item_without_function_attribute_is_skipped(plugin): def test_docstring_not_sent_for_different_nodeid(plugin): - """Docstring harvested for test A must not be sent when test B runs.""" + """Docstring for test_a must be sent when test_a runs, but not when test_b runs.""" item_a = _make_item("tests/test_foo.py::test_a", "Intention A") item_b = _make_item("tests/test_foo.py::test_b", None) - item_b.function.__doc__ = None _simulate_collection(plugin, [item_a, item_b]) - _simulate_logstart(plugin, "tests/test_foo.py::test_b") + _simulate_logstart(plugin, "tests/test_foo.py::test_a") + plugin.backend.update_test_case_text.assert_called_once_with(99, "Intention A") + + plugin.backend.update_test_case_text.reset_mock() + _simulate_logstart(plugin, "tests/test_foo.py::test_b") plugin.backend.update_test_case_text.assert_not_called()