Coverage for pyEDAA/Reports/Unittesting/JUnit/__init__.py: 72%
714 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-29 03:43 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-29 03:43 +0000
1# ==================================================================================================================== #
2# _____ ____ _ _ ____ _ #
3# _ __ _ _| ____| _ \ / \ / \ | _ \ ___ _ __ ___ _ __| |_ ___ #
4# | '_ \| | | | _| | | | |/ _ \ / _ \ | |_) / _ \ '_ \ / _ \| '__| __/ __| #
5# | |_) | |_| | |___| |_| / ___ \ / ___ \ _| _ < __/ |_) | (_) | | | |_\__ \ #
6# | .__/ \__, |_____|____/_/ \_\/_/ \_(_)_| \_\___| .__/ \___/|_| \__|___/ #
7# |_| |___/ |_| #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2024-2026 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"""
33The pyEDAA.Reports.Unittesting.JUnit package implements a hierarchy of test entities for the JUnit unit testing summary
34file format (XML format). This test entity hierarchy is not derived from :class:`pyEDAA.Reports.Unittesting`, because it
35doesn't match the unified data model. Nonetheless, both data models can be converted to each other. In addition, derived
36data models are provided for the many dialects of that XML file format. See the list modules in this package for the
37implemented dialects.
39The test entity hierarchy consists of test cases, test classes, test suites and a test summary. Test cases are the leaf
40elements in the hierarchy and represent an individual test run. Next, test classes group test cases, because the
41original Ant + JUnit format groups test cases (Java methods) in a Java class. Next, test suites are used to group
42multiple test classes. Finally, the root element is a test summary. When such a summary is stored in a file format like
43Ant + JUnit4 XML, a file format specific document is derived from a summary class.
45**Data Model**
47.. mermaid::
49 graph TD;
50 doc[Document]
51 sum[Summary]
52 ts1[Testsuite]
53 ts11[Testsuite]
54 ts2[Testsuite]
56 tc111[Testclass]
57 tc112[Testclass]
58 tc23[Testclass]
60 tc1111[Testcase]
61 tc1112[Testcase]
62 tc1113[Testcase]
63 tc1121[Testcase]
64 tc1122[Testcase]
65 tc231[Testcase]
66 tc232[Testcase]
67 tc233[Testcase]
69 doc:::root -.-> sum:::summary
70 sum --> ts1:::suite
71 sum ---> ts2:::suite
72 ts1 --> ts11:::suite
74 ts11 --> tc111:::cls
75 ts11 --> tc112:::cls
76 ts2 --> tc23:::cls
78 tc111 --> tc1111:::case
79 tc111 --> tc1112:::case
80 tc111 --> tc1113:::case
81 tc112 --> tc1121:::case
82 tc112 --> tc1122:::case
83 tc23 --> tc231:::case
84 tc23 --> tc232:::case
85 tc23 --> tc233:::case
87 classDef root fill:#4dc3ff
88 classDef summary fill:#80d4ff
89 classDef suite fill:#b3e6ff
90 classDef cls fill:#ff9966
91 classDef case fill:#eeccff
92"""
93from datetime import datetime, timedelta
94from enum import Flag
95from pathlib import Path
96from time import perf_counter_ns
97from typing import Optional as Nullable, Iterable, Dict, Any, Generator, Tuple, Union, TypeVar, Type, ClassVar
99from lxml.etree import XMLParser, parse, XMLSchema, ElementTree, Element, SubElement, tostring
100from lxml.etree import XMLSyntaxError, _ElementTree, _Element, _Comment, XMLSchemaParseError
101from pyTooling.Common import getFullyQualifiedName, getResourceFile
102from pyTooling.Decorators import export, readonly
103from pyTooling.Exceptions import ToolingException
104from pyTooling.MetaClasses import ExtendedType, mustoverride, abstractmethod
105from pyTooling.Tree import Node
107from pyEDAA.Reports import Resources
108from pyEDAA.Reports.Unittesting import UnittestException, AlreadyInHierarchyException, DuplicateTestsuiteException, DuplicateTestcaseException
109from pyEDAA.Reports.Unittesting import TestcaseStatus, TestsuiteStatus, TestsuiteKind, IterationScheme
110from pyEDAA.Reports.Unittesting import Document as ut_Document, TestsuiteSummary as ut_TestsuiteSummary
111from pyEDAA.Reports.Unittesting import Testsuite as ut_Testsuite, Testcase as ut_Testcase
114@export
115class JUnitException:
116 """An exception-mixin for JUnit format specific exceptions."""
119@export
120class UnittestException(UnittestException, JUnitException):
121 pass
124@export
125class AlreadyInHierarchyException(AlreadyInHierarchyException, JUnitException):
126 """
127 A unit test exception raised if the element is already part of a hierarchy.
129 This exception is caused by an inconsistent data model. Elements added to the hierarchy should be part of the same
130 hierarchy should occur only once in the hierarchy.
132 .. hint::
134 This is usually caused by a non-None parent reference.
135 """
138@export
139class DuplicateTestsuiteException(DuplicateTestsuiteException, JUnitException):
140 """
141 A unit test exception raised on duplicate test suites (by name).
143 This exception is raised, if a child test suite with same name already exist in the test suite.
145 .. hint::
147 Test suite names need to be unique per parent element (test suite or test summary).
148 """
151@export
152class DuplicateTestcaseException(DuplicateTestcaseException, JUnitException):
153 """
154 A unit test exception raised on duplicate test cases (by name).
156 This exception is raised, if a child test case with same name already exist in the test suite.
158 .. hint::
160 Test case names need to be unique per parent element (test suite).
161 """
164@export
165class JUnitReaderMode(Flag):
166 Default = 0 #: Default behavior
167 DecoupleTestsuiteHierarchyAndTestcaseClassName = 1 #: Undocumented
170TestsuiteType = TypeVar("TestsuiteType", bound="Testsuite")
171TestcaseAggregateReturnType = Tuple[int, int, int]
172TestsuiteAggregateReturnType = Tuple[int, int, int, int, int, int]
175@export
176class Base(metaclass=ExtendedType, slots=True):
177 """
178 Base-class for all test entities (test cases, test classes, test suites, ...).
180 It provides a reference to the parent test entity, so bidirectional referencing can be used in the test entity
181 hierarchy.
183 Every test entity has a name to identity it. It's also used in the parent's child element dictionaries to identify the
184 child. |br|
185 E.g. it's used as a test case name in the dictionary of test cases in a test class.
186 """
188 _parent: Nullable["Testsuite"]
189 _name: str
191 def __init__(self, name: str, parent: Nullable["Testsuite"] = None) -> None:
192 """
193 Initializes the fields of the base-class.
195 :param name: Name of the test entity.
196 :param parent: Reference to the parent test entity.
197 :raises ValueError: When parameter 'name' is None.
198 :raises TypeError: When parameter 'name' is not a string.
199 :raises ValueError: When parameter 'name' is empty.
200 """
201 if name is None: 201 ↛ 202line 201 didn't jump to line 202 because the condition on line 201 was never true
202 raise ValueError(f"Parameter 'name' is None.")
203 elif not isinstance(name, str): 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true
204 ex = TypeError(f"Parameter 'name' is not of type 'str'.")
205 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
206 raise ex
207 elif name.strip() == "": 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true
208 raise ValueError(f"Parameter 'name' is empty.")
210 self._parent = parent
211 self._name = name
213 @readonly
214 def Parent(self) -> Nullable["Testsuite"]:
215 """
216 Read-only property to access the reference to the parent test entity.
218 :returns: Reference to the parent entity.
219 """
220 return self._parent
222 # QUESTION: allow Parent as setter?
224 @readonly
225 def Name(self) -> str:
226 """
227 Read-only property to access the test entity's name.
229 :returns: Name of the test entity.
230 """
231 return self._name
234@export
235class BaseWithProperties(Base):
236 """
237 Base-class for all test entities supporting properties (test cases, test suites, ...).
239 Every test entity has fields for the test duration and number of executed assertions.
241 Every test entity offers an internal dictionary for properties.
242 """
244 _duration: Nullable[timedelta]
245 _assertionCount: Nullable[int]
246 _properties: Dict[str, Any]
248 def __init__(
249 self,
250 name: str,
251 duration: Nullable[timedelta] = None,
252 assertionCount: Nullable[int] = None,
253 parent: Nullable["Testsuite"] = None
254 ) -> None:
255 """
256 Initializes the fields of the base-class.
258 :param name: Name of the test entity.
259 :param duration: Duration of the entity's execution.
260 :param assertionCount: Number of assertions within the test.
261 :param parent: Reference to the parent test entity.
262 :raises TypeError: If parameter 'duration' is not a timedelta.
263 :raises TypeError: If parameter 'assertionCount' is not an integer.
264 """
265 super().__init__(name, parent)
267 if duration is not None and not isinstance(duration, timedelta): 267 ↛ 268line 267 didn't jump to line 268 because the condition on line 267 was never true
268 ex = TypeError(f"Parameter 'duration' is not of type 'timedelta'.")
269 ex.add_note(f"Got type '{getFullyQualifiedName(duration)}'.")
270 raise ex
272 if assertionCount is not None and not isinstance(assertionCount, int): 272 ↛ 273line 272 didn't jump to line 273 because the condition on line 272 was never true
273 ex = TypeError(f"Parameter 'assertionCount' is not of type 'int'.")
274 ex.add_note(f"Got type '{getFullyQualifiedName(assertionCount)}'.")
275 raise ex
277 self._duration = duration
278 self._assertionCount = assertionCount
280 self._properties = {}
282 @readonly
283 def Duration(self) -> timedelta:
284 """
285 Read-only property to access the duration of a test entity run.
287 .. note::
289 The JUnit format doesn't distinguish setup, run and teardown durations.
291 :returns: Duration of the entity's execution.
292 """
293 return self._duration
295 @readonly
296 @abstractmethod
297 def AssertionCount(self) -> int:
298 """
299 Read-only property to access the number of assertions (checks) in a test case.
301 .. note::
303 The JUnit format doesn't distinguish passed and failed assertions.
305 :returns: Number of assertions.
306 """
308 def __len__(self) -> int:
309 """
310 Returns the number of annotated properties.
312 Syntax: :pycode:`length = len(obj)`
314 :returns: Number of annotated properties.
315 """
316 return len(self._properties)
318 def __getitem__(self, name: str) -> Any:
319 """
320 Access a property by name.
322 Syntax: :pycode:`value = obj[name]`
324 :param name: Name if the property.
325 :returns: Value of the accessed property.
326 """
327 return self._properties[name]
329 def __setitem__(self, name: str, value: Any) -> None:
330 """
331 Set the value of a property by name.
333 If the property doesn't exist yet, it's created.
335 Syntax: :pycode:`obj[name] = value`
337 :param name: Name of the property.
338 :param value: Value of the property.
339 """
340 self._properties[name] = value
342 def __delitem__(self, name: str) -> None:
343 """
344 Delete a property by name.
346 Syntax: :pycode:`del obj[name]`
348 :param name: Name if the property.
349 """
350 del self._properties[name]
352 def __contains__(self, name: str) -> bool:
353 """
354 Returns True, if a property was annotated by this name.
356 Syntax: :pycode:`name in obj`
358 :param name: Name of the property.
359 :returns: True, if the property was annotated.
360 """
361 return name in self._properties
363 def __iter__(self) -> Generator[Tuple[str, Any], None, None]:
364 """
365 Iterate all annotated properties.
367 Syntax: :pycode:`for name, value in obj:`
369 :returns: A generator of property tuples (name, value).
370 """
371 yield from self._properties.items()
374@export
375class Testcase(BaseWithProperties):
376 """
377 A testcase is the leaf-entity in the test entity hierarchy representing an individual test run.
379 Test cases are grouped by test classes in the test entity hierarchy. These are again grouped by test suites. The root
380 of the hierarchy is a test summary.
382 Every test case has an overall status like unknown, skipped, failed or passed.
383 """
385 _status: TestcaseStatus
387 def __init__(
388 self,
389 name: str,
390 duration: Nullable[timedelta] = None,
391 status: TestcaseStatus = TestcaseStatus.Unknown,
392 assertionCount: Nullable[int] = None,
393 parent: Nullable["Testclass"] = None
394 ) -> None:
395 """
396 Initializes the fields of a test case.
398 :param name: Name of the test entity.
399 :param duration: Duration of the entity's execution.
400 :param status: Status of the test case.
401 :param assertionCount: Number of assertions within the test.
402 :param parent: Reference to the parent test class.
403 :raises TypeError: If parameter 'parent' is not a Testsuite.
404 :raises ValueError: If parameter 'assertionCount' is not consistent.
405 """
406 if parent is not None:
407 if not isinstance(parent, Testclass): 407 ↛ 408line 407 didn't jump to line 408 because the condition on line 407 was never true
408 ex = TypeError(f"Parameter 'parent' is not of type 'Testclass'.")
409 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
410 raise ex
412 parent._testcases[name] = self
414 super().__init__(name, duration, assertionCount, parent)
416 if not isinstance(status, TestcaseStatus): 416 ↛ 417line 416 didn't jump to line 417 because the condition on line 416 was never true
417 ex = TypeError(f"Parameter 'status' is not of type 'TestcaseStatus'.")
418 ex.add_note(f"Got type '{getFullyQualifiedName(status)}'.")
419 raise ex
421 self._status = status
423 @readonly
424 def Classname(self) -> str:
425 """
426 Read-only property to access the class name of the test case.
428 :returns: The test case's class name.
430 .. note::
432 In the JUnit format, a test case is uniquely identified by a tuple of class name and test case name. This
433 structure has been decomposed by this data model into 2 leaf-levels in the test entity hierarchy. Thus, the class
434 name is represented by its own level and instances of test classes.
435 """
436 if self._parent is None:
437 raise UnittestException("Standalone Testcase instance is not linked to a Testclass.")
438 return self._parent._name
440 @readonly
441 def Status(self) -> TestcaseStatus:
442 """
443 Read-only property to access the status of the test case.
445 :returns: The test case's status.
446 """
447 return self._status
449 @readonly
450 def AssertionCount(self) -> int:
451 """
452 Read-only property to return the number of assertions (checks) in a test case.
454 .. note::
456 The JUnit format doesn't distinguish passed and failed assertions.
458 :returns: Number of assertions.
459 """
460 if self._assertionCount is None: 460 ↛ 462line 460 didn't jump to line 462 because the condition on line 460 was always true
461 return 0
462 return self._assertionCount
464 def Copy(self) -> "Testcase":
465 return self.__class__(
466 self._name,
467 self._duration,
468 self._status,
469 self._assertionCount
470 )
472 def Aggregate(self) -> None:
473 if self._status is TestcaseStatus.Unknown:
474 if self._assertionCount is None:
475 self._status = TestcaseStatus.Passed
476 elif self._assertionCount == 0: 476 ↛ 477line 476 didn't jump to line 477 because the condition on line 476 was never true
477 self._status = TestcaseStatus.Weak
478 else:
479 self._status = TestcaseStatus.Failed
481 # TODO: check for setup errors
482 # TODO: check for teardown errors
484 @classmethod
485 def FromTestcase(cls, testcase: ut_Testcase) -> "Testcase":
486 """
487 Convert a test case of the unified test entity data model to the JUnit specific data model's test case object.
489 :param testcase: Test case from unified data model.
490 :returns: Test case from JUnit specific data model.
491 """
492 return cls(
493 testcase._name,
494 duration=testcase._testDuration,
495 status= testcase._status,
496 assertionCount=testcase._assertionCount
497 )
499 def ToTestcase(self) -> ut_Testcase:
500 return ut_Testcase(
501 self._name,
502 testDuration=self._duration,
503 status=self._status,
504 assertionCount=self._assertionCount,
505 # TODO: as only assertions are recorded by JUnit files, all are marked as passed
506 passedAssertionCount=self._assertionCount
507 )
509 def ToTree(self) -> Node:
510 node = Node(value=self._name)
511 node["status"] = self._status
512 node["assertionCount"] = self._assertionCount
513 node["duration"] = self._duration
515 return node
517 def __str__(self) -> str:
518 moduleName = self.__module__.split(".")[-1]
519 className = self.__class__.__name__
520 return (
521 f"<{moduleName}{className} {self._name}: {self._status.name} - asserts:{self._assertionCount}>"
522 )
525@export
526class TestsuiteBase(BaseWithProperties):
527 """
528 Base-class for all test suites and for test summaries.
530 A test suite is a mid-level grouping element in the test entity hierarchy, whereas the test summary is the root
531 element in that hierarchy. While a test suite groups test classes, a test summary can only group test suites. Thus, a
532 test summary contains no test classes and test cases.
533 """
535 _startTime: Nullable[datetime]
536 _status: TestsuiteStatus
538 _tests: int
539 _skipped: int
540 _errored: int
541 _weak: int
542 _failed: int
543 _passed: int
545 def __init__(
546 self,
547 name: str,
548 startTime: Nullable[datetime] = None,
549 duration: Nullable[timedelta] = None,
550 status: TestsuiteStatus = TestsuiteStatus.Unknown,
551 parent: Nullable["Testsuite"] = None
552 ) -> None:
553 """
554 Initializes the based-class fields of a test suite or test summary.
556 :param name: Name of the test entity.
557 :param startTime: Time when the test entity was started.
558 :param duration: Duration of the entity's execution.
559 :param status: Overall status of the test entity.
560 :param parent: Reference to the parent test entity.
561 :raises TypeError: If parameter 'parent' is not a TestsuiteBase.
562 """
563 if parent is not None:
564 if not isinstance(parent, TestsuiteBase): 564 ↛ 565line 564 didn't jump to line 565 because the condition on line 564 was never true
565 ex = TypeError(f"Parameter 'parent' is not of type 'TestsuiteBase'.")
566 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
567 raise ex
569 parent._testsuites[name] = self
571 super().__init__(name, duration, None, parent)
573 self._startTime = startTime
574 self._status = status
575 self._tests = 0
576 self._skipped = 0
577 self._errored = 0
578 self._failed = 0
579 self._passed = 0
581 @readonly
582 def StartTime(self) -> Nullable[datetime]:
583 """
584 Read-only property to access the time the test entity's execution started.
586 :returns: Start time of the execution, or ``None`` if it wasn't recorded.
587 """
588 return self._startTime
590 @readonly
591 def Status(self) -> TestsuiteStatus:
592 """
593 Read-only property to access the test entity's aggregated status.
595 :returns: Status of the test entity.
596 """
597 return self._status
599 @readonly
600 @mustoverride
601 def TestcaseCount(self) -> int:
602 """
603 Read-only property to access the number of testcases in this entity.
605 :returns: Number of testcases.
606 """
607 pass
609 @readonly
610 def Tests(self) -> int:
611 """
612 Read-only property to access the number of testcases in this entity.
614 :returns: Number of testcases.
615 """
616 return self.TestcaseCount
618 @readonly
619 def Skipped(self) -> int:
620 """
621 Read-only property to access the number of skipped testcases.
623 :returns: Number of skipped testcases.
624 """
625 return self._skipped
627 @readonly
628 def Errored(self) -> int:
629 """
630 Read-only property to access the number of errored testcases.
632 :returns: Number of errored testcases.
633 """
634 return self._errored
636 @readonly
637 def Failed(self) -> int:
638 """
639 Read-only property to access the number of failed testcases.
641 :returns: Number of failed testcases.
642 """
643 return self._failed
645 @readonly
646 def Passed(self) -> int:
647 """
648 Read-only property to access the number of passed testcases.
650 :returns: Number of passed testcases.
651 """
652 return self._passed
654 def Aggregate(self) -> TestsuiteAggregateReturnType:
655 tests = 0
656 skipped = 0
657 errored = 0
658 weak = 0
659 failed = 0
660 passed = 0
662 # for testsuite in self._testsuites.values():
663 # t, s, e, w, f, p = testsuite.Aggregate()
664 # tests += t
665 # skipped += s
666 # errored += e
667 # weak += w
668 # failed += f
669 # passed += p
671 return tests, skipped, errored, weak, failed, passed
673 @mustoverride
674 def Iterate(self, scheme: IterationScheme = IterationScheme.Default) -> Generator[Union[TestsuiteType, Testcase], None, None]:
675 pass
678@export
679class Testclass(Base):
680 """
681 A test class is a low-level element in the test entity hierarchy representing a group of tests.
683 Test classes contain test cases and are grouped by a test suites.
684 """
686 _testcases: Dict[str, "Testcase"]
688 def __init__(
689 self,
690 classname: str,
691 testcases: Nullable[Iterable["Testcase"]] = None,
692 parent: Nullable["Testsuite"] = None
693 ) -> None:
694 """
695 Initializes the fields of the test class.
697 :param classname: Classname of the test entity.
698 :param parent: Reference to the parent test suite.
699 :raises ValueError: If parameter 'classname' is None.
700 :raises TypeError: If parameter 'classname' is not a string.
701 :raises ValueError: If parameter 'classname' is empty.
702 """
703 if parent is not None:
704 if not isinstance(parent, Testsuite): 704 ↛ 705line 704 didn't jump to line 705 because the condition on line 704 was never true
705 ex = TypeError(f"Parameter 'parent' is not of type 'Testsuite'.")
706 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
707 raise ex
709 parent._testclasses[classname] = self
711 super().__init__(classname, parent)
713 self._testcases = {}
714 if testcases is not None:
715 for testcase in testcases:
716 if testcase._parent is not None: 716 ↛ 717line 716 didn't jump to line 717 because the condition on line 716 was never true
717 raise AlreadyInHierarchyException(f"Testcase '{testcase._name}' is already part of a testsuite hierarchy.")
719 if testcase._name in self._testcases: 719 ↛ 720line 719 didn't jump to line 720 because the condition on line 719 was never true
720 raise DuplicateTestcaseException(f"Class already contains a testcase with same name '{testcase._name}'.")
722 testcase._parent = self
723 self._testcases[testcase._name] = testcase
725 @readonly
726 def Classname(self) -> str:
727 """
728 Read-only property to access the name of the test class.
730 :returns: The test class' name.
731 """
732 return self._name
734 @readonly
735 def Testcases(self) -> Dict[str, "Testcase"]:
736 """
737 Read-only property to access a reference to the internal dictionary of test cases.
739 :returns: Reference to the dictionary of test cases.
740 """
741 return self._testcases
743 @readonly
744 def TestcaseCount(self) -> int:
745 """
746 Read-only property to return the number of all test cases in the test entity hierarchy.
748 :returns: Number of test cases.
749 """
750 return len(self._testcases)
752 @readonly
753 def AssertionCount(self) -> int:
754 """
755 Read-only property to return the number of assertions across all testcases of this testclass.
757 :returns: Sum of the testcases' assertion counts.
758 """
759 return sum(tc.AssertionCount for tc in self._testcases.values())
761 def AddTestcase(self, testcase: "Testcase") -> None:
762 if testcase._parent is not None: 762 ↛ 763line 762 didn't jump to line 763 because the condition on line 762 was never true
763 raise ValueError(f"Testcase '{testcase._name}' is already part of a testsuite hierarchy.")
765 if testcase._name in self._testcases: 765 ↛ 766line 765 didn't jump to line 766 because the condition on line 765 was never true
766 raise DuplicateTestcaseException(f"Class already contains a testcase with same name '{testcase._name}'.")
768 testcase._parent = self
769 self._testcases[testcase._name] = testcase
771 def AddTestcases(self, testcases: Iterable["Testcase"]) -> None:
772 for testcase in testcases:
773 self.AddTestcase(testcase)
775 def ToTestsuite(self) -> ut_Testsuite:
776 return ut_Testsuite(
777 self._name,
778 TestsuiteKind.Class,
779 # startTime=self._startTime,
780 # totalDuration=self._duration,
781 # status=self._status,
782 testcases=(tc.ToTestcase() for tc in self._testcases.values())
783 )
785 def ToTree(self) -> Node:
786 node = Node(
787 value=self._name,
788 children=(tc.ToTree() for tc in self._testcases.values())
789 )
791 return node
793 def __str__(self) -> str:
794 moduleName = self.__module__.split(".")[-1]
795 className = self.__class__.__name__
796 return (
797 f"<{moduleName}{className} {self._name}: {len(self._testcases)}>"
798 )
801@export
802class Testsuite(TestsuiteBase):
803 """
804 A testsuite is a mid-level element in the test entity hierarchy representing a logical group of tests.
806 Test suites contain test classes and are grouped by a test summary, which is the root of the hierarchy.
807 """
809 _hostname: str
810 _testclasses: Dict[str, "Testclass"]
812 def __init__(
813 self,
814 name: str,
815 hostname: Nullable[str] = None,
816 startTime: Nullable[datetime] = None,
817 duration: Nullable[timedelta] = None,
818 status: TestsuiteStatus = TestsuiteStatus.Unknown,
819 testclasses: Nullable[Iterable["Testclass"]] = None,
820 parent: Nullable["TestsuiteSummary"] = None
821 ) -> None:
822 """
823 Initializes the fields of a test suite.
825 :param name: Name of the test suite.
826 :param startTime: Time when the test suite was started.
827 :param duration: duration of the entity's execution.
828 :param status: Overall status of the test suite.
829 :param parent: Reference to the parent test summary.
830 :raises TypeError: If parameter 'testcases' is not iterable.
831 :raises TypeError: If element in parameter 'testcases' is not a Testcase.
832 :raises AlreadyInHierarchyException: If a test case in parameter 'testcases' is already part of a test entity hierarchy.
833 :raises DuplicateTestcaseException: If a test case in parameter 'testcases' is already listed (by name) in the list of test cases.
834 """
835 if parent is not None:
836 if not isinstance(parent, TestsuiteSummary): 836 ↛ 837line 836 didn't jump to line 837 because the condition on line 836 was never true
837 ex = TypeError(f"Parameter 'parent' is not of type 'TestsuiteSummary'.")
838 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
839 raise ex
841 parent._testsuites[name] = self
843 super().__init__(name, startTime, duration, status, parent)
845 self._hostname = hostname
847 self._testclasses = {}
848 if testclasses is not None:
849 for testclass in testclasses:
850 if testclass._parent is not None: 850 ↛ 851line 850 didn't jump to line 851 because the condition on line 850 was never true
851 raise ValueError(f"Class '{testclass._name}' is already part of a testsuite hierarchy.")
853 if testclass._name in self._testclasses: 853 ↛ 854line 853 didn't jump to line 854 because the condition on line 853 was never true
854 raise DuplicateTestcaseException(f"Testsuite already contains a class with same name '{testclass._name}'.")
856 testclass._parent = self
857 self._testclasses[testclass._name] = testclass
859 @readonly
860 def Hostname(self) -> Nullable[str]:
861 """
862 Read-only property to access the host the testsuite was executed on.
864 :returns: Hostname, or ``None`` if it wasn't recorded.
865 """
866 return self._hostname
868 @readonly
869 def Testclasses(self) -> Dict[str, "Testclass"]:
870 """
871 Read-only property to access the testsuite's testclasses.
873 :returns: Dictionary of testclass names and testclasses.
874 """
875 return self._testclasses
877 @readonly
878 def TestclassCount(self) -> int:
879 """
880 Read-only property to return the number of testclasses in this testsuite.
882 :returns: Number of testclasses.
883 """
884 return len(self._testclasses)
886 # @readonly
887 # def Testcases(self) -> Dict[str, "Testcase"]:
888 # return self._classes
890 @readonly
891 def TestcaseCount(self) -> int:
892 """
893 Read-only property to return the number of testcases across all testclasses.
895 :returns: Sum of the testclasses' testcase counts.
896 """
897 return sum(cls.TestcaseCount for cls in self._testclasses.values())
899 @readonly
900 def AssertionCount(self) -> int:
901 """
902 Read-only property to return the number of assertions across all testclasses.
904 :returns: Sum of the testclasses' assertion counts.
905 """
906 return sum(cls.AssertionCount for cls in self._testclasses.values())
908 def AddTestclass(self, testclass: "Testclass") -> None:
909 if testclass._parent is not None: 909 ↛ 910line 909 didn't jump to line 910 because the condition on line 909 was never true
910 raise ValueError(f"Class '{testclass._name}' is already part of a testsuite hierarchy.")
912 if testclass._name in self._testclasses: 912 ↛ 913line 912 didn't jump to line 913 because the condition on line 912 was never true
913 raise DuplicateTestcaseException(f"Testsuite already contains a class with same name '{testclass._name}'.")
915 testclass._parent = self
916 self._testclasses[testclass._name] = testclass
918 def AddTestclasses(self, testclasses: Iterable["Testclass"]) -> None:
919 for testcase in testclasses:
920 self.AddTestclass(testcase)
922 # def IterateTestsuites(self, scheme: IterationScheme = IterationScheme.TestsuiteDefault) -> Generator[TestsuiteType, None, None]:
923 # return self.Iterate(scheme)
925 def IterateTestcases(self, scheme: IterationScheme = IterationScheme.TestcaseDefault) -> Generator[Testcase, None, None]:
926 return self.Iterate(scheme)
928 def Copy(self) -> "Testsuite":
929 return self.__class__(
930 self._name,
931 self._hostname,
932 self._startTime,
933 self._duration,
934 self._status
935 )
937 def Aggregate(self, strict: bool = True) -> TestsuiteAggregateReturnType:
938 tests, skipped, errored, weak, failed, passed = super().Aggregate()
940 for testclass in self._testclasses.values():
941 for testcase in testclass._testcases.values():
942 _ = testcase.Aggregate()
944 status = testcase._status
945 if status is TestcaseStatus.Unknown: 945 ↛ 946line 945 didn't jump to line 946 because the condition on line 945 was never true
946 raise UnittestException(f"Found testcase '{testcase._name}' with state 'Unknown'.")
947 elif status is TestcaseStatus.Skipped:
948 skipped += 1
949 elif status is TestcaseStatus.Errored: 949 ↛ 950line 949 didn't jump to line 950 because the condition on line 949 was never true
950 errored += 1
951 elif status is TestcaseStatus.Passed:
952 passed += 1
953 elif status is TestcaseStatus.Failed: 953 ↛ 955line 953 didn't jump to line 955 because the condition on line 953 was always true
954 failed += 1
955 elif status is TestcaseStatus.Weak:
956 weak += 1
957 elif status & TestcaseStatus.Mask is not TestcaseStatus.Unknown:
958 raise UnittestException(f"Found testcase '{testcase._name}' with unsupported state '{status}'.")
959 else:
960 raise UnittestException(f"Internal error for testcase '{testcase._name}', field '_status' is '{status}'.")
962 self._tests = tests
963 self._skipped = skipped
964 self._errored = errored
965 self._weak = weak
966 self._failed = failed
967 self._passed = passed
969 # FIXME: weak?
970 if errored > 0: 970 ↛ 971line 970 didn't jump to line 971 because the condition on line 970 was never true
971 self._status = TestsuiteStatus.Errored
972 elif failed > 0:
973 self._status = TestsuiteStatus.Failed
974 elif tests == 0: 974 ↛ 976line 974 didn't jump to line 976 because the condition on line 974 was always true
975 self._status = TestsuiteStatus.Empty
976 elif tests - skipped == passed:
977 self._status = TestsuiteStatus.Passed
978 elif tests == skipped:
979 self._status = TestsuiteStatus.Skipped
980 else:
981 self._status = TestsuiteStatus.Unknown
983 return tests, skipped, errored, weak, failed, passed
985 def Iterate(self, scheme: IterationScheme = IterationScheme.Default) -> Generator[Union[TestsuiteType, Testcase], None, None]:
986 """
987 Iterate the test suite and its child elements according to the iteration scheme.
989 If no scheme is given, use the default scheme.
991 :param scheme: Scheme how to iterate the test suite and its child elements.
992 :returns: A generator for iterating the results filtered and in the order defined by the iteration scheme.
993 """
994 if IterationScheme.PreOrder in scheme:
995 if IterationScheme.IncludeSelf | IterationScheme.IncludeTestsuites in scheme:
996 yield self
998 if IterationScheme.IncludeTestcases in scheme:
999 for testcase in self._testclasses.values():
1000 yield testcase
1002 for testclass in self._testclasses.values():
1003 yield from testclass.Iterate(scheme | IterationScheme.IncludeSelf)
1005 if IterationScheme.PostOrder in scheme:
1006 if IterationScheme.IncludeTestcases in scheme:
1007 for testcase in self._testclasses.values():
1008 yield testcase
1010 if IterationScheme.IncludeSelf | IterationScheme.IncludeTestsuites in scheme:
1011 yield self
1013 @classmethod
1014 def FromTestsuite(cls, testsuite: ut_Testsuite) -> "Testsuite":
1015 """
1016 Convert a test suite of the unified test entity data model to the JUnit specific data model's test suite object.
1018 :param testsuite: Test suite from unified data model.
1019 :returns: Test suite from JUnit specific data model.
1020 """
1021 juTestsuite = cls(
1022 testsuite._name,
1023 hostname=testsuite._hostname,
1024 startTime=testsuite._startTime,
1025 duration=testsuite._totalDuration,
1026 status= testsuite._status,
1027 )
1029 juTestsuite._tests = testsuite._tests
1030 juTestsuite._skipped = testsuite._skipped
1031 juTestsuite._errored = testsuite._errored
1032 juTestsuite._failed = testsuite._failed
1033 juTestsuite._passed = testsuite._passed
1035 for tc in testsuite.IterateTestcases():
1036 ts = tc._parent
1037 if ts is None: 1037 ↛ 1038line 1037 didn't jump to line 1038 because the condition on line 1037 was never true
1038 raise UnittestException(f"Testcase '{tc._name}' is not part of a hierarchy.")
1040 classname = ts._name
1041 ts = ts._parent
1042 while ts is not None and ts._kind > TestsuiteKind.Logical:
1043 classname = f"{ts._name}.{classname}"
1044 ts = ts._parent
1046 if classname in juTestsuite._testclasses:
1047 juClass = juTestsuite._testclasses[classname]
1048 else:
1049 juClass = Testclass(classname, parent=juTestsuite)
1051 juClass.AddTestcase(Testcase.FromTestcase(tc))
1053 return juTestsuite
1055 def ToTestsuite(self) -> ut_Testsuite:
1056 testsuite = ut_Testsuite(
1057 self._name,
1058 TestsuiteKind.Logical,
1059 self._hostname,
1060 startTime=self._startTime,
1061 totalDuration=self._duration,
1062 status=self._status,
1063 )
1065 for testclass in self._testclasses.values():
1066 suite = testsuite
1067 classpath = testclass._name.split(".")
1068 for element in classpath:
1069 if element in suite._testsuites:
1070 suite = suite._testsuites[element]
1071 else:
1072 suite = ut_Testsuite(element, kind=TestsuiteKind.Package, parent=suite)
1074 suite._kind = TestsuiteKind.Class
1075 if suite._parent is not testsuite:
1076 suite._parent._kind = TestsuiteKind.Module
1078 suite.AddTestcases(tc.ToTestcase() for tc in testclass._testcases.values())
1080 return testsuite
1082 def ToTree(self) -> Node:
1083 node = Node(
1084 value=self._name,
1085 children=(cls.ToTree() for cls in self._testclasses.values())
1086 )
1087 node["startTime"] = self._startTime
1088 node["duration"] = self._duration
1090 return node
1092 def __str__(self) -> str:
1093 moduleName = self.__module__.split(".")[-1]
1094 className = self.__class__.__name__
1095 return (
1096 f"<{moduleName}{className} {self._name}: {self._status.name} - tests:{self._tests}>"
1097 )
1100@export
1101class TestsuiteSummary(TestsuiteBase):
1102 _testsuites: Dict[str, Testsuite]
1104 def __init__(
1105 self,
1106 name: str,
1107 startTime: Nullable[datetime] = None,
1108 duration: Nullable[timedelta] = None,
1109 status: TestsuiteStatus = TestsuiteStatus.Unknown,
1110 testsuites: Nullable[Iterable[Testsuite]] = None
1111 ) -> None:
1112 super().__init__(name, startTime, duration, status, None)
1114 self._testsuites = {}
1115 if testsuites is not None:
1116 for testsuite in testsuites:
1117 if testsuite._parent is not None: 1117 ↛ 1118line 1117 didn't jump to line 1118 because the condition on line 1117 was never true
1118 raise ValueError(f"Testsuite '{testsuite._name}' is already part of a testsuite hierarchy.")
1120 if testsuite._name in self._testsuites: 1120 ↛ 1121line 1120 didn't jump to line 1121 because the condition on line 1120 was never true
1121 raise DuplicateTestsuiteException(f"Testsuite already contains a testsuite with same name '{testsuite._name}'.")
1123 testsuite._parent = self
1124 self._testsuites[testsuite._name] = testsuite
1126 @readonly
1127 def Testsuites(self) -> Dict[str, Testsuite]:
1128 """
1129 Read-only property to access the summary's testsuites.
1131 :returns: Dictionary of testsuite names and testsuites.
1132 """
1133 return self._testsuites
1135 @readonly
1136 def TestcaseCount(self) -> int:
1137 """
1138 Read-only property to return the number of testcases across all testsuites.
1140 :returns: Sum of the testsuites' testcase counts.
1141 """
1142 return sum(ts.TestcaseCount for ts in self._testsuites.values())
1144 @readonly
1145 def TestsuiteCount(self) -> int:
1146 """
1147 Read-only property to return the number of testsuites in this summary.
1149 :returns: Number of testsuites.
1150 """
1151 return len(self._testsuites)
1153 @readonly
1154 def AssertionCount(self) -> int:
1155 """
1156 Read-only property to return the number of assertions across all testsuites.
1158 :returns: Sum of the testsuites' assertion counts.
1159 """
1160 return sum(ts.AssertionCount for ts in self._testsuites.values())
1162 def AddTestsuite(self, testsuite: Testsuite) -> None:
1163 if testsuite._parent is not None: 1163 ↛ 1164line 1163 didn't jump to line 1164 because the condition on line 1163 was never true
1164 raise ValueError(f"Testsuite '{testsuite._name}' is already part of a testsuite hierarchy.")
1166 if testsuite._name in self._testsuites: 1166 ↛ 1167line 1166 didn't jump to line 1167 because the condition on line 1166 was never true
1167 raise DuplicateTestsuiteException(f"Testsuite already contains a testsuite with same name '{testsuite._name}'.")
1169 testsuite._parent = self
1170 self._testsuites[testsuite._name] = testsuite
1172 def AddTestsuites(self, testsuites: Iterable[Testsuite]) -> None:
1173 for testsuite in testsuites:
1174 self.AddTestsuite(testsuite)
1176 def Aggregate(self) -> TestsuiteAggregateReturnType:
1177 tests, skipped, errored, weak, failed, passed = super().Aggregate()
1179 for testsuite in self._testsuites.values():
1180 t, s, e, w, f, p = testsuite.Aggregate()
1181 tests += t
1182 skipped += s
1183 errored += e
1184 weak += w
1185 failed += f
1186 passed += p
1188 self._tests = tests
1189 self._skipped = skipped
1190 self._errored = errored
1191 self._weak = weak
1192 self._failed = failed
1193 self._passed = passed
1195 # FIXME: weak
1196 if errored > 0: 1196 ↛ 1197line 1196 didn't jump to line 1197 because the condition on line 1196 was never true
1197 self._status = TestsuiteStatus.Errored
1198 elif failed > 0:
1199 self._status = TestsuiteStatus.Failed
1200 elif tests == 0: 1200 ↛ 1202line 1200 didn't jump to line 1202 because the condition on line 1200 was always true
1201 self._status = TestsuiteStatus.Empty
1202 elif tests - skipped == passed:
1203 self._status = TestsuiteStatus.Passed
1204 elif tests == skipped:
1205 self._status = TestsuiteStatus.Skipped
1206 else:
1207 self._status = TestsuiteStatus.Unknown
1209 return tests, skipped, errored, weak, failed, passed
1211 def Iterate(self, scheme: IterationScheme = IterationScheme.Default) -> Generator[Union[Testsuite, Testcase], None, None]:
1212 """
1213 Iterate the test suite summary and its child elements according to the iteration scheme.
1215 If no scheme is given, use the default scheme.
1217 :param scheme: Scheme how to iterate the test suite summary and its child elements.
1218 :returns: A generator for iterating the results filtered and in the order defined by the iteration scheme.
1219 """
1220 if IterationScheme.IncludeSelf | IterationScheme.IncludeTestsuites | IterationScheme.PreOrder in scheme:
1221 yield self
1223 for testsuite in self._testsuites.values():
1224 yield from testsuite.IterateTestsuites(scheme | IterationScheme.IncludeSelf)
1226 if IterationScheme.IncludeSelf | IterationScheme.IncludeTestsuites | IterationScheme.PostOrder in scheme:
1227 yield self
1229 @classmethod
1230 def FromTestsuiteSummary(cls, testsuiteSummary: ut_TestsuiteSummary) -> "TestsuiteSummary":
1231 """
1232 Convert a test suite summary of the unified test entity data model to the JUnit specific data model's test suite.
1234 :param testsuiteSummary: Test suite summary from unified data model.
1235 :returns: Test suite summary from JUnit specific data model.
1236 """
1237 return cls(
1238 testsuiteSummary._name,
1239 startTime=testsuiteSummary._startTime,
1240 duration=testsuiteSummary._totalDuration,
1241 status=testsuiteSummary._status,
1242 testsuites=(ut_Testsuite.FromTestsuite(testsuite) for testsuite in testsuiteSummary._testsuites.values())
1243 )
1245 def ToTestsuiteSummary(self) -> ut_TestsuiteSummary:
1246 """
1247 Convert this test suite summary a new test suite summary of the unified data model.
1249 All fields are copied to the new instance. Child elements like test suites are copied recursively.
1251 :returns: A test suite summary of the unified test entity data model.
1252 """
1253 return ut_TestsuiteSummary(
1254 self._name,
1255 startTime=self._startTime,
1256 totalDuration=self._duration,
1257 status=self._status,
1258 testsuites=(testsuite.ToTestsuite() for testsuite in self._testsuites.values())
1259 )
1261 def ToTree(self) -> Node:
1262 node = Node(
1263 value=self._name,
1264 children=(ts.ToTree() for ts in self._testsuites.values())
1265 )
1266 node["startTime"] = self._startTime
1267 node["duration"] = self._duration
1269 return node
1271 def __str__(self) -> str:
1272 moduleName = self.__module__.split(".")[-1]
1273 className = self.__class__.__name__
1274 return (
1275 f"<{moduleName}{className} {self._name}: {self._status.name} - tests:{self._tests}>"
1276 )
1279@export
1280class Document(TestsuiteSummary, ut_Document):
1281 _DIALECT: ClassVar[str] = "Any-JUnit"
1282 _TESTCASE: ClassVar[Type[Testcase]] = Testcase
1283 _TESTCLASS: ClassVar[Type[Testclass]] = Testclass
1284 _TESTSUITE: ClassVar[Type[Testsuite]] = Testsuite
1286 _readerMode: JUnitReaderMode
1287 _xmlDocument: Nullable[_ElementTree]
1289 def __init__(self, xmlReportFile: Path, analyzeAndConvert: bool = False, readerMode: JUnitReaderMode = JUnitReaderMode.Default) -> None:
1290 super().__init__("Unprocessed JUnit XML file")
1292 self._readerMode = readerMode
1293 self._xmlDocument = None
1295 ut_Document.__init__(self, xmlReportFile, analyzeAndConvert)
1297 @classmethod
1298 def FromTestsuiteSummary(cls, xmlReportFile: Path, testsuiteSummary: ut_TestsuiteSummary):
1299 doc = cls(xmlReportFile)
1300 doc._name = testsuiteSummary._name
1301 doc._startTime = testsuiteSummary._startTime
1302 doc._duration = testsuiteSummary._totalDuration
1303 doc._status = testsuiteSummary._status
1304 doc._tests = testsuiteSummary._tests
1305 doc._skipped = testsuiteSummary._skipped
1306 doc._errored = testsuiteSummary._errored
1307 doc._failed = testsuiteSummary._failed
1308 doc._passed = testsuiteSummary._passed
1310 doc.AddTestsuites(Testsuite.FromTestsuite(testsuite) for testsuite in testsuiteSummary._testsuites.values())
1312 return doc
1314 def Analyze(self) -> None:
1315 """
1316 Analyze the XML file, parse the content into an XML data structure and validate the data structure using an XML
1317 schema.
1319 .. hint::
1321 The time spend for analysis will be made available via property :data:`AnalysisDuration`.
1323 The used XML schema definition is generic to support "any" dialect.
1324 """
1325 xmlSchemaFile = "Any-JUnit.xsd"
1326 self._Analyze(xmlSchemaFile)
1328 def _Analyze(self, xmlSchemaFile: str) -> None:
1329 if not self._path.exists(): 1329 ↛ 1330line 1329 didn't jump to line 1330 because the condition on line 1329 was never true
1330 raise UnittestException(f"JUnit XML file '{self._path}' does not exist.") \
1331 from FileNotFoundError(f"File '{self._path}' not found.")
1333 startAnalysis = perf_counter_ns()
1334 try:
1335 xmlSchemaResourceFile = getResourceFile(Resources, xmlSchemaFile)
1336 except ToolingException as ex:
1337 raise UnittestException(f"Couldn't locate XML Schema '{xmlSchemaFile}' in package resources.") from ex
1339 try:
1340 schemaParser = XMLParser(ns_clean=True)
1341 schemaRoot = parse(xmlSchemaResourceFile, schemaParser)
1342 except XMLSyntaxError as ex:
1343 raise UnittestException(f"XML Syntax Error while parsing XML Schema '{xmlSchemaFile}'.") from ex
1345 try:
1346 junitSchema = XMLSchema(schemaRoot)
1347 except XMLSchemaParseError as ex:
1348 raise UnittestException(f"Error while parsing XML Schema '{xmlSchemaFile}'.")
1350 try:
1351 junitParser = XMLParser(schema=junitSchema, ns_clean=True)
1352 junitDocument = parse(self._path, parser=junitParser)
1354 self._xmlDocument = junitDocument
1355 except XMLSyntaxError as ex:
1356 for logEntry in junitParser.error_log:
1357 ex.add_note(str(logEntry))
1358 raise UnittestException(f"XML syntax or validation error for '{self._path}' using XSD schema '{xmlSchemaResourceFile}'.") from ex
1359 except Exception as ex:
1360 raise UnittestException(f"Couldn't open '{self._path}'.") from ex
1362 endAnalysis = perf_counter_ns()
1363 self._analysisDuration = (endAnalysis - startAnalysis) / 1e9
1365 def Write(self, path: Nullable[Path] = None, overwrite: bool = False, regenerate: bool = False) -> None:
1366 """
1367 Write the data model as XML into a file adhering to the Any JUnit dialect.
1369 :param path: Optional path to the XMl file, if internal path shouldn't be used.
1370 :param overwrite: If true, overwrite an existing file.
1371 :param regenerate: If true, regenerate the XML structure from data model.
1372 :raises UnittestException: If the file cannot be overwritten.
1373 :raises UnittestException: If the internal XML data structure wasn't generated.
1374 :raises UnittestException: If the file cannot be opened or written.
1375 """
1376 if path is None:
1377 path = self._path
1379 if not overwrite and path.exists(): 1379 ↛ 1380line 1379 didn't jump to line 1380 because the condition on line 1379 was never true
1380 raise UnittestException(f"JUnit XML file '{path}' can not be overwritten.") \
1381 from FileExistsError(f"File '{path}' already exists.")
1383 if regenerate:
1384 self.Generate(overwrite=True)
1386 if self._xmlDocument is None: 1386 ↛ 1387line 1386 didn't jump to line 1387 because the condition on line 1386 was never true
1387 ex = UnittestException(f"Internal XML document tree is empty and needs to be generated before write is possible.")
1388 ex.add_note(f"Call 'JUnitDocument.Generate()' or 'JUnitDocument.Write(..., regenerate=True)'.")
1389 raise ex
1391 try:
1392 with path.open("wb") as file:
1393 file.write(tostring(self._xmlDocument, encoding="utf-8", xml_declaration=True, pretty_print=True))
1394 except Exception as ex:
1395 raise UnittestException(f"JUnit XML file '{path}' can not be written.") from ex
1397 def Convert(self) -> None:
1398 """
1399 Convert the parsed and validated XML data structure into a JUnit test entity hierarchy.
1401 This method converts the root element.
1403 .. hint::
1405 The time spend for model conversion will be made available via property :data:`ModelConversionDuration`.
1407 :raises UnittestException: If XML was not read and parsed before.
1408 """
1409 if self._xmlDocument is None: 1409 ↛ 1410line 1409 didn't jump to line 1410 because the condition on line 1409 was never true
1410 ex = UnittestException(f"JUnit XML file '{self._path}' needs to be read and analyzed by an XML parser.")
1411 ex.add_note(f"Call 'JUnitDocument.Analyze()' or create the document using 'JUnitDocument(path, parse=True)'.")
1412 raise ex
1414 startConversion = perf_counter_ns()
1415 rootElement: _Element = self._xmlDocument.getroot()
1417 self._name = self._ConvertName(rootElement, optional=True)
1418 self._startTime = self._ConvertTimestamp(rootElement, optional=True)
1419 self._duration = self._ConvertTime(rootElement, optional=True)
1421 if False: # self._readerMode is JUnitReaderMode.
1422 self._tests = self._ConvertTests(testsuitesNode)
1423 self._skipped = self._ConvertSkipped(testsuitesNode)
1424 self._errored = self._ConvertErrors(testsuitesNode)
1425 self._failed = self._ConvertFailures(testsuitesNode)
1426 self._assertionCount = self._ConvertAssertions(testsuitesNode)
1428 for rootNode in rootElement.iterchildren(tag="testsuite"): # type: _Element
1429 self._ConvertTestsuite(self, rootNode)
1431 if True: # self._readerMode is JUnitReaderMode.
1432 self.Aggregate()
1434 endConversation = perf_counter_ns()
1435 self._modelConversion = (endConversation - startConversion) / 1e9
1437 def _ConvertName(self, element: _Element, default: str = "root", optional: bool = True) -> str:
1438 """
1439 Convert the ``name`` attribute from an XML element node to a string.
1441 :param element: The XML element node with a ``name`` attribute.
1442 :param default: The default value, if no ``name`` attribute was found.
1443 :param optional: If false, an exception is raised for the missing attribute.
1444 :returns: The ``name`` attribute's content if found, otherwise the given default value.
1445 :raises UnittestException: If optional is false and no ``name`` attribute exists on the given element node.
1446 """
1447 if "name" in element.attrib:
1448 return element.attrib["name"]
1449 elif not optional: 1449 ↛ 1450line 1449 didn't jump to line 1450 because the condition on line 1449 was never true
1450 raise UnittestException(f"Required parameter 'name' not found in tag '{element.tag}'.")
1451 else:
1452 return default
1454 def _ConvertTimestamp(self, element: _Element, optional: bool = True) -> Nullable[datetime]:
1455 """
1456 Convert the ``timestamp`` attribute from an XML element node to a datetime.
1458 :param element: The XML element node with a ``timestamp`` attribute.
1459 :param optional: If false, an exception is raised for the missing attribute.
1460 :returns: The ``timestamp`` attribute's content if found, otherwise ``None``.
1461 :raises UnittestException: If optional is false and no ``timestamp`` attribute exists on the given element node.
1462 """
1463 if "timestamp" in element.attrib:
1464 timestamp = element.attrib["timestamp"]
1465 return datetime.fromisoformat(timestamp)
1466 elif not optional:
1467 raise UnittestException(f"Required parameter 'timestamp' not found in tag '{element.tag}'.")
1468 else:
1469 return None
1471 def _ConvertTime(self, element: _Element, optional: bool = True) -> Nullable[timedelta]:
1472 """
1473 Convert the ``time`` attribute from an XML element node to a timedelta.
1475 :param element: The XML element node with a ``time`` attribute.
1476 :param optional: If false, an exception is raised for the missing attribute.
1477 :returns: The ``time`` attribute's content if found, otherwise ``None``.
1478 :raises UnittestException: If optional is false and no ``time`` attribute exists on the given element node.
1479 """
1480 if "time" in element.attrib:
1481 time = element.attrib["time"]
1482 return timedelta(seconds=float(time))
1483 elif not optional: 1483 ↛ 1484line 1483 didn't jump to line 1484 because the condition on line 1483 was never true
1484 raise UnittestException(f"Required parameter 'time' not found in tag '{element.tag}'.")
1485 else:
1486 return None
1488 def _ConvertHostname(self, element: _Element, default: str = "localhost", optional: bool = True) -> str:
1489 """
1490 Convert the ``hostname`` attribute from an XML element node to a string.
1492 :param element: The XML element node with a ``hostname`` attribute.
1493 :param default: The default value, if no ``hostname`` attribute was found.
1494 :param optional: If false, an exception is raised for the missing attribute.
1495 :returns: The ``hostname`` attribute's content if found, otherwise the given default value.
1496 :raises UnittestException: If optional is false and no ``hostname`` attribute exists on the given element node.
1497 """
1498 if "hostname" in element.attrib:
1499 return element.attrib["hostname"]
1500 elif not optional: 1500 ↛ 1501line 1500 didn't jump to line 1501 because the condition on line 1500 was never true
1501 raise UnittestException(f"Required parameter 'hostname' not found in tag '{element.tag}'.")
1502 else:
1503 return default
1505 def _ConvertClassname(self, element: _Element) -> str:
1506 """
1507 Convert the ``classname`` attribute from an XML element node to a string.
1509 :param element: The XML element node with a ``classname`` attribute.
1510 :returns: The ``classname`` attribute's content.
1511 :raises UnittestException: If no ``classname`` attribute exists on the given element node.
1512 """
1513 if "classname" in element.attrib: 1513 ↛ 1516line 1513 didn't jump to line 1516 because the condition on line 1513 was always true
1514 return element.attrib["classname"]
1515 else:
1516 raise UnittestException(f"Required parameter 'classname' not found in tag '{element.tag}'.")
1518 def _ConvertTests(self, element: _Element, default: Nullable[int] = None, optional: bool = True) -> Nullable[int]:
1519 """
1520 Convert the ``tests`` attribute from an XML element node to an integer.
1522 :param element: The XML element node with a ``tests`` attribute.
1523 :param default: The default value, if no ``tests`` attribute was found.
1524 :param optional: If false, an exception is raised for the missing attribute.
1525 :returns: The ``tests`` attribute's content if found, otherwise the given default value.
1526 :raises UnittestException: If optional is false and no ``tests`` attribute exists on the given element node.
1527 """
1528 if "tests" in element.attrib:
1529 return int(element.attrib["tests"])
1530 elif not optional:
1531 raise UnittestException(f"Required parameter 'tests' not found in tag '{element.tag}'.")
1532 else:
1533 return default
1535 def _ConvertSkipped(self, element: _Element, default: Nullable[int] = None, optional: bool = True) -> Nullable[int]:
1536 """
1537 Convert the ``skipped`` attribute from an XML element node to an integer.
1539 :param element: The XML element node with a ``skipped`` attribute.
1540 :param default: The default value, if no ``skipped`` attribute was found.
1541 :param optional: If false, an exception is raised for the missing attribute.
1542 :returns: The ``skipped`` attribute's content if found, otherwise the given default value.
1543 :raises UnittestException: If optional is false and no ``skipped`` attribute exists on the given element node.
1544 """
1545 if "skipped" in element.attrib:
1546 return int(element.attrib["skipped"])
1547 elif not optional:
1548 raise UnittestException(f"Required parameter 'skipped' not found in tag '{element.tag}'.")
1549 else:
1550 return default
1552 def _ConvertErrors(self, element: _Element, default: Nullable[int] = None, optional: bool = True) -> Nullable[int]:
1553 """
1554 Convert the ``errors`` attribute from an XML element node to an integer.
1556 :param element: The XML element node with a ``errors`` attribute.
1557 :param default: The default value, if no ``errors`` attribute was found.
1558 :param optional: If false, an exception is raised for the missing attribute.
1559 :returns: The ``errors`` attribute's content if found, otherwise the given default value.
1560 :raises UnittestException: If optional is false and no ``errors`` attribute exists on the given element node.
1561 """
1562 if "errors" in element.attrib:
1563 return int(element.attrib["errors"])
1564 elif not optional:
1565 raise UnittestException(f"Required parameter 'errors' not found in tag '{element.tag}'.")
1566 else:
1567 return default
1569 def _ConvertFailures(self, element: _Element, default: Nullable[int] = None, optional: bool = True) -> Nullable[int]:
1570 """
1571 Convert the ``failures`` attribute from an XML element node to an integer.
1573 :param element: The XML element node with a ``failures`` attribute.
1574 :param default: The default value, if no ``failures`` attribute was found.
1575 :param optional: If false, an exception is raised for the missing attribute.
1576 :returns: The ``failures`` attribute's content if found, otherwise the given default value.
1577 :raises UnittestException: If optional is false and no ``failures`` attribute exists on the given element node.
1578 """
1579 if "failures" in element.attrib:
1580 return int(element.attrib["failures"])
1581 elif not optional:
1582 raise UnittestException(f"Required parameter 'failures' not found in tag '{element.tag}'.")
1583 else:
1584 return default
1586 def _ConvertAssertions(self, element: _Element, default: Nullable[int] = None, optional: bool = True) -> Nullable[int]:
1587 """
1588 Convert the ``assertions`` attribute from an XML element node to an integer.
1590 :param element: The XML element node with a ``assertions`` attribute.
1591 :param default: The default value, if no ``assertions`` attribute was found.
1592 :param optional: If false, an exception is raised for the missing attribute.
1593 :returns: The ``assertions`` attribute's content if found, otherwise the given default value.
1594 :raises UnittestException: If optional is false and no ``assertions`` attribute exists on the given element node.
1595 """
1596 if "assertions" in element.attrib:
1597 return int(element.attrib["assertions"])
1598 elif not optional: 1598 ↛ 1599line 1598 didn't jump to line 1599 because the condition on line 1598 was never true
1599 raise UnittestException(f"Required parameter 'assertions' not found in tag '{element.tag}'.")
1600 else:
1601 return default
1603 def _ConvertTestsuite(self, parent: TestsuiteSummary, testsuitesNode: _Element) -> None:
1604 """
1605 Convert the XML data structure of a ``<testsuite>`` to a test suite.
1607 This method uses private helper methods provided by the base-class.
1609 :param parent: The test suite summary as a parent element in the test entity hierarchy.
1610 :param testsuitesNode: The current XML element node representing a test suite.
1611 """
1612 newTestsuite = self._TESTSUITE(
1613 self._ConvertName(testsuitesNode, optional=False),
1614 self._ConvertHostname(testsuitesNode, optional=True),
1615 self._ConvertTimestamp(testsuitesNode, optional=True),
1616 self._ConvertTime(testsuitesNode, optional=True),
1617 parent=parent
1618 )
1620 if False: # self._readerMode is JUnitReaderMode.
1621 self._tests = self._ConvertTests(testsuitesNode)
1622 self._skipped = self._ConvertSkipped(testsuitesNode)
1623 self._errored = self._ConvertErrors(testsuitesNode)
1624 self._failed = self._ConvertFailures(testsuitesNode)
1625 self._assertionCount = self._ConvertAssertions(testsuitesNode)
1627 self._ConvertTestsuiteChildren(testsuitesNode, newTestsuite)
1629 def _ConvertTestsuiteChildren(self, testsuitesNode: _Element, newTestsuite: Testsuite) -> None:
1630 for node in testsuitesNode.iterchildren(): # type: _Element
1631 # if node.tag == "testsuite":
1632 # self._ConvertTestsuite(newTestsuite, node)
1633 # el
1634 if node.tag == "testcase":
1635 self._ConvertTestcase(newTestsuite, node)
1637 def _ConvertTestcase(self, parent: Testsuite, testcaseNode: _Element) -> None:
1638 """
1639 Convert the XML data structure of a ``<testcase>`` to a test case.
1641 This method uses private helper methods provided by the base-class.
1643 :param parent: The test suite as a parent element in the test entity hierarchy.
1644 :param testcaseNode: The current XML element node representing a test case.
1645 """
1646 className = self._ConvertClassname(testcaseNode)
1647 testclass = self._FindOrCreateTestclass(parent, className)
1649 newTestcase = self._TESTCASE(
1650 self._ConvertName(testcaseNode, optional=False),
1651 self._ConvertTime(testcaseNode, optional=False),
1652 assertionCount=self._ConvertAssertions(testcaseNode),
1653 parent=testclass
1654 )
1656 self._ConvertTestcaseChildren(testcaseNode, newTestcase)
1658 def _FindOrCreateTestclass(self, parent: Testsuite, className: str) -> Testclass:
1659 if className in parent._testclasses:
1660 return parent._testclasses[className]
1661 else:
1662 return self._TESTCLASS(className, parent=parent)
1664 def _ConvertTestcaseChildren(self, testcaseNode: _Element, newTestcase: Testcase) -> None:
1665 for node in testcaseNode.iterchildren(): # type: _Element
1666 if isinstance(node, _Comment): 1666 ↛ 1667line 1666 didn't jump to line 1667 because the condition on line 1666 was never true
1667 pass
1668 elif isinstance(node, _Element): 1668 ↛ 1684line 1668 didn't jump to line 1684 because the condition on line 1668 was always true
1669 if node.tag == "skipped":
1670 newTestcase._status = TestcaseStatus.Skipped
1671 elif node.tag == "failure":
1672 newTestcase._status = TestcaseStatus.Failed
1673 elif node.tag == "error": 1673 ↛ 1674line 1673 didn't jump to line 1674 because the condition on line 1673 was never true
1674 newTestcase._status = TestcaseStatus.Errored
1675 elif node.tag == "system-out":
1676 pass
1677 elif node.tag == "system-err":
1678 pass
1679 elif node.tag == "properties": 1679 ↛ 1682line 1679 didn't jump to line 1682 because the condition on line 1679 was always true
1680 pass
1681 else:
1682 raise UnittestException(f"Unknown element '{node.tag}' in junit file.")
1683 else:
1684 pass
1686 if newTestcase._status is TestcaseStatus.Unknown:
1687 newTestcase._status = TestcaseStatus.Passed
1689 def Generate(self, overwrite: bool = False) -> None:
1690 """
1691 Generate the internal XML data structure from test suites and test cases.
1693 This method generates the XML root element (``<testsuites>``) and recursively calls other generated methods.
1695 :param overwrite: Overwrite the internal XML data structure.
1696 :raises UnittestException: If overwrite is false and the internal XML data structure is not empty.
1697 """
1698 if not overwrite and self._xmlDocument is not None: 1698 ↛ 1699line 1698 didn't jump to line 1699 because the condition on line 1698 was never true
1699 raise UnittestException(f"Internal XML document is populated with data.")
1701 rootElement = Element("testsuites")
1702 rootElement.attrib["name"] = self._name
1703 if self._startTime is not None:
1704 rootElement.attrib["timestamp"] = f"{self._startTime.isoformat()}"
1705 if self._duration is not None:
1706 rootElement.attrib["time"] = f"{self._duration.total_seconds():.6f}"
1707 rootElement.attrib["tests"] = str(self._tests)
1708 rootElement.attrib["failures"] = str(self._failed)
1709 rootElement.attrib["errors"] = str(self._errored)
1710 rootElement.attrib["skipped"] = str(self._skipped)
1711 # if self._assertionCount is not None:
1712 # rootElement.attrib["assertions"] = f"{self._assertionCount}"
1714 self._xmlDocument = ElementTree(rootElement)
1716 for testsuite in self._testsuites.values():
1717 self._GenerateTestsuite(testsuite, rootElement)
1719 def _GenerateTestsuite(self, testsuite: Testsuite, parentElement: _Element) -> None:
1720 """
1721 Generate the internal XML data structure for a test suite.
1723 This method generates the XML element (``<testsuite>``) and recursively calls other generated methods.
1725 :param testsuite: The test suite to convert to an XML data structures.
1726 :param parentElement: The parent XML data structure element, this data structure part will be added to.
1727 """
1728 testsuiteElement = SubElement(parentElement, "testsuite")
1729 testsuiteElement.attrib["name"] = testsuite._name
1730 if testsuite._startTime is not None:
1731 testsuiteElement.attrib["timestamp"] = f"{testsuite._startTime.isoformat()}"
1732 if testsuite._duration is not None:
1733 testsuiteElement.attrib["time"] = f"{testsuite._duration.total_seconds():.6f}"
1734 testsuiteElement.attrib["tests"] = str(testsuite._tests)
1735 testsuiteElement.attrib["failures"] = str(testsuite._failed)
1736 testsuiteElement.attrib["errors"] = str(testsuite._errored)
1737 testsuiteElement.attrib["skipped"] = str(testsuite._skipped)
1738 # if testsuite._assertionCount is not None:
1739 # testsuiteElement.attrib["assertions"] = f"{testsuite._assertionCount}"
1740 if testsuite._hostname is not None:
1741 testsuiteElement.attrib["hostname"] = testsuite._hostname
1743 for testclass in testsuite._testclasses.values():
1744 for tc in testclass._testcases.values():
1745 self._GenerateTestcase(tc, testsuiteElement)
1747 def _GenerateTestcase(self, testcase: Testcase, parentElement: _Element) -> None:
1748 """
1749 Generate the internal XML data structure for a test case.
1751 This method generates the XML element (``<testcase>``) and recursively calls other generated methods.
1753 :param testcase: The test case to convert to an XML data structures.
1754 :param parentElement: The parent XML data structure element, this data structure part will be added to.
1755 """
1756 testcaseElement = SubElement(parentElement, "testcase")
1757 if testcase.Classname is not None: 1757 ↛ 1759line 1757 didn't jump to line 1759 because the condition on line 1757 was always true
1758 testcaseElement.attrib["classname"] = testcase.Classname
1759 testcaseElement.attrib["name"] = testcase._name
1760 if testcase._duration is not None: 1760 ↛ 1762line 1760 didn't jump to line 1762 because the condition on line 1760 was always true
1761 testcaseElement.attrib["time"] = f"{testcase._duration.total_seconds():.6f}"
1762 if testcase._assertionCount is not None:
1763 testcaseElement.attrib["assertions"] = f"{testcase._assertionCount}"
1765 if testcase._status is TestcaseStatus.Passed:
1766 pass
1767 elif testcase._status is TestcaseStatus.Failed:
1768 failureElement = SubElement(testcaseElement, "failure")
1769 elif testcase._status is TestcaseStatus.Skipped: 1769 ↛ 1772line 1769 didn't jump to line 1772 because the condition on line 1769 was always true
1770 skippedElement = SubElement(testcaseElement, "skipped")
1771 else:
1772 errorElement = SubElement(testcaseElement, "error")
1774 def __str__(self) -> str:
1775 moduleName = self.__module__.split(".")[-1]
1776 className = self.__class__.__name__
1777 return (
1778 f"<{moduleName}{className} {self._name} ({self._path}): {self._status.name} - suites/tests:{self.TestsuiteCount}/{self.TestcaseCount}>"
1779 )