Coverage for pyEDAA/Reports/Unittesting/JUnit/CTestJUnit.py: 44%

183 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-05-16 22:20 +0000

1# ==================================================================================================================== # 

2# _____ ____ _ _ ____ _ # 

3# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ # 

4# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| # 

5# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ # 

6# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ # 

7# |_| |___/ |_| # 

8# ==================================================================================================================== # 

9# Authors: # 

10# Patrick Lehmann # 

11# # 

12# License: # 

13# ==================================================================================================================== # 

14# Copyright 2024-2025 Electronic Design Automation Abstraction (EDA²) # 

15# Copyright 2023-2023 Patrick Lehmann - Bötzingen, Germany # 

16# # 

17# Licensed under the Apache License, Version 2.0 (the "License"); # 

18# you may not use this file except in compliance with the License. # 

19# You may obtain a copy of the License at # 

20# # 

21# http://www.apache.org/licenses/LICENSE-2.0 # 

22# # 

23# Unless required by applicable law or agreed to in writing, software # 

24# distributed under the License is distributed on an "AS IS" BASIS, # 

25# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

26# See the License for the specific language governing permissions and # 

27# limitations under the License. # 

28# # 

29# SPDX-License-Identifier: Apache-2.0 # 

30# ==================================================================================================================== # 

31# 

32""" 

33Reader for JUnit unit testing summary files in XML format. 

34""" 

35from pathlib import Path 

36from time import perf_counter_ns 

37from typing import Optional as Nullable, Generator, Tuple, Union, TypeVar, Type, ClassVar 

38 

39from lxml.etree import ElementTree, Element, SubElement, tostring, _Element 

40from pyTooling.Common import firstValue 

41from pyTooling.Decorators import export, InheritDocString 

42 

43from pyEDAA.Reports.Unittesting import UnittestException, TestsuiteKind 

44from pyEDAA.Reports.Unittesting import TestcaseStatus, TestsuiteStatus, IterationScheme 

45from pyEDAA.Reports.Unittesting import TestsuiteSummary as ut_TestsuiteSummary, Testsuite as ut_Testsuite 

46from pyEDAA.Reports.Unittesting.JUnit import Testcase as ju_Testcase, Testclass as ju_Testclass, Testsuite as ju_Testsuite 

47from pyEDAA.Reports.Unittesting.JUnit import TestsuiteSummary as ju_TestsuiteSummary, Document as ju_Document 

48 

49 

50TestsuiteType = TypeVar("TestsuiteType", bound="Testsuite") 

51TestcaseAggregateReturnType = Tuple[int, int, int] 

52TestsuiteAggregateReturnType = Tuple[int, int, int, int, int] 

53 

54 

55@export 

56@InheritDocString(ju_Testcase, merge=True) 

57class Testcase(ju_Testcase): 

58 """ 

59 This is a derived implementation for the CTest JUnit dialect. 

60 """ 

61 

62 

63@export 

64@InheritDocString(ju_Testclass, merge=True) 

65class Testclass(ju_Testclass): 

66 """ 

67 This is a derived implementation for the CTest JUnit dialect. 

68 """ 

69 

70 

71@export 

72@InheritDocString(ju_Testsuite, merge=True) 

73class Testsuite(ju_Testsuite): 

74 """ 

75 This is a derived implementation for the CTest JUnit dialect. 

76 """ 

77 

78 def Aggregate(self, strict: bool = True) -> TestsuiteAggregateReturnType: 

79 tests, skipped, errored, failed, passed = super().Aggregate() 

80 

81 for testclass in self._testclasses.values(): 

82 _ = testclass.Aggregate(strict) 

83 

84 tests += 1 

85 

86 status = testclass._status 

87 if status is TestcaseStatus.Unknown: 

88 raise UnittestException(f"Found testclass '{testclass._name}' with state 'Unknown'.") 

89 elif status is TestcaseStatus.Skipped: 

90 skipped += 1 

91 elif status is TestcaseStatus.Errored: 

92 errored += 1 

93 elif status is TestcaseStatus.Passed: 

94 passed += 1 

95 elif status is TestcaseStatus.Failed: 

96 failed += 1 

97 elif status & TestcaseStatus.Mask is not TestcaseStatus.Unknown: 

98 raise UnittestException(f"Found testclass '{testclass._name}' with unsupported state '{status}'.") 

99 else: 

100 raise UnittestException(f"Internal error for testclass '{testclass._name}', field '_status' is '{status}'.") 

101 

102 self._tests = tests 

103 self._skipped = skipped 

104 self._errored = errored 

105 self._failed = failed 

106 self._passed = passed 

107 

108 if errored > 0: 

109 self._status = TestsuiteStatus.Errored 

110 elif failed > 0: 

111 self._status = TestsuiteStatus.Failed 

112 elif tests == 0: 

113 self._status = TestsuiteStatus.Empty 

114 elif tests - skipped == passed: 

115 self._status = TestsuiteStatus.Passed 

116 elif tests == skipped: 

117 self._status = TestsuiteStatus.Skipped 

118 else: 

119 self._status = TestsuiteStatus.Unknown 

120 

121 return tests, skipped, errored, failed, passed 

122 

123 @classmethod 

124 def FromTestsuite(cls, testsuite: ut_Testsuite) -> "Testsuite": 

125 """ 

126 Convert a test suite of the unified test entity data model to the JUnit specific data model's test suite object 

127 adhering to the CTest JUnit dialect. 

128 

129 :param testsuite: Test suite from unified data model. 

130 :return: Test suite from JUnit specific data model (CTest JUnit dialect). 

131 """ 

132 juTestsuite = cls( 

133 testsuite._name, 

134 startTime=testsuite._startTime, 

135 duration=testsuite._totalDuration, 

136 status= testsuite._status, 

137 ) 

138 

139 juTestsuite._tests = testsuite._tests 

140 juTestsuite._skipped = testsuite._skipped 

141 juTestsuite._errored = testsuite._errored 

142 juTestsuite._failed = testsuite._failed 

143 juTestsuite._passed = testsuite._passed 

144 

145 for tc in testsuite.IterateTestcases(): 

146 ts = tc._parent 

147 if ts is None: 

148 raise UnittestException(f"Testcase '{tc._name}' is not part of a hierarchy.") 

149 

150 classname = ts._name 

151 ts = ts._parent 

152 while ts is not None and ts._kind > TestsuiteKind.Logical: 

153 classname = f"{ts._name}.{classname}" 

154 ts = ts._parent 

155 

156 if classname in juTestsuite._testclasses: 

157 juClass = juTestsuite._testclasses[classname] 

158 else: 

159 juClass = Testclass(classname, parent=juTestsuite) 

160 

161 juClass.AddTestcase(Testcase.FromTestcase(tc)) 

162 

163 return juTestsuite 

164 

165 

166@export 

167@InheritDocString(ju_TestsuiteSummary, merge=True) 

168class TestsuiteSummary(ju_TestsuiteSummary): 

169 """ 

170 This is a derived implementation for the CTest JUnit dialect. 

171 """ 

172 

173 @classmethod 

174 def FromTestsuiteSummary(cls, testsuiteSummary: ut_TestsuiteSummary) -> "TestsuiteSummary": 

175 """ 

176 Convert a test suite summary of the unified test entity data model to the JUnit specific data model's test suite 

177 summary object adhering to the CTest JUnit dialect. 

178 

179 :param testsuiteSummary: Test suite summary from unified data model. 

180 :return: Test suite summary from JUnit specific data model (CTest JUnit dialect). 

181 """ 

182 return cls( 

183 testsuiteSummary._name, 

184 startTime=testsuiteSummary._startTime, 

185 duration=testsuiteSummary._totalDuration, 

186 status=testsuiteSummary._status, 

187 testsuites=(ut_Testsuite.FromTestsuite(testsuite) for testsuite in testsuiteSummary._testsuites.values()) 

188 ) 

189 

190 

191@export 

192class Document(ju_Document): 

193 """ 

194 A document reader and writer for the CTest JUnit XML file format. 

195 

196 This class reads, validates and transforms an XML file in the CTest JUnit format into a JUnit data model. It can then 

197 be converted into a unified test entity data model. 

198 

199 In reverse, a JUnit data model instance with the specific CTest JUnit file format can be created from a unified test 

200 entity data model. This data model can be written as XML into a file. 

201 """ 

202 

203 _TESTCASE: ClassVar[Type[Testcase]] = Testcase 

204 _TESTCLASS: ClassVar[Type[Testclass]] = Testclass 

205 _TESTSUITE: ClassVar[Type[Testsuite]] = Testsuite 

206 

207 @classmethod 

208 def FromTestsuiteSummary(cls, xmlReportFile: Path, testsuiteSummary: ut_TestsuiteSummary): 

209 doc = cls(xmlReportFile) 

210 doc._name = testsuiteSummary._name 

211 doc._startTime = testsuiteSummary._startTime 

212 doc._duration = testsuiteSummary._totalDuration 

213 doc._status = testsuiteSummary._status 

214 doc._tests = testsuiteSummary._tests 

215 doc._skipped = testsuiteSummary._skipped 

216 doc._errored = testsuiteSummary._errored 

217 doc._failed = testsuiteSummary._failed 

218 doc._passed = testsuiteSummary._passed 

219 

220 doc.AddTestsuites(Testsuite.FromTestsuite(testsuite) for testsuite in testsuiteSummary._testsuites.values()) 

221 

222 return doc 

223 

224 def Analyze(self) -> None: 

225 """ 

226 Analyze the XML file, parse the content into an XML data structure and validate the data structure using an XML 

227 schema. 

228 

229 .. hint:: 

230 

231 The time spend for analysis will be made available via property :data:`AnalysisDuration`. 

232 

233 The used XML schema definition is specific to the CTest JUnit dialect. 

234 """ 

235 xmlSchemaFile = "CTest-JUnit.xsd" 

236 self._Analyze(xmlSchemaFile) 

237 

238 def Write(self, path: Nullable[Path] = None, overwrite: bool = False, regenerate: bool = False) -> None: 

239 """ 

240 Write the data model as XML into a file adhering to the CTest dialect. 

241 

242 :param path: Optional path to the XMl file, if internal path shouldn't be used. 

243 :param overwrite: If true, overwrite an existing file. 

244 :param regenerate: If true, regenerate the XML structure from data model. 

245 :raises UnittestException: If the file cannot be overwritten. 

246 :raises UnittestException: If the internal XML data structure wasn't generated. 

247 :raises UnittestException: If the file cannot be opened or written. 

248 """ 

249 if path is None: 249 ↛ 250line 249 didn't jump to line 250 because the condition on line 249 was never true

250 path = self._path 

251 

252 if not overwrite and path.exists(): 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true

253 raise UnittestException(f"JUnit XML file '{path}' can not be overwritten.") \ 

254 from FileExistsError(f"File '{path}' already exists.") 

255 

256 if regenerate: 256 ↛ 259line 256 didn't jump to line 259 because the condition on line 256 was always true

257 self.Generate(overwrite=True) 

258 

259 if self._xmlDocument is None: 259 ↛ 260line 259 didn't jump to line 260 because the condition on line 259 was never true

260 ex = UnittestException(f"Internal XML document tree is empty and needs to be generated before write is possible.") 

261 ex.add_note(f"Call 'JUnitDocument.Generate()' or 'JUnitDocument.Write(..., regenerate=True)'.") 

262 raise ex 

263 

264 try: 

265 with path.open("wb") as file: 

266 file.write(tostring(self._xmlDocument, encoding="utf-8", xml_declaration=True, pretty_print=True)) 

267 except Exception as ex: 

268 raise UnittestException(f"JUnit XML file '{path}' can not be written.") from ex 

269 

270 def Convert(self) -> None: 

271 """ 

272 Convert the parsed and validated XML data structure into a JUnit test entity hierarchy. 

273 

274 This method converts the root element. 

275 

276 .. hint:: 

277 

278 The time spend for model conversion will be made available via property :data:`ModelConversionDuration`. 

279 

280 :raises UnittestException: If XML was not read and parsed before. 

281 """ 

282 if self._xmlDocument is None: 282 ↛ 283line 282 didn't jump to line 283 because the condition on line 282 was never true

283 ex = UnittestException(f"JUnit XML file '{self._path}' needs to be read and analyzed by an XML parser.") 

284 ex.add_note(f"Call 'JUnitDocument.Analyze()' or create the document using 'JUnitDocument(path, parse=True)'.") 

285 raise ex 

286 

287 startConversion = perf_counter_ns() 

288 rootElement: _Element = self._xmlDocument.getroot() 

289 

290 self._name = self._ConvertName(rootElement, optional=True) 

291 self._startTime =self._ConvertTimestamp(rootElement, optional=True) 

292 self._duration = self._ConvertTime(rootElement, optional=True) 

293 

294 # tests = rootElement.getAttribute("tests") 

295 # skipped = rootElement.getAttribute("skipped") 

296 # errors = rootElement.getAttribute("errors") 

297 # failures = rootElement.getAttribute("failures") 

298 # assertions = rootElement.getAttribute("assertions") 

299 

300 ts = Testsuite(self._name, startTime=self._startTime, duration=self._duration, parent=self) 

301 self._ConvertTestsuiteChildren(rootElement, ts) 

302 

303 self.Aggregate() 

304 endConversation = perf_counter_ns() 

305 self._modelConversion = (endConversation - startConversion) / 1e9 

306 

307 def _ConvertTestsuite(self, parent: TestsuiteSummary, testsuitesNode: _Element) -> None: 

308 """ 

309 Convert the XML data structure of a ``<testsuite>`` to a test suite. 

310 

311 This method uses private helper methods provided by the base-class. 

312 

313 :param parent: The test suite summary as a parent element in the test entity hierarchy. 

314 :param testsuitesNode: The current XML element node representing a test suite. 

315 """ 

316 newTestsuite = Testsuite( 

317 self._ConvertName(testsuitesNode, optional=False), 

318 self._ConvertHostname(testsuitesNode, optional=False), 

319 self._ConvertTimestamp(testsuitesNode, optional=False), 

320 self._ConvertTime(testsuitesNode, optional=False), 

321 parent=parent 

322 ) 

323 

324 self._ConvertTestsuiteChildren(testsuitesNode, newTestsuite) 

325 

326 def Generate(self, overwrite: bool = False) -> None: 

327 """ 

328 Generate the internal XML data structure from test suites and test cases. 

329 

330 This method generates the XML root element (``<testsuite>``) and recursively calls other generated methods. 

331 

332 :param overwrite: Overwrite the internal XML data structure. 

333 :raises UnittestException: If overwrite is false and the internal XML data structure is not empty. 

334 """ 

335 if not overwrite and self._xmlDocument is not None: 335 ↛ 336line 335 didn't jump to line 336 because the condition on line 335 was never true

336 raise UnittestException(f"Internal XML document is populated with data.") 

337 

338 if self.TestsuiteCount != 1: 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true

339 ex = UnittestException(f"The CTest JUnit format requires exactly one test suite.") 

340 ex.add_note(f"Found {self.TestsuiteCount} test suites.") 

341 raise ex 

342 

343 testsuite = firstValue(self._testsuites) 

344 

345 rootElement = Element("testsuite") 

346 rootElement.attrib["name"] = self._name 

347 if self._startTime is not None: 347 ↛ 349line 347 didn't jump to line 349 because the condition on line 347 was always true

348 rootElement.attrib["timestamp"] = f"{self._startTime.isoformat()}" 

349 if self._duration is not None: 349 ↛ 351line 349 didn't jump to line 351 because the condition on line 349 was always true

350 rootElement.attrib["time"] = f"{self._duration.total_seconds():.6f}" 

351 rootElement.attrib["tests"] = str(self._tests) 

352 rootElement.attrib["failures"] = str(self._failed) 

353 # rootElement.attrib["errors"] = str(self._errored) 

354 rootElement.attrib["skipped"] = str(self._skipped) 

355 rootElement.attrib["disabled"] = "0" # TODO: find a value 

356 # if self._assertionCount is not None: 

357 # rootElement.attrib["assertions"] = f"{self._assertionCount}" 

358 rootElement.attrib["hostname"] = str(testsuite._hostname) # TODO: find a value 

359 

360 self._xmlDocument = ElementTree(rootElement) 

361 

362 for testclass in testsuite._testclasses.values(): 

363 for tc in testclass._testcases.values(): 

364 self._GenerateTestcase(tc, rootElement) 

365 

366 def _GenerateTestcase(self, testcase: Testcase, parentElement: _Element) -> None: 

367 """ 

368 Generate the internal XML data structure for a test case. 

369 

370 This method generates the XML element (``<testcase>``) and recursively calls other generated methods. 

371 

372 :param testcase: The test case to convert to an XML data structures. 

373 :param parentElement: The parent XML data structure element, this data structure part will be added to. 

374 :return: 

375 """ 

376 testcaseElement = SubElement(parentElement, "testcase") 

377 if testcase.Classname is not None: 377 ↛ 379line 377 didn't jump to line 379 because the condition on line 377 was always true

378 testcaseElement.attrib["classname"] = testcase.Classname 

379 testcaseElement.attrib["name"] = testcase._name 

380 if testcase._duration is not None: 380 ↛ 382line 380 didn't jump to line 382 because the condition on line 380 was always true

381 testcaseElement.attrib["time"] = f"{testcase._duration.total_seconds():.6f}" 

382 if testcase._assertionCount is not None: 382 ↛ 383line 382 didn't jump to line 383 because the condition on line 382 was never true

383 testcaseElement.attrib["assertions"] = f"{testcase._assertionCount}" 

384 

385 testcaseElement.attrib["status"] = "run" # TODO: find a value 

386 

387 if testcase._status is TestcaseStatus.Passed: 387 ↛ 389line 387 didn't jump to line 389 because the condition on line 387 was always true

388 pass 

389 elif testcase._status is TestcaseStatus.Failed: 

390 failureElement = SubElement(testcaseElement, "failure") 

391 elif testcase._status is TestcaseStatus.Skipped: 

392 skippedElement = SubElement(testcaseElement, "skipped") 

393 else: 

394 errorElement = SubElement(testcaseElement, "error")