Coverage for pyTooling/Testing/PyTest.py: 80%
79 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-03 23:02 +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
53from _pytest.unittest import TestCaseFunction, UnitTestCase
54from pytest import Class, Collector, Item, StashKey, fixture, hookimpl
55from pyTooling.Decorators import export
56from pyTooling.Documentation import splitDocString
59hierarchyKey: StashKey[dict[str, dict[str, str]]] = StashKey()
60"""Where the names of every test suite level are stashed, keyed by the dotted path matching ``classname``."""
63@export
64def getTestcases(cls: type) -> dict[str, Any]:
65 """
66 Return the methods marked as testcases.
68 :param cls: Class to search for marked methods.
69 :returns: Dictionary of a method's name to the method, for every method carrying ``__testcase_title__``.
70 """
71 return {
72 name: member
73 for name, member in vars(cls).items()
74 if callable(member) and hasattr(member, "__testcase_title__")
75 }
78@export
79class MarkedUnitTestCase(UnitTestCase):
80 """
81 Collector of a marked :class:`unittest.TestCase` class: every marked method is a testcase under its own name.
83 pytest's :mod:`unittest` support collects what :meth:`unittest.TestLoader.getTestCaseNames` returns - the methods
84 starting with :attr:`~unittest.TestLoader.testMethodPrefix`, ``"test"``. This collector collects them too, and
85 adds every marked method, in the order the class defines it. Such a testcase runs like any other: :mod:`unittest`
86 instantiates the class with the method's name, so ``setUp()``, ``tearDown()``, ``subTest()`` and skipping work as
87 they do for a method named ``test_*``.
88 """
90 def collect(self) -> Iterable[Union[Item, Collector]]:
91 """
92 Collect the methods :mod:`unittest` finds by name, then the marked ones it doesn't.
94 :returns: The testcases of the class.
95 """
96 if not getattr(self.obj, "__test__", True):
97 return
99 items = list(super().collect())
100 collected = {item.name for item in items}
101 marked = [name for name in getTestcases(self.obj) if name not in collected]
103 # unittest runs 'runTest' only in a class without testcases, and marked methods are testcases
104 if len(marked) > 0 and collected == {"runTest"}:
105 items = []
107 yield from items
108 for name in marked:
109 yield TestCaseFunction.from_parent(self, name=name)
112@export
113@hookimpl(tryfirst=True)
114def pytest_pycollect_makeitem(collector: Collector, name: str, obj: Any) -> Nullable[Any]:
115 """
116 Collect a marked class or a marked method, whatever it is named.
118 A marked :class:`unittest.TestCase` is collected by :class:`MarkedUnitTestCase` instead of pytest's :mod:`unittest`
119 support, which would collect only the methods named ``test*``. It runs before that support, so a marked class
120 reaches this collector first.
122 :param collector: The module collector asking about the object.
123 :param name: Name the object is bound to in the module.
124 :param obj: The object to decide about.
125 :returns: A collector or a list of items for a marked entity, otherwise ``None`` to let pytest decide.
126 """
127 if isinstance(obj, type) and hasattr(obj, "__testsuite_title__"):
128 if issubclass(obj, TestCase): 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true
129 if isabstract(obj):
130 return None
132 return MarkedUnitTestCase.from_parent(collector, name=name, obj=obj)
134 return Class.from_parent(collector, name=name)
136 if callable(obj) and hasattr(obj, "__testcase_title__"):
137 return list(collector._genfunctions(name, obj))
139 return None
142@export
143def getNamesOfTestItem(holder: Union[ModuleType, type]) -> dict[str, str]:
144 """
145 Return the names an item carries as a test suite level.
147 A class marked with :deco:`~pyTooling.Testing.testsuite` carries all three in its ``__testsuite_***__`` fields.
148 A package or a module has only its doc-string, whose summary and full text are the summary and the description.
150 :param holder: The package, module or class to read the names from.
151 :returns: Dictionary of a name's kind to its value, holding only the ones that are not empty.
152 """
153 if hasattr(holder, "__testsuite_title__"):
154 names = {
155 "title": holder.__testsuite_title__,
156 "summary": holder.__testsuite_summary__,
157 "description": holder.__testsuite_description__,
158 }
159 else:
160 summary, _ = splitDocString(holder.__doc__)
161 names = {
162 "summary": summary,
163 "description": "" if holder.__doc__ is None else cleandoc(holder.__doc__),
164 }
166 return {kind: value for kind, value in names.items() if value != ""}
169@export
170def getLevelNames(item: Item) -> dict[str, dict[str, str]]:
171 """
172 Return the names of every test suite level a testcase sits in, keyed by the level's dotted path.
174 The path is built the way pytest builds a testcase's ``classname``: the module's dotted path, then every class
175 between the module and the testcase. So the keys of the result are prefixes of - and finally equal to - the
176 ``classname`` the same testcase gets in the JUnit report, which is what lets a reader join the two.
178 A level contributes only the names it has, and a level with none is skipped, so an unmarked test suite of
179 undocumented packages produces an empty result.
181 :param item: The collected testcase to walk the levels of.
182 :returns: Dictionary of a level's dotted path to its names.
183 """
184 modulePath, _, remainder = item.nodeid.partition("::")
185 classNames = remainder.split("::")[:-1]
187 levels: dict[str, dict[str, str]] = {}
188 path, holder = "", None
189 for level in (*PurePath(modulePath).with_suffix("").parts, *classNames):
190 path = f"{path}.{level}" if path != "" else level
192 # below the module, the levels are classes reached from it; at and above it, they are loaded modules
193 holder = getattr(holder, level, None) if level in classNames else loadedModules.get(path, None)
194 if holder is None:
195 continue
197 if len(names := getNamesOfTestItem(holder)) > 0: 197 ↛ 189line 197 didn't jump to line 189 because the condition on line 197 was always true
198 levels[path] = names
200 return levels
203@export
204def pytest_collection_modifyitems(items: list[Item]) -> None:
205 """
206 Attach the names of every marked item to the item, for the report to pick up.
208 A test item has four names, and only the first of them is what Python calls it:
210 * the **ID** - the module, class or method name, which is the item's ``classname``/``name``,
211 * the **title** - what the marker was given,
212 * the **summary** - the first paragraph of the doc-string,
213 * the **description** - the doc-string.
215 They travel as :attr:`~_pytest.nodes.Item.user_properties`, which is the channel the
216 :func:`~_pytest.python_api.record_property` fixture writes to: they are part of the test report, so they survive
217 being sent from a ``pytest-xdist`` worker, and they reach the JUnit report as ``<property>`` elements.
219 **The item's own name and node ID are deliberately left alone.** They are what selects a test - on the command
220 line, from an IDE, and from ``--last-failed``'s cache - and post-processing tools expect them to be identifiers,
221 free of spaces and punctuation. The title is additional information, not a replacement.
223 :param items: The collected items, modified in place.
224 """
225 hierarchy = items[0].config.stash.setdefault(hierarchyKey, {}) if len(items) > 0 else {}
227 for item in items:
228 hierarchy.update(getLevelNames(item))
230 testcaseTitle = getattr(getattr(item, "function", None), "__testcase_title__", None)
231 if testcaseTitle is None:
232 continue
234 function = item.function
236 # an item has four names: the ID (its 'classname'/'name'), a title, a summary and a description.
237 # The test suite's own names are not repeated here - they are in the session's properties, keyed by the
238 # level's dotted path, so they are written once instead of once per testcase.
239 for propertyName, value in (
240 ("title", testcaseTitle),
241 ("summary", getattr(function, "__testcase_summary__", "")),
242 ("description", getattr(function, "__testcase_description__", "")),
243 ):
244 if value != "":
245 item.user_properties.append((propertyName, value))
248@export
249@fixture(scope="session", autouse=True)
250def _recordTestsuiteHierarchy(request, record_testsuite_property: Callable[[str, object], None]) -> None:
251 """
252 Write the names of every test suite level into the report's session-level ``<properties>``.
254 JUnit has one flat ``<testsuite>`` element and squeezes the hierarchy into a dotted ``classname``, so a level
255 between the root and the class has no element that could carry a title or a description. The names are therefore
256 written as *keys*: ``tests.unit.Versioning.description`` names the level whose path is ``tests.unit.Versioning``,
257 which is a prefix of that testcase's ``classname``.
259 They are written **once per session**, not once per testcase - a property inside ``<testcase>`` would repeat for
260 every testcase in the level.
262 :param request: The fixture request, holding the configuration the levels were stashed on.
263 :param record_testsuite_property: pytest's fixture writing a property into the session's ``<testsuite>``.
264 """
265 for path, names in request.config.stash.get(hierarchyKey, {}).items():
266 for kind, value in names.items():
267 record_testsuite_property(f"{path}.{kind}", value)