Coverage for pyTooling/Testing/PyTest.py: 81%
84 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
1# ==================================================================================================================== #
2# _____ _ _ _____ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _|_ _|__ ___| |_(_)_ __ __ _ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |/ _ \/ __| __| | '_ \ / _` | #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| | __/\__ \ |_| | | | | (_| | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_|\___||___/\__|_|_| |_|\__, | #
7# |_| |___/ |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2026-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32A pytest plugin collecting what :deco:`~pyTooling.Testing.testsuite` and :deco:`~pyTooling.Testing.testcase` mark.
34pytest decides what a test is from a *name*: ``python_classes`` matches ``Test*`` and ``python_functions`` matches
35``test_*``. The identifier therefore has to enable collection as well as describe the check. This plugin adds a
36second route in - a class or method carrying a marker is collected whatever it is called - and reports the title
37its marker gives it as a JUnit property.
39The plugin is inert until something is marked, so enabling it changes nothing for a name-based test suite. Both
40styles can live in the same run, and even in the same file.
42.. hint::
44 See :ref:`high-level help <TESTING/Markers>` for explanations and usage examples.
45"""
46from inspect import cleandoc, isabstract
47from pathlib import PurePath
48from sys import modules as loadedModules
49from types import ModuleType
50from typing import Any, Callable, Iterable, Union, Optional as Nullable
51from unittest import TestCase
52from warnings import warn
54from _pytest.unittest import TestCaseFunction, UnitTestCase
55from pytest import Class, Collector, Item, PytestCollectionWarning, StashKey, fixture, hookimpl
56from pyTooling.Decorators import export
57from pyTooling.Documentation import DocumentationError, splitDocString
60hierarchyKey: StashKey[dict[str, dict[str, str]]] = StashKey()
61"""Where the names of every test suite level are stashed, keyed by the dotted path matching ``classname``."""
64@export
65def getTestcases(cls: type) -> dict[str, Any]:
66 """
67 Return the methods marked as testcases.
69 :param cls: Class to search for marked methods.
70 :returns: Dictionary of a method's name to the method, for every method carrying ``__testcase_title__``.
71 """
72 return {
73 name: member
74 for name, member in vars(cls).items()
75 if callable(member) and hasattr(member, "__testcase_title__")
76 }
79@export
80class MarkedUnitTestCase(UnitTestCase):
81 """
82 Collector of a marked :class:`unittest.TestCase` class: every marked method is a testcase under its own name.
84 pytest's :mod:`unittest` support collects what :meth:`unittest.TestLoader.getTestCaseNames` returns - the methods
85 starting with :attr:`~unittest.TestLoader.testMethodPrefix`, ``"test"``. This collector collects them too, and
86 adds every marked method, in the order the class defines it. Such a testcase runs like any other: :mod:`unittest`
87 instantiates the class with the method's name, so ``setUp()``, ``tearDown()``, ``subTest()`` and skipping work as
88 they do for a method named ``test_*``.
89 """
91 def collect(self) -> Iterable[Union[Item, Collector]]:
92 """
93 Collect the methods :mod:`unittest` finds by name, then the marked ones it doesn't.
95 :returns: The testcases of the class.
96 """
97 if not getattr(self.obj, "__test__", True):
98 return
100 items = list(super().collect())
101 collected = {item.name for item in items}
102 marked = [name for name in getTestcases(self.obj) if name not in collected]
104 # unittest runs 'runTest' only in a class without testcases, and marked methods are testcases
105 if len(marked) > 0 and collected == {"runTest"}:
106 items = []
108 yield from items
109 for name in marked:
110 yield TestCaseFunction.from_parent(self, name=name)
113@export
114@hookimpl(tryfirst=True)
115def pytest_pycollect_makeitem(collector: Collector, name: str, obj: Any) -> Nullable[Any]:
116 """
117 Collect a marked class or a marked method, whatever it is named.
119 A marked :class:`unittest.TestCase` is collected by :class:`MarkedUnitTestCase` instead of pytest's :mod:`unittest`
120 support, which would collect only the methods named ``test*``. It runs before that support, so a marked class
121 reaches this collector first.
123 :param collector: The module collector asking about the object.
124 :param name: Name the object is bound to in the module.
125 :param obj: The object to decide about.
126 :returns: A collector or a list of items for a marked entity, otherwise ``None`` to let pytest decide.
127 """
128 if isinstance(obj, type) and hasattr(obj, "__testsuite_title__"):
129 if issubclass(obj, TestCase): 129 ↛ 130line 129 didn't jump to line 130 because the condition on line 129 was never true
130 if isabstract(obj):
131 return None
133 return MarkedUnitTestCase.from_parent(collector, name=name, obj=obj)
135 return Class.from_parent(collector, name=name)
137 if callable(obj) and hasattr(obj, "__testcase_title__"):
138 return list(collector._genfunctions(name, obj))
140 return None
143@export
144def getNamesOfTestItem(holder: Union[ModuleType, type]) -> dict[str, str]:
145 """
146 Return the names an item carries as a test suite level.
148 A class marked with :deco:`~pyTooling.Testing.testsuite` carries all three in its ``__testsuite_***__`` fields.
149 A package or a module has only its doc-string, whose summary and full text are the summary and the description.
151 A summary longer than :data:`~pyTooling.Documentation.MAXIMUM_SUMMARY_LENGTH` isn't reported; a
152 :class:`~pytest.PytestCollectionWarning` names the item instead, so one doc-string doesn't stop the collection.
154 :param holder: The package, module or class to read the names from.
155 :returns: Dictionary of a name's kind to its value, holding only the ones that are not empty.
156 """
157 if hasattr(holder, "__testsuite_title__"):
158 names = {
159 "title": holder.__testsuite_title__,
160 "summary": holder.__testsuite_summary__,
161 "description": holder.__testsuite_description__,
162 }
163 else:
164 try:
165 summary, _ = splitDocString(holder.__doc__)
166 except DocumentationError as ex:
167 warn(PytestCollectionWarning(f"The summary of '{holder.__name__}' isn't reported: {ex}"))
168 summary = ""
170 names = {
171 "summary": summary,
172 "description": "" if holder.__doc__ is None else cleandoc(holder.__doc__),
173 }
175 return {kind: value for kind, value in names.items() if value != ""}
178@export
179def getLevelNames(item: Item) -> dict[str, dict[str, str]]:
180 """
181 Return the names of every test suite level a testcase sits in, keyed by the level's dotted path.
183 The path is built the way pytest builds a testcase's ``classname``: the module's dotted path, then every class
184 between the module and the testcase. So the keys of the result are prefixes of - and finally equal to - the
185 ``classname`` the same testcase gets in the JUnit report, which is what lets a reader join the two.
187 A level contributes only the names it has, and a level with none is skipped, so an unmarked test suite of
188 undocumented packages produces an empty result.
190 :param item: The collected testcase to walk the levels of.
191 :returns: Dictionary of a level's dotted path to its names.
192 """
193 modulePath, _, remainder = item.nodeid.partition("::")
194 classNames = remainder.split("::")[:-1]
196 levels: dict[str, dict[str, str]] = {}
197 path, holder = "", None
198 for level in (*PurePath(modulePath).with_suffix("").parts, *classNames):
199 path = f"{path}.{level}" if path != "" else level
201 # below the module, the levels are classes reached from it; at and above it, they are loaded modules
202 holder = getattr(holder, level, None) if level in classNames else loadedModules.get(path, None)
203 if holder is None:
204 continue
206 if len(names := getNamesOfTestItem(holder)) > 0: 206 ↛ 198line 206 didn't jump to line 198 because the condition on line 206 was always true
207 levels[path] = names
209 return levels
212@export
213def pytest_collection_modifyitems(items: list[Item]) -> None:
214 """
215 Attach the names of every marked item to the item, for the report to pick up.
217 A test item has four names, and only the first of them is what Python calls it:
219 * the **ID** - the module, class or method name, which is the item's ``classname``/``name``,
220 * the **title** - what the marker was given,
221 * the **summary** - the first paragraph of the doc-string,
222 * the **description** - the doc-string.
224 They travel as :attr:`~_pytest.nodes.Item.user_properties`, which is the channel the
225 :func:`~_pytest.python_api.record_property` fixture writes to: they are part of the test report, so they survive
226 being sent from a ``pytest-xdist`` worker, and they reach the JUnit report as ``<property>`` elements.
228 **The item's own name and node ID are deliberately left alone.** They are what selects a test - on the command
229 line, from an IDE, and from ``--last-failed``'s cache - and post-processing tools expect them to be identifiers,
230 free of spaces and punctuation. The title is additional information, not a replacement.
232 :param items: The collected items, modified in place.
233 """
234 hierarchy = items[0].config.stash.setdefault(hierarchyKey, {}) if len(items) > 0 else {}
236 for item in items:
237 hierarchy.update(getLevelNames(item))
239 testcaseTitle = getattr(getattr(item, "function", None), "__testcase_title__", None)
240 if testcaseTitle is None:
241 continue
243 function = item.function
245 # an item has four names: the ID (its 'classname'/'name'), a title, a summary and a description.
246 # The test suite's own names are not repeated here - they are in the session's properties, keyed by the
247 # level's dotted path, so they are written once instead of once per testcase.
248 for propertyName, value in (
249 ("title", testcaseTitle),
250 ("summary", getattr(function, "__testcase_summary__", "")),
251 ("description", getattr(function, "__testcase_description__", "")),
252 ):
253 if value != "":
254 item.user_properties.append((propertyName, value))
257@export
258@fixture(scope="session", autouse=True)
259def _recordTestsuiteHierarchy(request, record_testsuite_property: Callable[[str, object], None]) -> None:
260 """
261 Write the names of every test suite level into the report's session-level ``<properties>``.
263 JUnit has one flat ``<testsuite>`` element and squeezes the hierarchy into a dotted ``classname``, so a level
264 between the root and the class has no element that could carry a title or a description. The names are therefore
265 written as *keys*: ``tests.unit.Versioning.description`` names the level whose path is ``tests.unit.Versioning``,
266 which is a prefix of that testcase's ``classname``.
268 They are written **once per session**, not once per testcase - a property inside ``<testcase>`` would repeat for
269 every testcase in the level.
271 :param request: The fixture request, holding the configuration the levels were stashed on.
272 :param record_testsuite_property: pytest's fixture writing a property into the session's ``<testsuite>``.
273 """
274 for path, names in request.config.stash.get(hierarchyKey, {}).items():
275 for kind, value in names.items():
276 record_testsuite_property(f"{path}.{kind}", value)