diff --git a/tcms_pytest_plugin/__init__.py b/tcms_pytest_plugin/__init__.py index 77a1301..168230e 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() @@ -53,6 +68,9 @@ 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 diff --git a/tests/test_docstring_reporting.py b/tests/test_docstring_reporting.py new file mode 100644 index 0000000..c8fc2bb --- /dev/null +++ b/tests/test_docstring_reporting.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2026 +# 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, "text": None}, 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 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) + _simulate_collection(plugin, [item_a, item_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() + + +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."})