Coverage for pyTooling/Testing/PyTest.py: 98%

65 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-08 21:24 +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. 

33 

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. 

38 

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. 

41 

42.. hint:: 

43 

44 See :ref:`high-level help <TESTING/Markers>` for explanations and usage examples. 

45""" 

46from inspect import cleandoc 

47from pathlib import PurePath 

48from sys import modules as loadedModules 

49from types import ModuleType 

50from typing import Any, Callable, Union, Optional as Nullable 

51from unittest import TestCase 

52 

53from pytest import Class, Collector, Item, StashKey, fixture 

54from pyTooling.Decorators import export 

55from pyTooling.Documentation import splitDocString 

56 

57 

58hierarchyKey: StashKey[dict[str, dict[str, str]]] = StashKey() 

59"""Where the names of every test suite level are stashed, keyed by the dotted path matching ``classname``.""" 

60 

61 

62@export 

63def getTestcases(cls: type) -> dict[str, Any]: 

64 """ 

65 Return the methods marked as testcases. 

66 

67 :param cls: Class to search for marked methods. 

68 :returns: Dictionary of a method's name to the method, for every method carrying ``__testcase_title__``. 

69 """ 

70 return { 

71 name: member 

72 for name, member in vars(cls).items() 

73 if callable(member) and hasattr(member, "__testcase_title__") 

74 } 

75 

76 

77@export 

78def pytest_pycollect_makeitem(collector: Collector, name: str, obj: Any) -> Nullable[Any]: 

79 """ 

80 Collect a marked class or a marked method, whatever it is named. 

81 

82 A marked :class:`unittest.TestCase` is a special case: such a class is collected by pytest's :mod:`unittest` 

83 support, which asks :meth:`unittest.TestLoader.getTestCaseNames` for the test methods - and that loader matches 

84 :attr:`~unittest.TestLoader.testMethodPrefix`, which is ``"test"`` and is *not* the ``python_functions`` setting. 

85 Each marked method is therefore aliased under a name that loader accepts, and the class is handed back to pytest, 

86 which collects a :class:`~unittest.TestCase` subclass regardless of ``python_classes``. The alias reaches no 

87 report, because the entry is titled from the marker. 

88 

89 :param collector: The module collector asking about the object. 

90 :param name: Name the object is bound to in the module. 

91 :param obj: The object to decide about. 

92 :returns: A collector or a list of items for a marked entity, otherwise ``None`` to let pytest decide. 

93 """ 

94 if isinstance(obj, type) and hasattr(obj, "__testsuite_title__"): 

95 if issubclass(obj, TestCase): 

96 for methodName, method in getTestcases(obj).items(): 

97 if not methodName.startswith("test"): 97 ↛ 96line 97 didn't jump to line 96 because the condition on line 97 was always true

98 setattr(obj, f"test_{methodName}", method) 

99 

100 return None 

101 

102 return Class.from_parent(collector, name=name) 

103 

104 if callable(obj) and hasattr(obj, "__testcase_title__"): 

105 return list(collector._genfunctions(name, obj)) 

106 

107 return None 

108 

109 

110@export 

111def getNamesOfTestItem(holder: Union[ModuleType, type]) -> dict[str, str]: 

112 """ 

113 Return the names an item carries as a test suite level. 

114 

115 A class marked with :deco:`~pyTooling.Testing.testsuite` carries all three in its ``__testsuite_***__`` fields. 

116 A package or a module has only its doc-string, whose summary and full text are the summary and the description. 

117 

118 :param holder: The package, module or class to read the names from. 

119 :returns: Dictionary of a name's kind to its value, holding only the ones that are not empty. 

120 """ 

121 if hasattr(holder, "__testsuite_title__"): 

122 names = { 

123 "title": holder.__testsuite_title__, 

124 "summary": holder.__testsuite_summary__, 

125 "description": holder.__testsuite_description__, 

126 } 

127 else: 

128 summary, _ = splitDocString(holder.__doc__) 

129 names = { 

130 "summary": summary, 

131 "description": "" if holder.__doc__ is None else cleandoc(holder.__doc__), 

132 } 

133 

134 return {kind: value for kind, value in names.items() if value != ""} 

135 

136 

137@export 

138def getLevelNames(item: Item) -> dict[str, dict[str, str]]: 

139 """ 

140 Return the names of every test suite level a testcase sits in, keyed by the level's dotted path. 

141 

142 The path is built the way pytest builds a testcase's ``classname``: the module's dotted path, then every class 

143 between the module and the testcase. So the keys of the result are prefixes of - and finally equal to - the 

144 ``classname`` the same testcase gets in the JUnit report, which is what lets a reader join the two. 

145 

146 A level contributes only the names it has, and a level with none is skipped, so an unmarked test suite of 

147 undocumented packages produces an empty result. 

148 

149 :param item: The collected testcase to walk the levels of. 

150 :returns: Dictionary of a level's dotted path to its names. 

151 """ 

152 modulePath, _, remainder = item.nodeid.partition("::") 

153 classNames = remainder.split("::")[:-1] 

154 

155 levels: dict[str, dict[str, str]] = {} 

156 path, holder = "", None 

157 for level in (*PurePath(modulePath).with_suffix("").parts, *classNames): 

158 path = f"{path}.{level}" if path != "" else level 

159 

160 # below the module, the levels are classes reached from it; at and above it, they are loaded modules 

161 holder = getattr(holder, level, None) if level in classNames else loadedModules.get(path, None) 

162 if holder is None: 

163 continue 

164 

165 if len(names := getNamesOfTestItem(holder)) > 0: 165 ↛ 157line 165 didn't jump to line 157 because the condition on line 165 was always true

166 levels[path] = names 

167 

168 return levels 

169 

170 

171@export 

172def pytest_collection_modifyitems(items: list[Item]) -> None: 

173 """ 

174 Attach the names of every marked item to the item, for the report to pick up. 

175 

176 A test item has four names, and only the first of them is what Python calls it: 

177 

178 * the **ID** - the module, class or method name, which is the item's ``classname``/``name``, 

179 * the **title** - what the marker was given, 

180 * the **summary** - the first paragraph of the doc-string, 

181 * the **description** - the doc-string. 

182 

183 They travel as :attr:`~_pytest.nodes.Item.user_properties`, which is the channel the 

184 :func:`~_pytest.python_api.record_property` fixture writes to: they are part of the test report, so they survive 

185 being sent from a ``pytest-xdist`` worker, and they reach the JUnit report as ``<property>`` elements. 

186 

187 **The item's own name and node ID are deliberately left alone.** They are what selects a test - on the command 

188 line, from an IDE, and from ``--last-failed``'s cache - and post-processing tools expect them to be identifiers, 

189 free of spaces and punctuation. The title is additional information, not a replacement. 

190 

191 :param items: The collected items, modified in place. 

192 """ 

193 hierarchy = items[0].config.stash.setdefault(hierarchyKey, {}) if len(items) > 0 else {} 

194 

195 for item in items: 

196 hierarchy.update(getLevelNames(item)) 

197 

198 testcaseTitle = getattr(getattr(item, "function", None), "__testcase_title__", None) 

199 if testcaseTitle is None: 

200 continue 

201 

202 function = item.function 

203 

204 # an item has four names: the ID (its 'classname'/'name'), a title, a summary and a description. 

205 # The test suite's own names are not repeated here - they are in the session's properties, keyed by the 

206 # level's dotted path, so they are written once instead of once per testcase. 

207 for propertyName, value in ( 

208 ("title", testcaseTitle), 

209 ("summary", getattr(function, "__testcase_summary__", "")), 

210 ("description", getattr(function, "__testcase_description__", "")), 

211 ): 

212 if value != "": 

213 item.user_properties.append((propertyName, value)) 

214 

215 

216@export 

217@fixture(scope="session", autouse=True) 

218def _recordTestsuiteHierarchy(request, record_testsuite_property: Callable[[str, object], None]) -> None: 

219 """ 

220 Write the names of every test suite level into the report's session-level ``<properties>``. 

221 

222 JUnit has one flat ``<testsuite>`` element and squeezes the hierarchy into a dotted ``classname``, so a level 

223 between the root and the class has no element that could carry a title or a description. The names are therefore 

224 written as *keys*: ``tests.unit.Versioning.description`` names the level whose path is ``tests.unit.Versioning``, 

225 which is a prefix of that testcase's ``classname``. 

226 

227 They are written **once per session**, not once per testcase - a property inside ``<testcase>`` would repeat for 

228 every testcase in the level. 

229 

230 :param request: The fixture request, holding the configuration the levels were stashed on. 

231 :param record_testsuite_property: pytest's fixture writing a property into the session's ``<testsuite>``. 

232 """ 

233 for path, names in request.config.stash.get(hierarchyKey, {}).items(): 

234 for kind, value in names.items(): 

235 record_testsuite_property(f"{path}.{kind}", value)