Coverage for pyEDAA/Reports/Unittesting/JUnit/__init__.py: 77%
751 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-21 23:49 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-21 23:49 +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 __future__ import annotations
95from datetime import datetime, timedelta
96from enum import Flag
97from io import BytesIO
98from pathlib import Path
99from typing import Optional as Nullable, Iterable, Dict, Any, Generator, Tuple, Union, TypeVar, Type, ClassVar
101from lxml.etree import XMLParser, parse, XMLSchema, ElementTree, Element, SubElement, tostring
102from lxml.etree import XMLSyntaxError, _ElementTree, _Element, _Comment, XMLSchemaParseError
103from pyTooling.Common import getFullyQualifiedName, getTypeName, getResourceFile
104from pyTooling.Decorators import export, readonly
105from pyTooling.Stopwatch import Stopwatch
106from pyTooling.Exceptions import ToolingException
107from pyTooling.MetaClasses import ExtendedType, mustoverride, abstractmethod
108from pyTooling.Tree import Node
110from pyEDAA.Reports import Resources
111from pyEDAA.Reports.Unittesting import UnittestError, AlreadyInTestsuiteError, DuplicateTestsuiteError
112from pyEDAA.Reports.Unittesting import DuplicateTestcaseError
113from pyEDAA.Reports.Unittesting import TestcaseStatus, TestsuiteStatus, TestsuiteKind, IterationScheme
114from pyEDAA.Reports.Unittesting import TestcaseOutputMixin
115from pyEDAA.Reports.Unittesting import Document as ut_Document, TestsuiteSummary as ut_TestsuiteSummary
116from pyEDAA.Reports.Unittesting import Testsuite as ut_Testsuite, Testcase as ut_Testcase
119@export
120class JUnitErrorMixin:
121 """An error-mixin for JUnit format specific errors."""
124@export
125class UnittestError(UnittestError, JUnitErrorMixin):
126 pass
129@export
130class AlreadyInTestsuiteError(AlreadyInTestsuiteError, JUnitErrorMixin):
131 """
132 A unit test exception raised if the element is already part of a hierarchy.
134 This exception is caused by an inconsistent data model. Elements added to the hierarchy should be part of the same
135 hierarchy should occur only once in the hierarchy.
137 .. hint::
139 This is usually caused by a non-None parent reference.
140 """
143@export
144class DuplicateTestsuiteError(DuplicateTestsuiteError, JUnitErrorMixin):
145 """
146 A unit test exception raised on duplicate test suites (by name).
148 This exception is raised, if a child test suite with same name already exist in the test suite.
150 .. hint::
152 Test suite names need to be unique per parent element (test suite or test summary).
153 """
156@export
157class DuplicateTestcaseError(DuplicateTestcaseError, JUnitErrorMixin):
158 """
159 A unit test exception raised on duplicate test cases (by name).
161 This exception is raised, if a child test case with same name already exist in the test suite.
163 .. hint::
165 Test case names need to be unique per parent element (test suite).
166 """
169@export
170class JUnitReaderMode(Flag):
171 Default = 0 #: Default behavior
172 DecoupleTestsuiteHierarchyAndTestcaseClassName = 1 #: Undocumented
175TestsuiteType = TypeVar("TestsuiteType", bound="Testsuite")
176TestcaseAggregateReturnType = Tuple[int, int, int]
177TestsuiteAggregateReturnType = Tuple[int, int, int, int, int, int]
180@export
181class Base(metaclass=ExtendedType, slots=True):
182 """
183 Base-class for all test entities (test cases, test classes, test suites, ...).
185 It provides a reference to the parent test entity, so bidirectional referencing can be used in the test entity
186 hierarchy.
188 Every test entity has a name to identity it. It's also used in the parent's child element dictionaries to identify the
189 child. |br|
190 E.g. it's used as a test case name in the dictionary of test cases in a test class.
191 """
193 _parent: Nullable[Testsuite]
194 _name: str
196 def __init__(self, name: str, *, parent: Nullable[Testsuite] = None) -> None:
197 """
198 Initializes the fields of the base-class.
200 :param name: Name of the test entity.
201 :param parent: Optional, reference to the parent test entity.
202 :raises ValueError: When parameter 'name' is None.
203 :raises TypeError: When parameter 'name' is not a string.
204 :raises ValueError: When parameter 'name' is empty.
205 """
206 if name is None: 206 ↛ 207line 206 didn't jump to line 207 because the condition on line 206 was never true
207 raise ValueError("Parameter 'name' is None.")
208 elif not isinstance(name, str): 208 ↛ 209line 208 didn't jump to line 209 because the condition on line 208 was never true
209 ex = TypeError("Parameter 'name' is not of type 'str'.")
210 ex.add_note(f"Got type '{getFullyQualifiedName(name)}'.")
211 raise ex
212 elif name.strip() == "":
213 raise ValueError("Parameter 'name' is empty.")
215 self._parent = parent
216 self._name = name
218 @readonly
219 def Parent(self) -> Nullable[Testsuite]:
220 """
221 Read-only property to access the reference to the parent test entity.
223 :returns: Reference to the parent entity.
224 """
225 return self._parent
227 # QUESTION: allow Parent as setter?
229 @readonly
230 def Name(self) -> str:
231 """
232 Read-only property to access the test entity's name.
234 :returns: Name of the test entity.
235 """
236 return self._name
239@export
240class BaseWithProperties(Base):
241 """
242 Base-class for all test entities supporting properties (test cases, test suites, ...).
244 Every test entity has fields for the test duration and number of executed assertions.
246 Every test entity offers an internal dictionary for properties.
247 """
249 _duration: Nullable[timedelta]
250 _assertionCount: Nullable[int]
251 _properties: Dict[str, Any]
253 def __init__(
254 self,
255 name: str,
256 duration: Nullable[timedelta] = None,
257 assertionCount: Nullable[int] = None,
258 *,
259 parent: Nullable[Testsuite] = None
260 ) -> None:
261 """
262 Initializes the fields of the base-class.
264 :param name: Name of the test entity.
265 :param duration: Optional, duration of the entity's execution.
266 :param assertionCount: Optional, number of assertions within the test.
267 :param parent: Optional, reference to the parent test entity.
268 :raises TypeError: If parameter 'duration' is not a timedelta.
269 :raises TypeError: If parameter 'assertionCount' is not an integer.
270 """
271 super().__init__(name, parent=parent)
273 if duration is not None and not isinstance(duration, timedelta): 273 ↛ 274line 273 didn't jump to line 274 because the condition on line 273 was never true
274 ex = TypeError("Parameter 'duration' is not of type 'timedelta'.")
275 ex.add_note(f"Got type '{getFullyQualifiedName(duration)}'.")
276 raise ex
278 if assertionCount is not None and not isinstance(assertionCount, int): 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true
279 ex = TypeError("Parameter 'assertionCount' is not of type 'int'.")
280 ex.add_note(f"Got type '{getFullyQualifiedName(assertionCount)}'.")
281 raise ex
283 self._duration = duration
284 self._assertionCount = assertionCount
286 self._properties = {}
288 @readonly
289 def Duration(self) -> timedelta:
290 """
291 Read-only property to access the duration of a test entity run.
293 .. note::
295 The JUnit format doesn't distinguish setup, run and teardown durations.
297 :returns: Duration of the entity's execution.
298 """
299 return self._duration
301 @readonly
302 @abstractmethod
303 def AssertionCount(self) -> int:
304 """
305 Read-only property to access the number of assertions (checks) in a test case.
307 .. note::
309 The JUnit format doesn't distinguish passed and failed assertions.
311 :returns: Number of assertions.
312 """
314 def __len__(self) -> int:
315 """
316 Returns the number of annotated properties.
318 Syntax: :pycode:`length = len(obj)`
320 :returns: Number of annotated properties.
321 """
322 return len(self._properties)
324 def __getitem__(self, name: str) -> Any:
325 """
326 Access a property by name.
328 Syntax: :pycode:`value = obj[name]`
330 :param name: Name if the property.
331 :returns: Value of the accessed property.
332 """
333 return self._properties[name]
335 def __setitem__(self, name: str, value: Any) -> None:
336 """
337 Set the value of a property by name.
339 If the property doesn't exist yet, it's created.
341 Syntax: :pycode:`obj[name] = value`
343 :param name: Name of the property.
344 :param value: Value of the property.
345 """
346 self._properties[name] = value
348 def __delitem__(self, name: str) -> None:
349 """
350 Delete a property by name.
352 Syntax: :pycode:`del obj[name]`
354 :param name: Name if the property.
355 """
356 del self._properties[name]
358 def __contains__(self, name: str) -> bool:
359 """
360 Returns True, if a property was annotated by this name.
362 Syntax: :pycode:`name in obj`
364 :param name: Name of the property.
365 :returns: True, if the property was annotated.
366 """
367 return name in self._properties
369 def __iter__(self) -> Generator[Tuple[str, Any], None, None]:
370 """
371 Iterate all annotated properties.
373 Syntax: :pycode:`for name, value in obj:`
375 :returns: A generator of property tuples (name, value).
376 """
377 yield from self._properties.items()
380@export
381class Testcase(BaseWithProperties, TestcaseOutputMixin):
382 """
383 A testcase is the leaf-entity in the test entity hierarchy representing an individual test run.
385 Test cases are grouped by test classes in the test entity hierarchy. These are again grouped by test suites. The root
386 of the hierarchy is a test summary.
388 Every test case has an overall status like unknown, skipped, failed or passed. The message and details of a
389 ``<failure>``, ``<error>`` or ``<skipped>`` element, as well as the captured output of ``<system-out>`` and
390 ``<system-err>`` are provided by :class:`~pyEDAA.Reports.Unittesting.TestcaseOutputMixin`.
391 """
393 _status: TestcaseStatus
395 def __init__(
396 self,
397 name: str,
398 duration: Nullable[timedelta] = None,
399 status: TestcaseStatus = TestcaseStatus.Unknown,
400 assertionCount: Nullable[int] = None,
401 message: Nullable[str] = None,
402 details: Nullable[str] = None,
403 standardOutput: Nullable[str] = None,
404 standardError: Nullable[str] = None,
405 *,
406 parent: Nullable[Testclass] = None
407 ) -> None:
408 """
409 Initializes the fields of a test case.
411 :param name: Name of the test entity.
412 :param duration: Optional, duration of the entity's execution.
413 :param status: Optional, status of the test case.
414 :param assertionCount: Optional, number of assertions within the test.
415 :param message: Optional, message explaining the test case's status.
416 :param details: Optional, details explaining the test case's status (e.g. a traceback).
417 :param standardOutput: Optional, captured standard output of the test case.
418 :param standardError: Optional, captured standard error of the test case.
419 :param parent: Optional, reference to the parent test class.
420 :raises TypeError: If parameter 'parent' is not a Testclass.
421 :raises ValueError: If parameter 'assertionCount' is not consistent.
422 """
423 if parent is not None:
424 if not isinstance(parent, Testclass): 424 ↛ 425line 424 didn't jump to line 425 because the condition on line 424 was never true
425 ex = TypeError("Parameter 'parent' is not of type 'Testclass'.")
426 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
427 raise ex
429 parent._testcases[name] = self
431 super().__init__(name, duration, assertionCount, parent=parent)
432 TestcaseOutputMixin.__init__(self, message, details, standardOutput, standardError)
434 if not isinstance(status, TestcaseStatus): 434 ↛ 435line 434 didn't jump to line 435 because the condition on line 434 was never true
435 ex = TypeError("Parameter 'status' is not of type 'TestcaseStatus'.")
436 ex.add_note(f"Got type '{getFullyQualifiedName(status)}'.")
437 raise ex
439 self._status = status
441 @readonly
442 def Classname(self) -> str:
443 """
444 Read-only property to access the class name of the test case.
446 :returns: The test case's class name.
448 .. note::
450 In the JUnit format, a test case is uniquely identified by a tuple of class name and test case name. This
451 structure has been decomposed by this data model into 2 leaf-levels in the test entity hierarchy. Thus, the class
452 name is represented by its own level and instances of test classes.
453 """
454 if self._parent is None:
455 raise UnittestError("Standalone Testcase instance is not linked to a Testclass.")
456 return self._parent._name
458 @readonly
459 def Status(self) -> TestcaseStatus:
460 """
461 Read-only property to access the status of the test case.
463 :returns: The test case's status.
464 """
465 return self._status
467 @readonly
468 def AssertionCount(self) -> int:
469 """
470 Read-only property to return the number of assertions (checks) in a test case.
472 .. note::
474 The JUnit format doesn't distinguish passed and failed assertions.
476 :returns: Number of assertions.
477 """
478 if self._assertionCount is None: 478 ↛ 480line 478 didn't jump to line 480 because the condition on line 478 was always true
479 return 0
480 return self._assertionCount
482 def Copy(self) -> Testcase:
483 return self.__class__(
484 self._name,
485 self._duration,
486 self._status,
487 self._assertionCount,
488 self._message,
489 self._details,
490 self._standardOutput,
491 self._standardError
492 )
494 def Aggregate(self) -> None:
495 if self._status is TestcaseStatus.Unknown:
496 if self._assertionCount is None:
497 self._status = TestcaseStatus.Passed
498 elif self._assertionCount == 0: 498 ↛ 499line 498 didn't jump to line 499 because the condition on line 498 was never true
499 self._status = TestcaseStatus.Weak
500 else:
501 self._status = TestcaseStatus.Failed
503 # TODO: check for setup errors
504 # TODO: check for teardown errors
506 @classmethod
507 def FromTestcase(cls, testcase: ut_Testcase) -> Testcase:
508 """
509 Convert a test case of the unified test entity data model to the JUnit specific data model's test case object.
511 :param testcase: Test case from unified data model.
512 :returns: Test case from JUnit specific data model.
513 """
514 return cls(
515 testcase._name,
516 duration=testcase._testDuration,
517 status= testcase._status,
518 assertionCount=testcase._assertionCount,
519 message=testcase._message,
520 details=testcase._details,
521 standardOutput=testcase._standardOutput,
522 standardError=testcase._standardError
523 )
525 def ToTestcase(self) -> ut_Testcase:
526 return ut_Testcase(
527 self._name,
528 testDuration=self._duration,
529 status=self._status,
530 assertionCount=self._assertionCount,
531 # TODO: as only assertions are recorded by JUnit files, all are marked as passed
532 passedAssertionCount=self._assertionCount,
533 message=self._message,
534 details=self._details,
535 standardOutput=self._standardOutput,
536 standardError=self._standardError
537 )
539 def ToTree(self) -> Node:
540 node = Node(value=self._name)
541 node["status"] = self._status
542 node["assertionCount"] = self._assertionCount
543 node["duration"] = self._duration
545 return node
547 def __str__(self) -> str:
548 moduleName = self.__module__.split(".")[-1]
549 className = getTypeName(self)
550 return (
551 f"<{moduleName}{className} {self._name}: {self._status.name} - asserts:{self._assertionCount}>"
552 )
555@export
556class TestsuiteBase(BaseWithProperties):
557 """
558 Base-class for all test suites and for test summaries.
560 A test suite is a mid-level grouping element in the test entity hierarchy, whereas the test summary is the root
561 element in that hierarchy. While a test suite groups test classes, a test summary can only group test suites. Thus, a
562 test summary contains no test classes and test cases.
563 """
565 _startTime: Nullable[datetime]
566 _status: TestsuiteStatus
568 _tests: int
569 _skipped: int
570 _errored: int
571 _weak: int
572 _failed: int
573 _passed: int
575 def __init__(
576 self,
577 name: str,
578 startTime: Nullable[datetime] = None,
579 duration: Nullable[timedelta] = None,
580 status: TestsuiteStatus = TestsuiteStatus.Unknown,
581 *,
582 parent: Nullable[Testsuite] = None
583 ) -> None:
584 """
585 Initializes the based-class fields of a test suite or test summary.
587 :param name: Name of the test entity.
588 :param startTime: Optional, time when the test entity was started.
589 :param duration: Optional, duration of the entity's execution.
590 :param status: Optional, overall status of the test entity.
591 :param parent: Optional, reference to the parent test entity.
592 :raises TypeError: If parameter 'parent' is not a TestsuiteBase.
593 """
594 if parent is not None:
595 if not isinstance(parent, TestsuiteBase): 595 ↛ 596line 595 didn't jump to line 596 because the condition on line 595 was never true
596 ex = TypeError("Parameter 'parent' is not of type 'TestsuiteBase'.")
597 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
598 raise ex
600 parent._testsuites[name] = self
602 super().__init__(name, duration, None, parent=parent)
604 self._startTime = startTime
605 self._status = status
606 self._tests = 0
607 self._skipped = 0
608 self._errored = 0
609 self._failed = 0
610 self._passed = 0
612 @readonly
613 def StartTime(self) -> Nullable[datetime]:
614 """
615 Read-only property to access the time the test entity's execution started.
617 :returns: Start time of the execution, or ``None`` if it wasn't recorded.
618 """
619 return self._startTime
621 @readonly
622 def Status(self) -> TestsuiteStatus:
623 """
624 Read-only property to access the test entity's aggregated status.
626 :returns: Status of the test entity.
627 """
628 return self._status
630 @readonly
631 @mustoverride
632 def TestcaseCount(self) -> int:
633 """
634 Read-only property to access the number of testcases in this entity.
636 :returns: Number of testcases.
637 """
638 pass
640 @readonly
641 def Tests(self) -> int:
642 """
643 Read-only property to access the number of testcases in this entity.
645 :returns: Number of testcases.
646 """
647 return self.TestcaseCount
649 @readonly
650 def Skipped(self) -> int:
651 """
652 Read-only property to access the number of skipped testcases.
654 :returns: Number of skipped testcases.
655 """
656 return self._skipped
658 @readonly
659 def Errored(self) -> int:
660 """
661 Read-only property to access the number of errored testcases.
663 :returns: Number of errored testcases.
664 """
665 return self._errored
667 @readonly
668 def Failed(self) -> int:
669 """
670 Read-only property to access the number of failed testcases.
672 :returns: Number of failed testcases.
673 """
674 return self._failed
676 @readonly
677 def Passed(self) -> int:
678 """
679 Read-only property to access the number of passed testcases.
681 :returns: Number of passed testcases.
682 """
683 return self._passed
685 def Aggregate(self) -> TestsuiteAggregateReturnType:
686 tests = 0
687 skipped = 0
688 errored = 0
689 weak = 0
690 failed = 0
691 passed = 0
693 # for testsuite in self._testsuites.values():
694 # t, s, e, w, f, p = testsuite.Aggregate()
695 # tests += t
696 # skipped += s
697 # errored += e
698 # weak += w
699 # failed += f
700 # passed += p
702 return tests, skipped, errored, weak, failed, passed
704 @mustoverride
705 def Iterate(self, scheme: IterationScheme = IterationScheme.Default) -> Generator[Union[TestsuiteType, Testcase], None, None]:
706 pass
709@export
710class Testclass(Base):
711 """
712 A test class is a low-level element in the test entity hierarchy representing a group of tests.
714 Test classes contain test cases and are grouped by a test suites.
715 """
717 _testcases: Dict[str, Testcase]
719 def __init__(
720 self,
721 classname: str,
722 testcases: Nullable[Iterable[Testcase]] = None,
723 *,
724 parent: Nullable[Testsuite] = None
725 ) -> None:
726 """
727 Initializes the fields of the test class.
729 :param classname: Classname of the test entity.
730 :param parent: Optional, reference to the parent test suite.
731 :raises ValueError: If parameter 'classname' is None.
732 :raises TypeError: If parameter 'classname' is not a string.
733 :raises ValueError: If parameter 'classname' is empty.
734 """
735 if parent is not None:
736 if not isinstance(parent, Testsuite): 736 ↛ 737line 736 didn't jump to line 737 because the condition on line 736 was never true
737 ex = TypeError("Parameter 'parent' is not of type 'Testsuite'.")
738 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
739 raise ex
741 parent._testclasses[classname] = self
743 super().__init__(classname, parent=parent)
745 self._testcases = {}
746 if testcases is not None:
747 for testcase in testcases:
748 if testcase._parent is not None: 748 ↛ 749line 748 didn't jump to line 749 because the condition on line 748 was never true
749 raise AlreadyInTestsuiteError(f"Testcase '{testcase._name}' is already part of a testsuite hierarchy.")
751 if testcase._name in self._testcases: 751 ↛ 752line 751 didn't jump to line 752 because the condition on line 751 was never true
752 raise DuplicateTestcaseError(f"Class already contains a testcase with same name '{testcase._name}'.")
754 testcase._parent = self
755 self._testcases[testcase._name] = testcase
757 @readonly
758 def Classname(self) -> str:
759 """
760 Read-only property to access the name of the test class.
762 :returns: The test class' name.
763 """
764 return self._name
766 @readonly
767 def Testcases(self) -> Dict[str, Testcase]:
768 """
769 Read-only property to access a reference to the internal dictionary of test cases.
771 :returns: Reference to the dictionary of test cases.
772 """
773 return self._testcases
775 @readonly
776 def TestcaseCount(self) -> int:
777 """
778 Read-only property to return the number of all test cases in the test entity hierarchy.
780 :returns: Number of test cases.
781 """
782 return len(self._testcases)
784 @readonly
785 def AssertionCount(self) -> int:
786 """
787 Read-only property to return the number of assertions across all testcases of this testclass.
789 :returns: Sum of the testcases' assertion counts.
790 """
791 return sum(tc.AssertionCount for tc in self._testcases.values())
793 def AddTestcase(self, testcase: Testcase) -> None:
794 if testcase._parent is not None: 794 ↛ 795line 794 didn't jump to line 795 because the condition on line 794 was never true
795 raise ValueError(f"Testcase '{testcase._name}' is already part of a testsuite hierarchy.")
797 if testcase._name in self._testcases: 797 ↛ 798line 797 didn't jump to line 798 because the condition on line 797 was never true
798 raise DuplicateTestcaseError(f"Class already contains a testcase with same name '{testcase._name}'.")
800 testcase._parent = self
801 self._testcases[testcase._name] = testcase
803 def AddTestcases(self, testcases: Iterable[Testcase]) -> None:
804 for testcase in testcases:
805 self.AddTestcase(testcase)
807 def ToTestsuite(self) -> ut_Testsuite:
808 return ut_Testsuite(
809 self._name,
810 kind=TestsuiteKind.Class,
811 # startTime=self._startTime,
812 # totalDuration=self._duration,
813 # status=self._status,
814 testcases=(tc.ToTestcase() for tc in self._testcases.values())
815 )
817 def ToTree(self) -> Node:
818 node = Node(
819 value=self._name,
820 children=(tc.ToTree() for tc in self._testcases.values())
821 )
823 return node
825 def __str__(self) -> str:
826 moduleName = self.__module__.split(".")[-1]
827 className = getTypeName(self)
828 return (
829 f"<{moduleName}{className} {self._name}: {len(self._testcases)}>"
830 )
833@export
834class Testsuite(TestsuiteBase):
835 """
836 A testsuite is a mid-level element in the test entity hierarchy representing a logical group of tests.
838 Test suites contain test classes and are grouped by a test summary, which is the root of the hierarchy.
839 """
841 _hostname: str
842 _testclasses: Dict[str, Testclass]
844 def __init__(
845 self,
846 name: str,
847 hostname: Nullable[str] = None,
848 startTime: Nullable[datetime] = None,
849 duration: Nullable[timedelta] = None,
850 status: TestsuiteStatus = TestsuiteStatus.Unknown,
851 testclasses: Nullable[Iterable[Testclass]] = None,
852 *,
853 parent: Nullable[TestsuiteSummary] = None
854 ) -> None:
855 """
856 Initializes the fields of a test suite.
858 :param name: Name of the test suite.
859 :param startTime: Optional, time when the test suite was started.
860 :param duration: Optional, duration of the entity's execution.
861 :param status: Optional, overall status of the test suite.
862 :param parent: Optional, reference to the parent test summary.
863 :raises TypeError: If parameter 'testcases' is not iterable.
864 :raises TypeError: If element in parameter 'testcases' is not a Testcase.
865 :raises AlreadyInTestsuiteError: If a test case in parameter 'testcases' is already part of a test entity hierarchy.
866 :raises DuplicateTestcaseError: If a test case in parameter 'testcases' is already listed (by name) in the list of
867 test cases.
868 """
869 if parent is not None:
870 if not isinstance(parent, TestsuiteSummary): 870 ↛ 871line 870 didn't jump to line 871 because the condition on line 870 was never true
871 ex = TypeError("Parameter 'parent' is not of type 'TestsuiteSummary'.")
872 ex.add_note(f"Got type '{getFullyQualifiedName(parent)}'.")
873 raise ex
875 parent._testsuites[name] = self
877 super().__init__(name, startTime, duration, status, parent=parent)
879 self._hostname = hostname
881 self._testclasses = {}
882 if testclasses is not None:
883 for testclass in testclasses:
884 if testclass._parent is not None: 884 ↛ 885line 884 didn't jump to line 885 because the condition on line 884 was never true
885 raise ValueError(f"Class '{testclass._name}' is already part of a testsuite hierarchy.")
887 if testclass._name in self._testclasses: 887 ↛ 888line 887 didn't jump to line 888 because the condition on line 887 was never true
888 raise DuplicateTestcaseError(f"Testsuite already contains a class with same name '{testclass._name}'.")
890 testclass._parent = self
891 self._testclasses[testclass._name] = testclass
893 @readonly
894 def Hostname(self) -> Nullable[str]:
895 """
896 Read-only property to access the host the testsuite was executed on.
898 :returns: Hostname, or ``None`` if it wasn't recorded.
899 """
900 return self._hostname
902 @readonly
903 def Testclasses(self) -> Dict[str, Testclass]:
904 """
905 Read-only property to access the testsuite's testclasses.
907 :returns: Dictionary of testclass names and testclasses.
908 """
909 return self._testclasses
911 @readonly
912 def TestclassCount(self) -> int:
913 """
914 Read-only property to return the number of testclasses in this testsuite.
916 :returns: Number of testclasses.
917 """
918 return len(self._testclasses)
920 # @readonly
921 # def Testcases(self) -> Dict[str, "Testcase"]:
922 # return self._classes
924 @readonly
925 def TestcaseCount(self) -> int:
926 """
927 Read-only property to return the number of testcases across all testclasses.
929 :returns: Sum of the testclasses' testcase counts.
930 """
931 return sum(cls.TestcaseCount for cls in self._testclasses.values())
933 @readonly
934 def AssertionCount(self) -> int:
935 """
936 Read-only property to return the number of assertions across all testclasses.
938 :returns: Sum of the testclasses' assertion counts.
939 """
940 return sum(cls.AssertionCount for cls in self._testclasses.values())
942 def AddTestclass(self, testclass: Testclass) -> None:
943 if testclass._parent is not None: 943 ↛ 944line 943 didn't jump to line 944 because the condition on line 943 was never true
944 raise ValueError(f"Class '{testclass._name}' is already part of a testsuite hierarchy.")
946 if testclass._name in self._testclasses: 946 ↛ 947line 946 didn't jump to line 947 because the condition on line 946 was never true
947 raise DuplicateTestcaseError(f"Testsuite already contains a class with same name '{testclass._name}'.")
949 testclass._parent = self
950 self._testclasses[testclass._name] = testclass
952 def AddTestclasses(self, testclasses: Iterable[Testclass]) -> None:
953 for testcase in testclasses:
954 self.AddTestclass(testcase)
956 # def IterateTestsuites(self, scheme: IterationScheme = IterationScheme.TestsuiteDefault) -> Generator[TestsuiteType, None, None]:
957 # return self.Iterate(scheme)
959 def IterateTestcases(self, scheme: IterationScheme = IterationScheme.TestcaseDefault) -> Generator[Testcase, None, None]:
960 return self.Iterate(scheme)
962 def Copy(self) -> Testsuite:
963 return self.__class__(
964 self._name,
965 self._hostname,
966 self._startTime,
967 self._duration,
968 self._status
969 )
971 def Aggregate(self, strict: bool = True) -> TestsuiteAggregateReturnType:
972 tests, skipped, errored, weak, failed, passed = super().Aggregate()
974 for testclass in self._testclasses.values():
975 for testcase in testclass._testcases.values():
976 _ = testcase.Aggregate()
978 tests += 1
979 status = testcase._status
980 if status is TestcaseStatus.Unknown: 980 ↛ 981line 980 didn't jump to line 981 because the condition on line 980 was never true
981 raise UnittestError(f"Found testcase '{testcase._name}' with state 'Unknown'.")
982 elif status is TestcaseStatus.Skipped:
983 skipped += 1
984 elif status is TestcaseStatus.Errored:
985 errored += 1
986 elif status is TestcaseStatus.Passed:
987 passed += 1
988 elif status is TestcaseStatus.Failed: 988 ↛ 990line 988 didn't jump to line 990 because the condition on line 988 was always true
989 failed += 1
990 elif status is TestcaseStatus.Weak:
991 weak += 1
992 elif status & TestcaseStatus.Mask is not TestcaseStatus.Unknown:
993 raise UnittestError(f"Found testcase '{testcase._name}' with unsupported state '{status}'.")
994 else:
995 raise UnittestError(f"Internal error for testcase '{testcase._name}', field '_status' is '{status}'.")
997 self._tests = tests
998 self._skipped = skipped
999 self._errored = errored
1000 self._weak = weak
1001 self._failed = failed
1002 self._passed = passed
1004 # FIXME: weak?
1005 if errored > 0:
1006 self._status = TestsuiteStatus.Errored
1007 elif failed > 0:
1008 self._status = TestsuiteStatus.Failed
1009 elif tests == 0:
1010 self._status = TestsuiteStatus.Empty
1011 elif tests - skipped == passed: 1011 ↛ 1013line 1011 didn't jump to line 1013 because the condition on line 1011 was always true
1012 self._status = TestsuiteStatus.Passed
1013 elif tests == skipped:
1014 self._status = TestsuiteStatus.Skipped
1015 else:
1016 self._status = TestsuiteStatus.Unknown
1018 return tests, skipped, errored, weak, failed, passed
1020 def Iterate(self, scheme: IterationScheme = IterationScheme.Default) -> Generator[Union[TestsuiteType, Testcase], None, None]:
1021 """
1022 Iterate the test suite and its child elements according to the iteration scheme.
1024 If no scheme is given, use the default scheme.
1026 :param scheme: Optional, scheme how to iterate the test suite and its child elements.
1027 :returns: A generator for iterating the results filtered and in the order defined by the iteration scheme.
1028 """
1029 if IterationScheme.PreOrder in scheme:
1030 if IterationScheme.IncludeSelf | IterationScheme.IncludeTestsuites in scheme:
1031 yield self
1033 if IterationScheme.IncludeTestcases in scheme:
1034 for testcase in self._testclasses.values():
1035 yield testcase
1037 for testclass in self._testclasses.values():
1038 yield from testclass.Iterate(scheme | IterationScheme.IncludeSelf)
1040 if IterationScheme.PostOrder in scheme:
1041 if IterationScheme.IncludeTestcases in scheme:
1042 for testcase in self._testclasses.values():
1043 yield testcase
1045 if IterationScheme.IncludeSelf | IterationScheme.IncludeTestsuites in scheme:
1046 yield self
1048 @classmethod
1049 def FromTestsuite(cls, testsuite: ut_Testsuite) -> Testsuite:
1050 """
1051 Convert a test suite of the unified test entity data model to the JUnit specific data model's test suite object.
1053 :param testsuite: Test suite from unified data model.
1054 :returns: Test suite from JUnit specific data model.
1055 """
1056 juTestsuite = cls(
1057 testsuite._name,
1058 hostname=testsuite._hostname,
1059 startTime=testsuite._startTime,
1060 duration=testsuite._totalDuration,
1061 status= testsuite._status,
1062 )
1064 for tc in testsuite.IterateTestcases():
1065 ts = tc._parent
1066 if ts is None: 1066 ↛ 1067line 1066 didn't jump to line 1067 because the condition on line 1066 was never true
1067 raise UnittestError(f"Testcase '{tc._name}' is not part of a hierarchy.")
1069 classname = ts._name
1070 ts = ts._parent
1071 while ts is not None and ts._kind > TestsuiteKind.Logical:
1072 classname = f"{ts._name}.{classname}"
1073 ts = ts._parent
1075 if classname in juTestsuite._testclasses:
1076 juClass = juTestsuite._testclasses[classname]
1077 else:
1078 juClass = Testclass(classname, parent=juTestsuite)
1080 juClass.AddTestcase(Testcase.FromTestcase(tc))
1082 return juTestsuite
1084 def ToTestsuite(self) -> ut_Testsuite:
1085 testsuite = ut_Testsuite(
1086 self._name,
1087 kind=TestsuiteKind.Logical,
1088 hostname=self._hostname,
1089 startTime=self._startTime,
1090 totalDuration=self._duration,
1091 status=self._status,
1092 )
1094 for testclass in self._testclasses.values():
1095 suite = testsuite
1096 classpath = testclass._name.split(".")
1097 for element in classpath:
1098 if element in suite._testsuites:
1099 suite = suite._testsuites[element]
1100 else:
1101 suite = ut_Testsuite(element, kind=TestsuiteKind.Package, parent=suite)
1103 suite._kind = TestsuiteKind.Class
1104 if suite._parent is not testsuite:
1105 suite._parent._kind = TestsuiteKind.Module
1107 suite.AddTestcases(tc.ToTestcase() for tc in testclass._testcases.values())
1109 return testsuite
1111 def ToTree(self) -> Node:
1112 node = Node(
1113 value=self._name,
1114 children=(cls.ToTree() for cls in self._testclasses.values())
1115 )
1116 node["startTime"] = self._startTime
1117 node["duration"] = self._duration
1119 return node
1121 def __str__(self) -> str:
1122 moduleName = self.__module__.split(".")[-1]
1123 className = getTypeName(self)
1124 return (
1125 f"<{moduleName}{className} {self._name}: {self._status.name} - tests:{self._tests}>"
1126 )
1129@export
1130class TestsuiteSummary(TestsuiteBase):
1131 _testsuites: Dict[str, Testsuite]
1133 def __init__(
1134 self,
1135 name: str,
1136 startTime: Nullable[datetime] = None,
1137 duration: Nullable[timedelta] = None,
1138 status: TestsuiteStatus = TestsuiteStatus.Unknown,
1139 testsuites: Nullable[Iterable[Testsuite]] = None
1140 ) -> None:
1141 super().__init__(name, startTime, duration, status)
1143 self._testsuites = {}
1144 if testsuites is not None:
1145 for testsuite in testsuites:
1146 if testsuite._parent is not None: 1146 ↛ 1147line 1146 didn't jump to line 1147 because the condition on line 1146 was never true
1147 raise ValueError(f"Testsuite '{testsuite._name}' is already part of a testsuite hierarchy.")
1149 if testsuite._name in self._testsuites: 1149 ↛ 1150line 1149 didn't jump to line 1150 because the condition on line 1149 was never true
1150 raise DuplicateTestsuiteError(f"Testsuite already contains a testsuite with same name '{testsuite._name}'.")
1152 testsuite._parent = self
1153 self._testsuites[testsuite._name] = testsuite
1155 @readonly
1156 def Testsuites(self) -> Dict[str, Testsuite]:
1157 """
1158 Read-only property to access the summary's testsuites.
1160 :returns: Dictionary of testsuite names and testsuites.
1161 """
1162 return self._testsuites
1164 @readonly
1165 def TestcaseCount(self) -> int:
1166 """
1167 Read-only property to return the number of testcases across all testsuites.
1169 :returns: Sum of the testsuites' testcase counts.
1170 """
1171 return sum(ts.TestcaseCount for ts in self._testsuites.values())
1173 @readonly
1174 def TestsuiteCount(self) -> int:
1175 """
1176 Read-only property to return the number of testsuites in this summary.
1178 :returns: Number of testsuites.
1179 """
1180 return len(self._testsuites)
1182 @readonly
1183 def AssertionCount(self) -> int:
1184 """
1185 Read-only property to return the number of assertions across all testsuites.
1187 :returns: Sum of the testsuites' assertion counts.
1188 """
1189 return sum(ts.AssertionCount for ts in self._testsuites.values())
1191 def AddTestsuite(self, testsuite: Testsuite) -> None:
1192 if testsuite._parent is not None: 1192 ↛ 1193line 1192 didn't jump to line 1193 because the condition on line 1192 was never true
1193 raise ValueError(f"Testsuite '{testsuite._name}' is already part of a testsuite hierarchy.")
1195 if testsuite._name in self._testsuites: 1195 ↛ 1196line 1195 didn't jump to line 1196 because the condition on line 1195 was never true
1196 raise DuplicateTestsuiteError(f"Testsuite already contains a testsuite with same name '{testsuite._name}'.")
1198 testsuite._parent = self
1199 self._testsuites[testsuite._name] = testsuite
1201 def AddTestsuites(self, testsuites: Iterable[Testsuite]) -> None:
1202 for testsuite in testsuites:
1203 self.AddTestsuite(testsuite)
1205 def Aggregate(self) -> TestsuiteAggregateReturnType:
1206 tests, skipped, errored, weak, failed, passed = super().Aggregate()
1208 for testsuite in self._testsuites.values():
1209 t, s, e, w, f, p = testsuite.Aggregate()
1210 tests += t
1211 skipped += s
1212 errored += e
1213 weak += w
1214 failed += f
1215 passed += p
1217 self._tests = tests
1218 self._skipped = skipped
1219 self._errored = errored
1220 self._weak = weak
1221 self._failed = failed
1222 self._passed = passed
1224 # FIXME: weak
1225 if errored > 0:
1226 self._status = TestsuiteStatus.Errored
1227 elif failed > 0:
1228 self._status = TestsuiteStatus.Failed
1229 elif tests == 0:
1230 self._status = TestsuiteStatus.Empty
1231 elif tests - skipped == passed: 1231 ↛ 1233line 1231 didn't jump to line 1233 because the condition on line 1231 was always true
1232 self._status = TestsuiteStatus.Passed
1233 elif tests == skipped:
1234 self._status = TestsuiteStatus.Skipped
1235 else:
1236 self._status = TestsuiteStatus.Unknown
1238 return tests, skipped, errored, weak, failed, passed
1240 def Iterate(self, scheme: IterationScheme = IterationScheme.Default) -> Generator[Union[Testsuite, Testcase], None, None]:
1241 """
1242 Iterate the test suite summary and its child elements according to the iteration scheme.
1244 If no scheme is given, use the default scheme.
1246 :param scheme: Optional, scheme how to iterate the test suite summary and its child elements.
1247 :returns: A generator for iterating the results filtered and in the order defined by the iteration scheme.
1248 """
1249 if IterationScheme.IncludeSelf | IterationScheme.IncludeTestsuites | IterationScheme.PreOrder in scheme:
1250 yield self
1252 for testsuite in self._testsuites.values():
1253 yield from testsuite.IterateTestsuites(scheme | IterationScheme.IncludeSelf)
1255 if IterationScheme.IncludeSelf | IterationScheme.IncludeTestsuites | IterationScheme.PostOrder in scheme:
1256 yield self
1258 @classmethod
1259 def FromTestsuiteSummary(cls, testsuiteSummary: ut_TestsuiteSummary) -> TestsuiteSummary:
1260 """
1261 Convert a test suite summary of the unified test entity data model to the JUnit specific data model's test suite.
1263 :param testsuiteSummary: Test suite summary from unified data model.
1264 :returns: Test suite summary from JUnit specific data model.
1265 """
1266 return cls(
1267 testsuiteSummary._name,
1268 startTime=testsuiteSummary._startTime,
1269 duration=testsuiteSummary._totalDuration,
1270 status=testsuiteSummary._status,
1271 testsuites=(Testsuite.FromTestsuite(testsuite) for testsuite in testsuiteSummary._testsuites.values())
1272 )
1274 def ToTestsuiteSummary(self) -> ut_TestsuiteSummary:
1275 """
1276 Convert this test suite summary a new test suite summary of the unified data model.
1278 All fields are copied to the new instance. Child elements like test suites are copied recursively.
1280 :returns: A test suite summary of the unified test entity data model.
1281 """
1282 return ut_TestsuiteSummary(
1283 self._name,
1284 startTime=self._startTime,
1285 totalDuration=self._duration,
1286 status=self._status,
1287 testsuites=(testsuite.ToTestsuite() for testsuite in self._testsuites.values())
1288 )
1290 def ToTree(self) -> Node:
1291 node = Node(
1292 value=self._name,
1293 children=(ts.ToTree() for ts in self._testsuites.values())
1294 )
1295 node["startTime"] = self._startTime
1296 node["duration"] = self._duration
1298 return node
1300 def __str__(self) -> str:
1301 moduleName = self.__module__.split(".")[-1]
1302 className = getTypeName(self)
1303 return (
1304 f"<{moduleName}{className} {self._name}: {self._status.name} - tests:{self._tests}>"
1305 )
1308@export
1309class Document(TestsuiteSummary, ut_Document):
1310 _DIALECT: ClassVar[str] = "Any-JUnit"
1311 _TESTCASE: ClassVar[Type[Testcase]] = Testcase
1312 _TESTCLASS: ClassVar[Type[Testclass]] = Testclass
1313 _TESTSUITE: ClassVar[Type[Testsuite]] = Testsuite
1315 _readerMode: JUnitReaderMode
1316 _xmlDocument: Nullable[_ElementTree]
1318 def __init__(
1319 self,
1320 xmlReportFile: Path,
1321 analyzeAndConvert: bool = False,
1322 readerMode: JUnitReaderMode = JUnitReaderMode.Default,
1323 *,
1324 name: str = "Unprocessed JUnit XML file",
1325 startTime: Nullable[datetime] = None,
1326 duration: Nullable[timedelta] = None,
1327 status: TestsuiteStatus = TestsuiteStatus.Unknown,
1328 testsuites: Nullable[Iterable[Testsuite]] = None
1329 ) -> None:
1330 """
1331 Initializes a JUnit XML document, read from or written to a file.
1333 :param xmlReportFile: Path to the JUnit XML file.
1334 :param analyzeAndConvert: Optional, if true, analyze (parse and validate) the file and convert its content.
1335 :param readerMode: Optional, how strictly the file is read.
1336 :param name: Optional, name of the test suite summary.
1337 :param startTime: Optional, time when the test run was started.
1338 :param duration: Optional, duration of the test run.
1339 :param status: Optional, overall status of the test run.
1340 :param testsuites: Optional, test suites of the summary.
1341 """
1342 super().__init__(name, startTime, duration, status, testsuites)
1344 self._readerMode = readerMode
1345 self._xmlDocument = None
1347 ut_Document.__init__(self, xmlReportFile, analyzeAndConvert)
1349 @classmethod
1350 def FromTestsuiteSummary(cls, xmlReportFile: Path, testsuiteSummary: ut_TestsuiteSummary) -> Document:
1351 """
1352 Convert a test suite summary of the unified test entity data model to a JUnit XML document of this dialect.
1354 The test suites become test suites of the dialect (:attr:`_TESTSUITE`). The counters are computed by
1355 :meth:`Aggregate`.
1357 :param xmlReportFile: Path to the JUnit XML file to write.
1358 :param testsuiteSummary: Test suite summary from unified data model.
1359 :returns: JUnit XML document of this dialect.
1360 """
1361 doc = cls(
1362 xmlReportFile,
1363 name=testsuiteSummary._name,
1364 startTime=testsuiteSummary._startTime,
1365 duration=testsuiteSummary._totalDuration,
1366 status=testsuiteSummary._status,
1367 testsuites=(cls._TESTSUITE.FromTestsuite(testsuite) for testsuite in testsuiteSummary._testsuites.values())
1368 )
1369 doc.Aggregate()
1371 return doc
1373 def Analyze(self) -> None:
1374 """
1375 Analyze the XML file, parse the content into an XML data structure and validate the data structure using an XML
1376 schema.
1378 .. hint::
1380 The time spend for analysis will be made available via property :data:`AnalysisDuration`.
1382 The used XML schema definition is generic to support "any" dialect.
1383 """
1384 xmlSchemaFile = "Any-JUnit.xsd"
1385 self._Analyze(xmlSchemaFile)
1387 def _Analyze(self, xmlSchemaFile: str) -> None:
1388 if not self._path.exists(): 1388 ↛ 1389line 1388 didn't jump to line 1389 because the condition on line 1388 was never true
1389 raise UnittestError(f"JUnit XML file '{self._path}' does not exist.") \
1390 from FileNotFoundError(f"File '{self._path}' not found.")
1392 with Stopwatch() as sw:
1393 try:
1394 xmlSchemaResourceFile = getResourceFile(Resources, xmlSchemaFile)
1395 except ToolingException as ex:
1396 raise UnittestError(f"Couldn't locate XML Schema '{xmlSchemaFile}' in package resources.") from ex
1398 try:
1399 schemaParser = XMLParser(ns_clean=True)
1400 schemaRoot = parse(xmlSchemaResourceFile, schemaParser)
1401 except XMLSyntaxError as ex:
1402 raise UnittestError(f"XML Syntax Error while parsing XML Schema '{xmlSchemaFile}'.") from ex
1404 try:
1405 junitSchema = XMLSchema(schemaRoot)
1406 except XMLSchemaParseError as ex:
1407 raise UnittestError(f"Error while parsing XML Schema '{xmlSchemaFile}'.") from ex
1409 try:
1410 content = self._path.read_bytes()
1411 except OSError as ex:
1412 raise UnittestError(f"Couldn't read JUnit XML file '{self._path}'.") from ex
1414 junitParser = XMLParser(schema=junitSchema, ns_clean=True)
1415 try:
1416 self._xmlDocument = parse(BytesIO(content), parser=junitParser, base_url=str(self._path))
1417 except XMLSyntaxError as ex:
1418 for logEntry in junitParser.error_log:
1419 ex.add_note(str(logEntry))
1420 raise UnittestError(
1421 f"XML syntax or validation error for '{self._path}' using XSD schema '{xmlSchemaResourceFile}'."
1422 ) from ex
1424 self._analysisDuration = sw.Duration
1426 def Write(self, path: Nullable[Path] = None, overwrite: bool = False, regenerate: bool = False) -> None:
1427 """
1428 Write the data model as XML into a file adhering to the Any JUnit dialect.
1430 :param path: Optional, path to the XML file, if internal path shouldn't be used.
1431 :param overwrite: Optional, if true, overwrite an existing file.
1432 :param regenerate: Optional, if true, regenerate the XML structure from data model.
1433 :raises UnittestError: If the file cannot be overwritten.
1434 :raises UnittestError: If the internal XML data structure wasn't generated.
1435 :raises UnittestError: If the file cannot be opened or written.
1436 """
1437 if path is None:
1438 path = self._path
1440 if not overwrite and path.exists():
1441 raise UnittestError(f"JUnit XML file '{path}' can not be overwritten.") \
1442 from FileExistsError(f"File '{path}' already exists.")
1444 if regenerate:
1445 self.Generate(overwrite=True)
1447 if self._xmlDocument is None: 1447 ↛ 1448line 1447 didn't jump to line 1448 because the condition on line 1447 was never true
1448 ex = UnittestError("Internal XML document tree is empty and needs to be generated before write is possible.")
1449 ex.add_note("Call 'JUnitDocument.Generate()' or 'JUnitDocument.Write(..., regenerate=True)'.")
1450 raise ex
1452 content = tostring(self._xmlDocument, encoding="utf-8", xml_declaration=True, pretty_print=True)
1453 try:
1454 with path.open("wb") as file:
1455 file.write(content)
1456 except OSError as ex:
1457 raise UnittestError(f"JUnit XML file '{path}' can not be written.") from ex
1459 def Convert(self) -> None:
1460 """
1461 Convert the parsed and validated XML data structure into a JUnit test entity hierarchy.
1463 This method converts the root element.
1465 .. hint::
1467 The time spend for model conversion will be made available via property :data:`ModelConversionDuration`.
1469 :raises UnittestError: If XML was not read and parsed before.
1470 """
1471 if self._xmlDocument is None: 1471 ↛ 1472line 1471 didn't jump to line 1472 because the condition on line 1471 was never true
1472 ex = UnittestError(f"JUnit XML file '{self._path}' needs to be read and analyzed by an XML parser.")
1473 ex.add_note("Call 'JUnitDocument.Analyze()' or create the document using 'JUnitDocument(path, parse=True)'.")
1474 raise ex
1476 with Stopwatch() as sw:
1477 rootElement: _Element = self._xmlDocument.getroot()
1479 self._name = self._ConvertName(rootElement, optional=True)
1480 self._startTime = self._ConvertTimestamp(rootElement, optional=True)
1481 self._duration = self._ConvertTime(rootElement, optional=True)
1483 if False: # self._readerMode is JUnitReaderMode.
1484 self._tests = self._ConvertTests(testsuitesNode)
1485 self._skipped = self._ConvertSkipped(testsuitesNode)
1486 self._errored = self._ConvertErrors(testsuitesNode)
1487 self._failed = self._ConvertFailures(testsuitesNode)
1488 self._assertionCount = self._ConvertAssertions(testsuitesNode)
1490 for rootNode in rootElement.iterchildren(tag="testsuite"): # type: _Element
1491 self._ConvertTestsuite(self, rootNode)
1493 if True: # self._readerMode is JUnitReaderMode.
1494 self.Aggregate()
1496 self._modelConversion = sw.Duration
1498 def _ConvertName(self, element: _Element, default: str = "root", optional: bool = True) -> str:
1499 """
1500 Convert the ``name`` attribute from an XML element node to a string.
1502 :param element: The XML element node with a ``name`` attribute.
1503 :param default: Optional, the default value, if no ``name`` attribute was found.
1504 :param optional: Optional, if false, an exception is raised for the missing attribute.
1505 :returns: The ``name`` attribute's content if found, otherwise the given default value.
1506 :raises UnittestError: If optional is false and no ``name`` attribute exists on the given element node.
1507 """
1508 if "name" in element.attrib:
1509 return element.attrib["name"]
1510 elif not optional: 1510 ↛ 1511line 1510 didn't jump to line 1511 because the condition on line 1510 was never true
1511 raise UnittestError(f"Required parameter 'name' not found in tag '{element.tag}'.")
1512 else:
1513 return default
1515 def _ConvertTimestamp(self, element: _Element, optional: bool = True) -> Nullable[datetime]:
1516 """
1517 Convert the ``timestamp`` attribute from an XML element node to a datetime.
1519 :param element: The XML element node with a ``timestamp`` attribute.
1520 :param optional: Optional, if false, an exception is raised for the missing attribute.
1521 :returns: The ``timestamp`` attribute's content if found, otherwise ``None``.
1522 :raises UnittestError: If optional is false and no ``timestamp`` attribute exists on the given element node.
1523 """
1524 if "timestamp" in element.attrib:
1525 timestamp = element.attrib["timestamp"]
1526 return datetime.fromisoformat(timestamp)
1527 elif not optional:
1528 raise UnittestError(f"Required parameter 'timestamp' not found in tag '{element.tag}'.")
1529 else:
1530 return None
1532 def _ConvertTime(self, element: _Element, optional: bool = True) -> Nullable[timedelta]:
1533 """
1534 Convert the ``time`` attribute from an XML element node to a timedelta.
1536 :param element: The XML element node with a ``time`` attribute.
1537 :param optional: Optional, if false, an exception is raised for the missing attribute.
1538 :returns: The ``time`` attribute's content if found, otherwise ``None``.
1539 :raises UnittestError: If optional is false and no ``time`` attribute exists on the given element node.
1540 """
1541 if "time" in element.attrib:
1542 time = element.attrib["time"]
1543 return timedelta(seconds=float(time))
1544 elif not optional: 1544 ↛ 1545line 1544 didn't jump to line 1545 because the condition on line 1544 was never true
1545 raise UnittestError(f"Required parameter 'time' not found in tag '{element.tag}'.")
1546 else:
1547 return None
1549 def _ConvertHostname(self, element: _Element, default: str = "localhost", optional: bool = True) -> str:
1550 """
1551 Convert the ``hostname`` attribute from an XML element node to a string.
1553 :param element: The XML element node with a ``hostname`` attribute.
1554 :param default: Optional, the default value, if no ``hostname`` attribute was found.
1555 :param optional: Optional, if false, an exception is raised for the missing attribute.
1556 :returns: The ``hostname`` attribute's content if found, otherwise the given default value.
1557 :raises UnittestError: If optional is false and no ``hostname`` attribute exists on the given element node.
1558 """
1559 if "hostname" in element.attrib:
1560 return element.attrib["hostname"]
1561 elif not optional:
1562 raise UnittestError(f"Required parameter 'hostname' not found in tag '{element.tag}'.")
1563 else:
1564 return default
1566 def _ConvertClassname(self, element: _Element) -> str:
1567 """
1568 Convert the ``classname`` attribute from an XML element node to a string.
1570 :param element: The XML element node with a ``classname`` attribute.
1571 :returns: The ``classname`` attribute's content.
1572 :raises UnittestError: If no ``classname`` attribute exists on the given element node.
1573 """
1574 if "classname" in element.attrib: 1574 ↛ 1577line 1574 didn't jump to line 1577 because the condition on line 1574 was always true
1575 return element.attrib["classname"]
1576 else:
1577 raise UnittestError(f"Required parameter 'classname' not found in tag '{element.tag}'.")
1579 def _ConvertTests(self, element: _Element, default: Nullable[int] = None, optional: bool = True) -> Nullable[int]:
1580 """
1581 Convert the ``tests`` attribute from an XML element node to an integer.
1583 :param element: The XML element node with a ``tests`` attribute.
1584 :param default: Optional, the default value, if no ``tests`` attribute was found.
1585 :param optional: Optional, if false, an exception is raised for the missing attribute.
1586 :returns: The ``tests`` attribute's content if found, otherwise the given default value.
1587 :raises UnittestError: If optional is false and no ``tests`` attribute exists on the given element node.
1588 """
1589 if "tests" in element.attrib:
1590 return int(element.attrib["tests"])
1591 elif not optional:
1592 raise UnittestError(f"Required parameter 'tests' not found in tag '{element.tag}'.")
1593 else:
1594 return default
1596 def _ConvertSkipped(self, element: _Element, default: Nullable[int] = None, optional: bool = True) -> Nullable[int]:
1597 """
1598 Convert the ``skipped`` attribute from an XML element node to an integer.
1600 :param element: The XML element node with a ``skipped`` attribute.
1601 :param default: Optional, the default value, if no ``skipped`` attribute was found.
1602 :param optional: Optional, if false, an exception is raised for the missing attribute.
1603 :returns: The ``skipped`` attribute's content if found, otherwise the given default value.
1604 :raises UnittestError: If optional is false and no ``skipped`` attribute exists on the given element node.
1605 """
1606 if "skipped" in element.attrib:
1607 return int(element.attrib["skipped"])
1608 elif not optional:
1609 raise UnittestError(f"Required parameter 'skipped' not found in tag '{element.tag}'.")
1610 else:
1611 return default
1613 def _ConvertErrors(self, element: _Element, default: Nullable[int] = None, optional: bool = True) -> Nullable[int]:
1614 """
1615 Convert the ``errors`` attribute from an XML element node to an integer.
1617 :param element: The XML element node with a ``errors`` attribute.
1618 :param default: Optional, the default value, if no ``errors`` attribute was found.
1619 :param optional: Optional, if false, an exception is raised for the missing attribute.
1620 :returns: The ``errors`` attribute's content if found, otherwise the given default value.
1621 :raises UnittestError: If optional is false and no ``errors`` attribute exists on the given element node.
1622 """
1623 if "errors" in element.attrib:
1624 return int(element.attrib["errors"])
1625 elif not optional:
1626 raise UnittestError(f"Required parameter 'errors' not found in tag '{element.tag}'.")
1627 else:
1628 return default
1630 def _ConvertFailures(self, element: _Element, default: Nullable[int] = None, optional: bool = True) -> Nullable[int]:
1631 """
1632 Convert the ``failures`` attribute from an XML element node to an integer.
1634 :param element: The XML element node with a ``failures`` attribute.
1635 :param default: Optional, the default value, if no ``failures`` attribute was found.
1636 :param optional: Optional, if false, an exception is raised for the missing attribute.
1637 :returns: The ``failures`` attribute's content if found, otherwise the given default value.
1638 :raises UnittestError: If optional is false and no ``failures`` attribute exists on the given element node.
1639 """
1640 if "failures" in element.attrib:
1641 return int(element.attrib["failures"])
1642 elif not optional:
1643 raise UnittestError(f"Required parameter 'failures' not found in tag '{element.tag}'.")
1644 else:
1645 return default
1647 def _ConvertAssertions(self, element: _Element, default: Nullable[int] = None, optional: bool = True) -> Nullable[int]:
1648 """
1649 Convert the ``assertions`` attribute from an XML element node to an integer.
1651 :param element: The XML element node with a ``assertions`` attribute.
1652 :param default: Optional, the default value, if no ``assertions`` attribute was found.
1653 :param optional: Optional, if false, an exception is raised for the missing attribute.
1654 :returns: The ``assertions`` attribute's content if found, otherwise the given default value.
1655 :raises UnittestError: If optional is false and no ``assertions`` attribute exists on the given element node.
1656 """
1657 if "assertions" in element.attrib:
1658 return int(element.attrib["assertions"])
1659 elif not optional: 1659 ↛ 1660line 1659 didn't jump to line 1660 because the condition on line 1659 was never true
1660 raise UnittestError(f"Required parameter 'assertions' not found in tag '{element.tag}'.")
1661 else:
1662 return default
1664 def _ConvertTestsuite(self, parent: TestsuiteSummary, testsuitesNode: _Element) -> None:
1665 """
1666 Convert the XML data structure of a ``<testsuite>`` to a test suite.
1668 This method uses private helper methods provided by the base-class.
1670 :param parent: The test suite summary as a parent element in the test entity hierarchy.
1671 :param testsuitesNode: The current XML element node representing a test suite.
1672 """
1673 newTestsuite = self._TESTSUITE(
1674 self._ConvertName(testsuitesNode, optional=False),
1675 self._ConvertHostname(testsuitesNode, optional=True),
1676 self._ConvertTimestamp(testsuitesNode, optional=True),
1677 self._ConvertTime(testsuitesNode, optional=True),
1678 parent=parent
1679 )
1681 if False: # self._readerMode is JUnitReaderMode.
1682 self._tests = self._ConvertTests(testsuitesNode)
1683 self._skipped = self._ConvertSkipped(testsuitesNode)
1684 self._errored = self._ConvertErrors(testsuitesNode)
1685 self._failed = self._ConvertFailures(testsuitesNode)
1686 self._assertionCount = self._ConvertAssertions(testsuitesNode)
1688 self._ConvertTestsuiteChildren(testsuitesNode, newTestsuite)
1690 def _ConvertTestsuiteChildren(self, testsuitesNode: _Element, newTestsuite: Testsuite) -> None:
1691 for node in testsuitesNode.iterchildren(): # type: _Element
1692 # if node.tag == "testsuite":
1693 # self._ConvertTestsuite(newTestsuite, node)
1694 # el
1695 if node.tag == "testcase":
1696 self._ConvertTestcase(newTestsuite, node)
1698 def _ConvertTestcase(self, parent: Testsuite, testcaseNode: _Element) -> None:
1699 """
1700 Convert the XML data structure of a ``<testcase>`` to a test case.
1702 This method uses private helper methods provided by the base-class.
1704 :param parent: The test suite as a parent element in the test entity hierarchy.
1705 :param testcaseNode: The current XML element node representing a test case.
1706 """
1707 className = self._ConvertClassname(testcaseNode)
1708 testclass = self._FindOrCreateTestclass(parent, className)
1710 newTestcase = self._TESTCASE(
1711 self._ConvertName(testcaseNode, optional=False),
1712 self._ConvertTime(testcaseNode, optional=True),
1713 assertionCount=self._ConvertAssertions(testcaseNode),
1714 parent=testclass
1715 )
1717 self._ConvertTestcaseChildren(testcaseNode, newTestcase)
1719 def _FindOrCreateTestclass(self, parent: Testsuite, className: str) -> Testclass:
1720 if className in parent._testclasses:
1721 return parent._testclasses[className]
1722 else:
1723 return self._TESTCLASS(className, parent=parent)
1725 def _ConvertTestcaseChildren(self, testcaseNode: _Element, newTestcase: Testcase) -> None:
1726 """
1727 Convert the child elements of a ``<testcase>`` to the test case's status, message, details and captured output.
1729 A ``<skipped>``, ``<failure>`` or ``<error>`` element sets the status. Its ``message`` attribute becomes the test
1730 case's message, its text becomes the test case's details. The texts of ``<system-out>`` and ``<system-err>``
1731 become the captured standard output and standard error; multiple such elements are concatenated. The reruns of a
1732 test case (``<flakyFailure>``, ``<flakyError>``, ``<rerunFailure>`` and ``<rerunError>``) don't change its status.
1734 :param testcaseNode: The current XML element node representing a test case.
1735 :param newTestcase: The test case to update.
1736 :raises UnittestError: If an unknown element is found.
1737 """
1738 for node in testcaseNode.iterchildren(): # type: _Element
1739 if isinstance(node, _Comment): 1739 ↛ 1740line 1739 didn't jump to line 1740 because the condition on line 1739 was never true
1740 pass
1741 elif isinstance(node, _Element): 1741 ↛ 1770line 1741 didn't jump to line 1770 because the condition on line 1741 was always true
1742 if node.tag == "skipped":
1743 newTestcase._status = TestcaseStatus.Skipped
1744 self._ConvertStatusMessage(node, newTestcase)
1745 elif node.tag == "failure":
1746 newTestcase._status = TestcaseStatus.Failed
1747 self._ConvertStatusMessage(node, newTestcase)
1748 elif node.tag == "error":
1749 newTestcase._status = TestcaseStatus.Errored
1750 self._ConvertStatusMessage(node, newTestcase)
1751 elif node.tag == "system-out":
1752 if node.text is None: 1752 ↛ 1753line 1752 didn't jump to line 1753 because the condition on line 1752 was never true
1753 pass
1754 elif newTestcase._standardOutput is None:
1755 newTestcase._standardOutput = node.text
1756 else:
1757 newTestcase._standardOutput += node.text
1758 elif node.tag == "system-err":
1759 if node.text is None:
1760 pass
1761 elif newTestcase._standardError is None:
1762 newTestcase._standardError = node.text
1763 else:
1764 newTestcase._standardError += node.text
1765 elif node.tag in ("properties", "flakyFailure", "flakyError", "rerunFailure", "rerunError"): 1765 ↛ 1768line 1765 didn't jump to line 1768 because the condition on line 1765 was always true
1766 pass
1767 else:
1768 raise UnittestError(f"Unknown element '{node.tag}' in junit file.")
1769 else:
1770 pass
1772 if newTestcase._status is TestcaseStatus.Unknown:
1773 newTestcase._status = TestcaseStatus.Passed
1775 def _ConvertStatusMessage(self, element: _Element, testcase: Testcase) -> None:
1776 """
1777 Convert the ``message`` attribute and the text of a ``<skipped>``, ``<failure>`` or ``<error>`` element.
1779 An empty text (only whitespace) is not recorded.
1781 :param element: The XML element node explaining the test case's status.
1782 :param testcase: The test case to update.
1783 """
1784 testcase._message = element.attrib.get("message", None)
1785 testcase._details = None if element.text is None or element.text.strip() == "" else element.text
1787 def Generate(self, overwrite: bool = False) -> None:
1788 """
1789 Generate the internal XML data structure from test suites and test cases.
1791 This method generates the XML root element (``<testsuites>``) and recursively calls other generated methods.
1793 :param overwrite: Optional, overwrite the internal XML data structure.
1794 :raises UnittestError: If overwrite is false and the internal XML data structure is not empty.
1795 """
1796 if not overwrite and self._xmlDocument is not None: 1796 ↛ 1797line 1796 didn't jump to line 1797 because the condition on line 1796 was never true
1797 raise UnittestError("Internal XML document is populated with data.")
1799 rootElement = Element("testsuites")
1800 rootElement.attrib["name"] = self._name
1801 if self._startTime is not None:
1802 rootElement.attrib["timestamp"] = f"{self._startTime.isoformat()}"
1803 if self._duration is not None:
1804 rootElement.attrib["time"] = f"{self._duration.total_seconds():.6f}"
1805 rootElement.attrib["tests"] = str(self._tests)
1806 rootElement.attrib["failures"] = str(self._failed)
1807 rootElement.attrib["errors"] = str(self._errored)
1808 rootElement.attrib["skipped"] = str(self._skipped)
1809 # if self._assertionCount is not None:
1810 # rootElement.attrib["assertions"] = f"{self._assertionCount}"
1812 self._xmlDocument = ElementTree(rootElement)
1814 for testsuite in self._testsuites.values():
1815 self._GenerateTestsuite(testsuite, rootElement)
1817 def _GenerateTestsuite(self, testsuite: Testsuite, parentElement: _Element) -> None:
1818 """
1819 Generate the internal XML data structure for a test suite.
1821 This method generates the XML element (``<testsuite>``) and recursively calls other generated methods.
1823 :param testsuite: The test suite to convert to an XML data structures.
1824 :param parentElement: The parent XML data structure element, this data structure part will be added to.
1825 """
1826 testsuiteElement = SubElement(parentElement, "testsuite")
1827 testsuiteElement.attrib["name"] = testsuite._name
1828 if testsuite._startTime is not None:
1829 testsuiteElement.attrib["timestamp"] = f"{testsuite._startTime.isoformat()}"
1830 if testsuite._duration is not None:
1831 testsuiteElement.attrib["time"] = f"{testsuite._duration.total_seconds():.6f}"
1832 testsuiteElement.attrib["tests"] = str(testsuite._tests)
1833 testsuiteElement.attrib["failures"] = str(testsuite._failed)
1834 testsuiteElement.attrib["errors"] = str(testsuite._errored)
1835 testsuiteElement.attrib["skipped"] = str(testsuite._skipped)
1836 # if testsuite._assertionCount is not None:
1837 # testsuiteElement.attrib["assertions"] = f"{testsuite._assertionCount}"
1838 if testsuite._hostname is not None:
1839 testsuiteElement.attrib["hostname"] = testsuite._hostname
1841 for testclass in testsuite._testclasses.values():
1842 for tc in testclass._testcases.values():
1843 self._GenerateTestcase(tc, testsuiteElement)
1845 def _GenerateTestcase(self, testcase: Testcase, parentElement: _Element) -> None:
1846 """
1847 Generate the internal XML data structure for a test case.
1849 This method generates the XML element (``<testcase>``) and recursively calls other generated methods.
1851 :param testcase: The test case to convert to an XML data structures.
1852 :param parentElement: The parent XML data structure element, this data structure part will be added to.
1853 """
1854 testcaseElement = SubElement(parentElement, "testcase")
1855 if testcase.Classname is not None: 1855 ↛ 1857line 1855 didn't jump to line 1857 because the condition on line 1855 was always true
1856 testcaseElement.attrib["classname"] = testcase.Classname
1857 testcaseElement.attrib["name"] = testcase._name
1858 if testcase._duration is not None: 1858 ↛ 1860line 1858 didn't jump to line 1860 because the condition on line 1858 was always true
1859 testcaseElement.attrib["time"] = f"{testcase._duration.total_seconds():.6f}"
1860 if testcase._assertionCount is not None:
1861 testcaseElement.attrib["assertions"] = f"{testcase._assertionCount}"
1863 self._GenerateTestcaseChildren(testcase, testcaseElement)
1865 def _GenerateTestcaseChildren(self, testcase: Testcase, testcaseElement: _Element) -> None:
1866 """
1867 Generate the child elements of a ``<testcase>`` from the test case's status, message, details and captured output.
1869 A failed, skipped or errored test case gets a ``<failure>``, ``<skipped>`` or ``<error>`` element carrying the
1870 message (``message`` attribute) and details (text). Captured standard output and standard error are written as
1871 ``<system-out>`` and ``<system-err>`` elements.
1873 :param testcase: The test case to convert to XML child elements.
1874 :param testcaseElement: The ``<testcase>`` element, the child elements will be added to.
1875 """
1876 if testcase._status is TestcaseStatus.Passed:
1877 statusElement = None
1878 elif testcase._status is TestcaseStatus.Failed:
1879 statusElement = SubElement(testcaseElement, "failure")
1880 elif testcase._status is TestcaseStatus.Skipped:
1881 statusElement = SubElement(testcaseElement, "skipped")
1882 else:
1883 statusElement = SubElement(testcaseElement, "error")
1885 if statusElement is not None:
1886 if testcase._message is not None:
1887 statusElement.attrib["message"] = testcase._message
1889 if testcase._details is not None:
1890 statusElement.text = testcase._details
1892 if testcase._standardOutput is not None:
1893 SubElement(testcaseElement, "system-out").text = testcase._standardOutput
1895 if testcase._standardError is not None:
1896 SubElement(testcaseElement, "system-err").text = testcase._standardError
1898 def __str__(self) -> str:
1899 moduleName = self.__module__.split(".")[-1]
1900 className = getTypeName(self)
1901 return (
1902 f"<{moduleName}{className} {self._name} ({self._path}): {self._status.name} - suites/tests:{self.TestsuiteCount}/{self.TestcaseCount}>"
1903 )