Skip to content
Open
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
18 changes: 18 additions & 0 deletions tcms_pytest_plugin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
# pylint: disable=unused-argument, no-self-use


import inspect

import pytest
from tcms_api import plugin_helpers

Expand Down Expand Up @@ -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 = []
Expand All @@ -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()
Expand All @@ -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
Expand Down
132 changes: 132 additions & 0 deletions tests/test_docstring_reporting.py
Original file line number Diff line number Diff line change
@@ -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()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a better assertion here would be that we've updated the text for test_a but not for test_b. Otherwise we may be missing a corner case.



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."})