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

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, 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 

53 

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 

58 

59 

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``.""" 

62 

63 

64@export 

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

66 """ 

67 Return the methods marked as testcases. 

68 

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 } 

77 

78 

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. 

83 

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 """ 

90 

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. 

94 

95 :returns: The testcases of the class. 

96 """ 

97 if not getattr(self.obj, "__test__", True): 

98 return 

99 

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] 

103 

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 = [] 

107 

108 yield from items 

109 for name in marked: 

110 yield TestCaseFunction.from_parent(self, name=name) 

111 

112 

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. 

118 

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. 

122 

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 

132 

133 return MarkedUnitTestCase.from_parent(collector, name=name, obj=obj) 

134 

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

136 

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

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

139 

140 return None 

141 

142 

143@export 

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

145 """ 

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

147 

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. 

150 

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. 

153 

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 = "" 

169 

170 names = { 

171 "summary": summary, 

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

173 } 

174 

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

176 

177 

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. 

182 

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. 

186 

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. 

189 

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] 

195 

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 

200 

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 

205 

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 

208 

209 return levels 

210 

211 

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. 

216 

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

218 

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. 

223 

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. 

227 

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. 

231 

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

233 """ 

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

235 

236 for item in items: 

237 hierarchy.update(getLevelNames(item)) 

238 

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

240 if testcaseTitle is None: 

241 continue 

242 

243 function = item.function 

244 

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)) 

255 

256 

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>``. 

262 

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``. 

267 

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

269 every testcase in the level. 

270 

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)