Unittesting
pyEDAA.Reports provides a unified and generic unittest summary data model. The data model allows the description of testcases grouped in testsuites. Testsuites can be nested in other testsuites. The data model’s root element is a special testsuite called testsuite summary. It contains only testsuites, but no testcases.
The data model can be filled from various sources like Ant JUnit test reports, Open Test Reporting files, pyTooling test reports or OSVVM testsuite summaries (more to be added). Many programming languages and/or unit testing frameworks support exporting results in the Ant JUnit format. See below for supported formats and their variations (dialects).
Attention
The so called JUnit XML format is the weakest file format and standard ever seen. At first was not created by JUnit (version 4). It was added by the built system Ant, but it’s not called Ant XML format nor Ant JUnit XML format. The latest JUnit 5 uses a completely different format called open test reporting. As JUnit is not the formats author, no file format documentation nor XML schema was provided. Also Ant isn’t providing any file format documentation or XML schema. Various Ant JUnit XML adopters have tried to reverse engineer a description and XML schemas, but unfortunately many are not even compatible to each other.
Unified data model
The unified data model for test entities (test summary, test suite, test case) implements a super-set of all (so far known) unit test result summary file formats. pyEDAA.Report’s data model is a structural and functional cleanup of the Ant JUnit data model. Naming has been cleaned up and missing features have been added.
As some of the JUnit XML dialects are too divergent from the original Ant + JUnit4 format, these dialects have an independent test entity inheritance hierarchy. Nonetheless, instances of each data format can be converted to and from the unified data model.
A test case is the leaf-element in the test entity hierarchy and describes an individual test run. Test cases are grouped by test suites.
A test suite is a group of test cases and/or test suites. Test suites itself can be grouped by test suites. The test suite hierarchy’s root element is a test suite summary.
The test suite summary is derived from test suite and defines the root of the test suite hierarchy.
The document is derived from a test suite summary and represents a file containing a test suite summary.
graph TD;
doc[Document]
sum[Summary]
ts1[Testsuite]
ts2[Testsuite]
ts21[Testsuite]
tc11[Testcase]
tc12[Testcase]
tc13[Testcase]
tc21[Testcase]
tc22[Testcase]
tc211[Testcase]
tc212[Testcase]
tc213[Testcase]
doc:::root -.-> sum:::summary
sum --> ts1:::suite
sum --> ts2:::suite
ts2 --> ts21:::suite
ts1 --> tc11:::case
ts1 --> tc12:::case
ts1 --> tc13:::case
ts2 --> tc21:::case
ts2 --> tc22:::case
ts21 --> tc211:::case
ts21 --> tc212:::case
ts21 --> tc213:::case
classDef root fill:#4dc3ff
classDef summary fill:#80d4ff
classDef suite fill:#b3e6ff
classDef case fill:#eeccff
Testcase Status
TestcaseStatus and TestsuiteStatus are
flag enumerations to describe the overall status of a test case or test suite.
- Unknown
tbd
- Excluded
tbd
- Skipped
tbd
- Weak
tbd
- Passed
tbd
- Failed
tbd
- Inverted
tbd
- Warned
tbd
- Errored
tbd
- Failed
tbd
- SetupError
tbd
- TearDownError
tbd
- Inconsistent
tbd
@export
class TestcaseStatus(Flag):
"""A flag enumeration describing the status of a test case."""
Unknown = 0 #: Testcase status is uninitialized and therefore unknown.
Excluded = 1 #: Testcase was permanently excluded / disabled
Skipped = 2 #: Testcase was temporarily skipped (e.g. based on a condition)
Weak = 4 #: No assertions were recorded.
Passed = 8 #: A passed testcase, because all assertions were successful.
Failed = 16 #: A failed testcase due to at least one failed assertion.
Mask = Excluded | Skipped | Weak | Passed | Failed
Inverted = 128 #: To mark inverted results
UnexpectedPassed = Failed | Inverted
ExpectedFailed = Passed | Inverted
Warned = 1024 #: Runtime warning
Errored = 2048 #: Runtime error (mostly caught exceptions)
Aborted = 4096 #: Uncaught runtime exception
SetupError = 8192 #: Preparation / compilation error
TearDownError = 16384 #: Cleanup error / resource release error
Inconsistent = 32768 #: Dataset is inconsistent
Flags = Warned | Errored | Aborted | SetupError | TearDownError | Inconsistent
@export
class TestsuiteStatus(Flag):
"""A flag enumeration describing the status of a test suite."""
Unknown = 0
Excluded = 1 #: Testcase was permanently excluded / disabled
Skipped = 2 #: Testcase was temporarily skipped (e.g. based on a condition)
Empty = 4 #: No tests in suite
Passed = 8 #: Passed testcase, because all assertions succeeded
Failed = 16 #: Failed testcase due to failing assertions
Mask = Excluded | Skipped | Empty | Passed | Failed
Inverted = 128 #: To mark inverted results
UnexpectedPassed = Failed | Inverted
ExpectedFailed = Passed | Inverted
Warned = 1024 #: Runtime warning
Errored = 2048 #: Runtime error (mostly caught exceptions)
Aborted = 4096 #: Uncaught runtime exception
SetupError = 8192 #: Preparation / compilation error
TearDownError = 16384 #: Cleanup error / resource release error
Flags = Warned | Errored | Aborted | SetupError | TearDownError
Testcase
A Testcase is the leaf-element in the test entity hierarchy and describes an
individual test run. Besides a test case status, it also contains statistics like the start time or the test
duration. Test cases are grouped by test suites and need to be unique per parent test suite.
A test case (or its base classes) implements the following properties and methods:
ParentThe test case has a reference to it’s parent test suite in the hierarchy. By iterating parent references, the root element (test suite summary) be be found, which has no parent reference (
None).NameThe test case has a name. This name must be unique per hierarchy parent, but can exist multiple times in the overall test hierarchy.
In case the data format uses hierarchical names like
pyEDAA.Reports.CLI.Application, the name is split at the separator and multiple hierarchy levels (test suites) are created in the unified data model. To be able to recreate such an hierarchical name,TestsuiteKindis applied accordingly to test suite’sKindfield.Title,Summary,DescriptionThe test case can carry a title, a summary and a description written for a reader, e.g. taken from a doc-string. Formats like JUnit XML can’t express them, so they are
Nonethen.StartTimeThe test case stores a time when the individual test run was started. In combination with
TotalDuration, the end time can be calculated. If the start time is unknown, set this value toNone.SetupDuration,TestDuration,TeardownDuration,TotalDurationThe test case has fields to capture the setup duration, test run duration and teardown duration. The sum of all durations is provided by total duration.
TotalDuration := SetupDuration + TestDuration + TeardownDurationThe setup duration is the time spend on setting up a test run. If the setup duration can’t be distinguished from the test’s runtime, set this value to
None.The test’s runtime without setup and teardown portions is captured by test duration. If the duration is unknown, set this value to
None.The teardown duration of a test run is the time spend on tearing down a test run. If the teardown duration can’t be distinguished from the test’s runtime, set this value to
None.The test case has a field total duration to sum up setup duration, test duration and teardown duration. If the duration is unknown, this value will be
None.WarningCount,ErrorCount,FatalCountThe test case counts for warnings, errors and fatal errors observed in a test run while the test was executed.
__len__(),__getitem__(),__setitem__(),__delitem__(),__contains__(),__iter__()The test case implements a dictionary interface, so arbitrary key-value pairs can be annotated per test entity.
StatusThe overall status of a test case.
See also: Testcase Status.
AssertionCount,PassedAssertionCount,FailedAssertionCountThe assertion count represents the overall number of assertions (checks) in a test case. It can be distinguished into passed assertions and failed assertions. If it can’t be distinguished, set passed and failed assertions to
None.AssertionCount := PassedAssertionCount + FailedAssertionCountMessage,DetailsA test case’s status can be explained by a message and details, e.g. the message of a failed assertion and its traceback, or why a test case was skipped. If unknown, these values are
None.StandardOutput,StandardErrorThe test case’s standard output and standard error captured while it ran. If not captured, these values are
None.Copy()tbd
Aggregate()Aggregate (recalculate) all durations, warnings, errors, assertions, etc.
__str__()tbd
@export
class Testcase(Base):
def __init__(
self,
name: str,
startTime: Nullable[datetime] = None,
setupDuration: Nullable[timedelta] = None,
testDuration: Nullable[timedelta] = None,
teardownDuration: Nullable[timedelta] = None,
totalDuration: Nullable[timedelta] = None,
status: TestcaseStatus = TestcaseStatus.Unknown,
assertionCount: Nullable[int] = None,
failedAssertionCount: Nullable[int] = None,
passedAssertionCount: Nullable[int] = None,
warningCount: int = 0,
errorCount: int = 0,
fatalCount: int = 0,
message: Nullable[str] = None,
details: Nullable[str] = None,
standardOutput: Nullable[str] = None,
standardError: Nullable[str] = None,
*,
parent: Nullable["Testsuite"] = None
):
...
@readonly
def Parent(self) -> Nullable["Testsuite"]:
...
@readonly
def Name(self) -> str:
...
@readonly
def StartTime(self) -> Nullable[datetime]:
...
@readonly
def SetupDuration(self) -> Nullable[timedelta]:
...
@readonly
def TestDuration(self) -> Nullable[timedelta]:
...
@readonly
def TeardownDuration(self) -> Nullable[timedelta]:
...
@readonly
def TotalDuration(self) -> Nullable[timedelta]:
...
@readonly
def WarningCount(self) -> int:
...
@readonly
def ErrorCount(self) -> int:
...
@readonly
def FatalCount(self) -> int:
...
def __len__(self) -> int:
...
def __getitem__(self, key: str) -> Any:
...
def __setitem__(self, key: str, value: Any) -> None:
...
def __delitem__(self, key: str) -> None:
...
def __contains__(self, key: str) -> bool:
...
def __iter__(self) -> Generator[Tuple[str, Any], None, None]:
...
@readonly
def Status(self) -> TestcaseStatus:
...
@readonly
def AssertionCount(self) -> int:
...
@readonly
def FailedAssertionCount(self) -> int:
...
@readonly
def PassedAssertionCount(self) -> int:
...
@readonly
def Message(self) -> Nullable[str]:
...
@readonly
def Details(self) -> Nullable[str]:
...
@readonly
def StandardOutput(self) -> Nullable[str]:
...
@readonly
def StandardError(self) -> Nullable[str]:
...
def Copy(self) -> "Testcase":
...
def Aggregate(self, strict: bool = True) -> TestcaseAggregateReturnType:
...
def __str__(self) -> str:
...
Testsuite
A Testsuite is a grouping element in the test entity hierarchy and describes
a group of test runs. Besides a list of test cases and a test suite status, it also contains statistics like the
start time or the test duration for the group of tests. Test suites are grouped by other test suites or a test
suite summary and need to be unique per parent test suite.
A test suite (or its base classes) implements the following properties and methods:
ParentThe test suite has a reference to it’s parent test entity in the hierarchy. By iterating parent references, the root element (test suite summary) be be found, which has no parent reference (
None).NameThe test suite has a name. This name must be unique per hierarchy parent, but can exist multiple times in the overall test hierarchy.
In case the data format uses hierarchical names like
pyEDAA.Reports.CLI.Application, the name is split at the separator and multiple hierarchy levels (test suites) are created in the unified data model. To be able to recreate such an hierarchical name,TestsuiteKindis applied accordingly to test suite’sKindfield.Title,Summary,DescriptionThe test suite can carry a title, a summary and a description written for a reader, e.g. taken from a doc-string. Formats like JUnit XML can’t express them, so they are
Nonethen.StartTimeThe test suite stores a time when the first test run was started. In combination with
TotalDuration, the end time can be calculated. If the start time is unknown, set this value toNone.SetupDuration,TestDuration,TeardownDuration,TotalDurationThe test suite has fields to capture the suite’s setup duration, test group run duration and suite’s teardown duration. The sum of all durations is provided by total duration.
TotalDuration := SetupDuration + TestDuration + TeardownDurationThe setup duration is the time spend on setting up a test suite. If the setup duration can’t be distinguished from the test group’s runtime, set this value to
None.The test group’s runtime without setup and teardown portions is captured by test duration. If the duration is unknown, set this value to
None.The teardown duration of a test suite is the time spend on tearing down a test suite. If the teardown duration can’t be distinguished from the test group’s runtime, set this value to
None.The test suite has a field total duration to sum up setup duration, test duration and teardown duration. If the duration is unknown, this value will be
None.WarningCount,ErrorCount,FatalCountThe test suite counts for warnings, errors and fatal errors observed in a test suite while the tests were executed.
__len__(),__getitem__(),__setitem__(),__delitem__(),__contains__(),__iter__()The test suite implements a dictionary interface, so arbitrary key-value pairs can be annotated.
Todo
TestsuiteBase APIs
Testcasestbd
TestcaseCounttbd
AssertionCountThe overall number of assertions (checks) in a test case.
Aggregate()Aggregate (recalculate) all durations, warnings, errors, assertions, etc.
Iterate()tbd
__str__()tbd
@export
class Testsuite(TestsuiteBase[TestsuiteType]):
def __init__(
self,
name: str,
kind: TestsuiteKind = TestsuiteKind.Logical,
startTime: Nullable[datetime] = None,
setupDuration: Nullable[timedelta] = None,
testDuration: Nullable[timedelta] = None,
teardownDuration: Nullable[timedelta] = None,
totalDuration: Nullable[timedelta] = None,
status: TestsuiteStatus = TestsuiteStatus.Unknown,
warningCount: int = 0,
errorCount: int = 0,
fatalCount: int = 0,
testsuites: Nullable[Iterable[TestsuiteType]] = None,
testcases: Nullable[Iterable["Testcase"]] = None,
*,
parent: Nullable[TestsuiteType] = None
):
...
@readonly
def Parent(self) -> Nullable["Testsuite"]:
...
@readonly
def Name(self) -> str:
...
@readonly
def StartTime(self) -> Nullable[datetime]:
...
@readonly
def SetupDuration(self) -> Nullable[timedelta]:
...
@readonly
def TestDuration(self) -> Nullable[timedelta]:
...
@readonly
def TeardownDuration(self) -> Nullable[timedelta]:
...
@readonly
def TotalDuration(self) -> Nullable[timedelta]:
...
@readonly
def WarningCount(self) -> int:
...
@readonly
def ErrorCount(self) -> int:
...
@readonly
def FatalCount(self) -> int:
...
def __len__(self) -> int:
...
def __getitem__(self, key: str) -> Any:
...
def __setitem__(self, key: str, value: Any) -> None:
...
def __delitem__(self, key: str) -> None:
...
def __contains__(self, key: str) -> bool:
...
def __iter__(self) -> Generator[Tuple[str, Any], None, None]:
...
# TestsuiteBase API
@readonly
def Testcases(self) -> Dict[str, "Testcase"]:
...
@readonly
def TestcaseCount(self) -> int:
...
@readonly
def AssertionCount(self) -> int:
...
def Aggregate(self, strict: bool = True) -> TestsuiteAggregateReturnType:
...
def Iterate(self, scheme: IterationScheme = IterationScheme.Default) -> Generator[Union[TestsuiteType, Testcase], None, None]:
...
def __str__(self) -> str:
...
TestsuiteSummary
A TestsuiteSummary is the root element in the test entity hierarchy and
describes a group of test suites as well as overall statistics for the whole set of test cases. A test suite
summary is derived for the same base-class as a test suite, thus they share almost all properties and methods.
A test suite summary (or its base classes) implements the following properties and methods:
ParentThe test suite summary has a parent reference, but as the root element in the test entity hierarchy, its always
None.NameThe test suite summary has a name.
Title,Summary,DescriptionThe test suite summary can carry a title, a summary and a description written for a reader, e.g. taken from a doc-string. Formats like JUnit XML can’t express them, so they are
Nonethen.StartTimeThe test suite summary stores a time when the first test runs was started. In combination with
TotalDuration, the end time can be calculated. If the start time is unknown, set this value toNone.SetupDuration,TestDuration,TeardownDuration,TotalDurationThe test suite summary has fields to capture the suite summary’s setup duration, overall run duration and suite summary’s teardown duration. The sum of all durations is provided by total duration.
TotalDuration := SetupDuration + TestDuration + TeardownDurationThe setup duration is the time spend on setting up an overall test run. If the setup duration can’t be distinguished from the test’s runtimes, set this value to
None.The test suite summary’s runtime without setup and teardown portions is captured by test duration. If the duration is unknown, set this value to
None.The teardown duration of a test suite summary is the time spend on tearing down a test suite summary. If the teardown duration can’t be distinguished from the test’s runtimes, set this value to
None.The test suite summary has a field total duration to sum up setup duration, overall run duration and teardown duration. If the duration is unknown, this value will be
None.WarningCount,ErrorCount,FatalCountThe test suite summary counts for warnings, errors and fatal errors observed in a test suite summary while the tests were executed.
__len__(),__getitem__(),__setitem__(),__delitem__(),__contains__(),__iter__()The test suite summary implements a dictionary interface, so arbitrary key-value pairs can be annotated.
Todo
TestsuiteBase APIs
Aggregate()tbd
Iterate()tbd
__str__()tbd
@export
class TestsuiteSummary(TestsuiteBase[TestsuiteType]):
def __init__(
self,
name: str,
startTime: Nullable[datetime] = None,
setupDuration: Nullable[timedelta] = None,
testDuration: Nullable[timedelta] = None,
teardownDuration: Nullable[timedelta] = None,
totalDuration: Nullable[timedelta] = None,
status: TestsuiteStatus = TestsuiteStatus.Unknown,
warningCount: int = 0,
errorCount: int = 0,
fatalCount: int = 0,
testsuites: Nullable[Iterable[TestsuiteType]] = None,
*,
parent: Nullable[TestsuiteType] = None
):
...
@readonly
def Parent(self) -> Nullable["Testsuite"]:
...
@readonly
def Name(self) -> str:
...
@readonly
def StartTime(self) -> Nullable[datetime]:
...
@readonly
def SetupDuration(self) -> Nullable[timedelta]:
...
@readonly
def TestDuration(self) -> Nullable[timedelta]:
...
@readonly
def TeardownDuration(self) -> Nullable[timedelta]:
...
@readonly
def TotalDuration(self) -> Nullable[timedelta]:
...
@readonly
def WarningCount(self) -> int:
...
@readonly
def ErrorCount(self) -> int:
...
@readonly
def FatalCount(self) -> int:
...
def __len__(self) -> int:
...
def __getitem__(self, key: str) -> Any:
...
def __setitem__(self, key: str, value: Any) -> None:
...
def __delitem__(self, key: str) -> None:
...
def __contains__(self, key: str) -> bool:
...
def __iter__(self) -> Generator[Tuple[str, Any], None, None]:
...
# TestsuiteBase API
def Aggregate(self) -> TestsuiteAggregateReturnType:
...
def Iterate(self, scheme: IterationScheme = IterationScheme.Default) -> Generator[Union[TestsuiteType, Testcase], None, None]:
...
def __str__(self) -> str:
...
Document
A Document is a mixin-class …
Pathtbd
AnalysisDurationtbd
ModelConversionDurationtbd
Analyze()tbd
Convert()tbd
@export
class Document(metaclass=ExtendedType, mixin=True):
def __init__(self, path: Path):
...
@readonly
def Path(self) -> Path:
...
@readonly
def AnalysisDuration(self) -> timedelta:
...
@readonly
def ModelConversionDuration(self) -> timedelta:
...
@abstractmethod
def Analyze(self) -> None:
...
@abstractmethod
def Convert(self):
...
Specific Data Models
JUnit Data Model
A test case is the leaf-element in the test entity hierarchy and describes an individual test run. Test cases are grouped by test classes.
A test class is the mid-level element in the test entity hierarchy and describes a group of test runs. Test classes are grouped by test suites.
A test suite is a group of test classes. Test suites are grouped by a test suite summary.
The test suite summary is derived from test suite and defines the root of the test suite hierarchy.
The document is derived from a test suite summary and represents a file containing a test suite summary.
The JUnit format is not well defined, thus multiple dialects developed over time.
graph TD;
doc[Document]
sum[Summary]
ts1[Testsuite]
ts11[Testsuite]
ts2[Testsuite]
tc111[Testclass]
tc112[Testclass]
tc23[Testclass]
tc1111[Testcase]
tc1112[Testcase]
tc1113[Testcase]
tc1121[Testcase]
tc1122[Testcase]
tc231[Testcase]
tc232[Testcase]
tc233[Testcase]
doc:::root -.-> sum:::summary
sum --> ts1:::suite
sum ---> ts2:::suite
ts1 --> ts11:::suite
ts11 --> tc111:::cls
ts11 --> tc112:::cls
ts2 --> tc23:::cls
tc111 --> tc1111:::case
tc111 --> tc1112:::case
tc111 --> tc1113:::case
tc112 --> tc1121:::case
tc112 --> tc1122:::case
tc23 --> tc231:::case
tc23 --> tc232:::case
tc23 --> tc233:::case
classDef root fill:#4dc3ff
classDef summary fill:#80d4ff
classDef suite fill:#b3e6ff
classDef cls fill:#ff9966
classDef case fill:#eeccff
Testcase
Testclass
Testsuite
TestsuiteSummary
Document
JUnit Dialects
As the JUnit XML format was not well specified and no XML Schema Definition (XSDXML Schema Definition) was provided, many variants and dialects (and simplifications) were created by the various frameworks emitting JUnit XML files.
JUnit Dialect Comparison
Feature |
Any JUnit |
Ant + JUnit4 |
Catch2 JUnit |
CTest JUnit |
go-junit-report |
GoogleTest JUnit |
cargo-nextest JUnit |
pyTest JUnit |
TestLogger JUnit |
|---|---|---|---|---|---|---|---|---|---|
Root element |
testsuites |
testsuite |
testsuites |
testsuite |
testsuites |
testsuites |
testsuites |
testsuites |
testsuites |
Supports properties |
☑ |
☑ |
☑ |
☑ |
⸺ |
☑ |
|||
Testcase status |
… |
… |
always run |
more status values |
reruns |
no errors |
Any JUnit
The Any JUnit format uses a relaxed XML schema definition aiming to parse many JUnit XML dialects, which use a
<testsuites> root element.
A report is validated against Any-JUnit.xsd.
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit import Document
xmlReport = Path("AnyJUnit-Report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting.JUnit import Document
# Convert to unified test data model
summary = doc.ToTestsuiteSummary()
# Convert back to a document
newXmlReport = Path("New JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit import Document
xmlReport = Path("AnyJUnit-Report.xml")
try:
newDoc.Write(xmlReport)
except UnittestError as ex:
...
Ant + JUnit4
The original JUnit format created by Ant for JUnit4
uses <testsuite> as a root element.
A report is validated against Ant-JUnit4.xsd.
Gradle writes this format too, when it runs JUnit4 tests.
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.AntJUnit4 import Document
xmlReport = Path("AntJUnit4-Report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting.JUnit.AntJUnit4 import Document
# Convert to unified test data model
summary = doc.ToTestsuiteSummary()
# Convert back to a document
newXmlReport = Path("New JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.AntJUnit4 import Document
xmlReport = Path("AntJUnit4-Report.xml")
try:
newDoc.Write(xmlReport)
except UnittestError as ex:
...
Catch2 JUnit
The Catch2 JUnit format written by the JUnit reporter of Catch2
(version 3) uses <testsuites> as a root element. It holds exactly one <testsuite>, named after the test
executable.
A report is validated against Catch2-JUnit.xsd.
Each test case and each path of nested sections is a
<testcase>, named by its section path (TestCase/Section/Subsection). A test case or section containing sections is a<testcase>of its own.The
classnameis<executable>.global, or<executable>.<fixture class>for a test case of a fixture class (::becomes.).SKIP()writes a<skipped>element, a failed assertion a<failure>element, an unexpected exception an<error>element. Only the first of them per<testcase>is written.Each
<testcase>carriesstatus="run".
Known issues
The
tests,failures,errorsandskippedattributes of<testsuite>count assertions, not test cases. Written by pyEDAA.Reports, they count test cases.The
hostnameattribute is alwaystbd.A test case without assertions and without output is missing.
A failure in a section is reported on the section’s
<testcase>only; the<testcase>of the enclosing test case passes.A failure expected by
[!mayfail]or[!shouldfail]writes a<skipped>element followed by a<failure>element. Its test case is read as skipped.A
[!shouldfail]test case passing unexpectedly fails for Catch2, but passes in the report.The properties
random-seedandfiltersaren’t read.
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.Catch2JUnit import Document
xmlReport = Path("Catch2JUnit-Report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting.JUnit.Catch2JUnit import Document
# Convert to unified test data model
summary = doc.ToTestsuiteSummary()
# Convert back to a document
newXmlReport = Path("New JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.Catch2JUnit import Document
xmlReport = Path("Catch2JUnit-Report.xml")
try:
newDoc.Write(xmlReport)
except UnittestError as ex:
...
CTest JUnit
The CTest JUnit format written by CMake’s CTest (ctest --output-junit) uses
<testsuite> as a root element.
A report is validated against CTest-JUnit.xsd.
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.CTestJUnit import Document
xmlReport = Path("CTestJUnit-Report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting.JUnit.CTestJUnit import Document
# Convert to unified test data model
summary = doc.ToTestsuiteSummary()
# Convert back to a document
newXmlReport = Path("New JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.CTestJUnit import Document
xmlReport = Path("CTestJUnit-Report.xml")
try:
newDoc.Write(xmlReport)
except UnittestError as ex:
...
go-junit-report JUnit
The JUnit format written by go-junit-report (v2) uses
<testsuites> as a root element. It converts the output of go test (option -parser gotest, the default)
or of go test -json (option -parser gojson).
A
<testsuite>is a Go package, named by its import path. It carries anid(its index in the report), and thehostnameandtimestampof the conversion, not of the test run.A
<testcase>is a test, a subtest (TestName/Subtest) or an example. Itsclassnameis the package’s import path. A parent test is a test case of its own, besides its subtests.<failure message="Failed">holds the failure’s log lines,<skipped message="Skipped">the reason. An<error>is a build error, a runtime error of the test binary or a test without a result. A passed test’s log is in its<system-out>.
from pyEDAA.Reports.Unittesting.JUnit.GoJUnitReport import Document
xmlReport = Path("go-junit-report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pyEDAA.Reports.Unittesting.JUnit.GoJUnitReport import Document
# Convert to unified test data model
summary = doc.ToTestsuiteSummary()
# Convert back to a document
newXmlReport = Path("New JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
from pyEDAA.Reports.Unittesting.JUnit.GoJUnitReport import Document
xmlReport = Path("go-junit-report.xml")
try:
newDoc.Write(xmlReport)
except UnittestError as ex:
...
Reader
A package without tests, run with code coverage (
go test -coverprofile=...), is written as<testsuite name="">: go-junit-report doesn’t recognizego test’s result line of such a package (<tab><import path><tab><tab>coverage: 0.0% of statements) and names the package by its option-package-name, which is empty by default. The line is the test suite’s<system-out>, and the reader names the test suite by the import path in it. A test suite with an empty name and without such a line is rejected.A panic ends the package’s test binary. Its stack trace is written to the package’s
<system-out>, which isn’t read, and the<failure>of the panicking test is empty. A parent test failed by a subtest has an empty<failure>too.<properties>(coverage.statements.pct, and the properties of the options-pand-go-version) and theidattribute aren’t read.In the unified data model, a test class becomes a test suite of kind class named by the whole import path; it isn’t split at
.into packages.Option
-subtest-modechanges the parent tests:ignore-parent-resultswrites them as passed,exclude-parentsleaves them out.
Writer
idis the test suite’s index in the document.A test suite without a duration gets the sum of its test cases’ durations as
time, as go-junit-report does.Every
<skipped>,<error>and<failure>gets amessageattribute; it’s empty for a test case without a message.
GoogleTest JUnit
The GoogleTest JUnit format written by GoogleTest (sometimes GTest)
uses <testsuites> as a root element.
A report is validated against GoogleTest-JUnit.xsd.
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.GoogleTestJUnit import Document
xmlReport = Path("GoogleTestJUnit-Report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting.JUnit.GoogleTestJUnit import Document
# Convert to unified test data model
summary = doc.ToTestsuiteSummary()
# Convert back to a document
newXmlReport = Path("New JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.GoogleTestJUnit import Document
xmlReport = Path("GoogleTestJUnit-Report.xml")
try:
newDoc.Write(xmlReport)
except UnittestError as ex:
...
cargo-nextest JUnit
The JUnit format written by cargo-nextest, the test runner for Rust,
uses <testsuites> as a root element. nextest serializes it with the crate
quick-junit.
A report is validated against Nextest-JUnit.xsd.
Mapping to the data model
<testsuites>: itsnameis the profile’s report name (nextest-runby default), itsuuidthe run ID (RunID),timestampandtimethe run’s start time and duration.<testsuite>: one per test binary, named by its binary ID, e.g.counterfor the unit tests of a library andcounter::sequencefor its integration testtests/sequence.rs. A setup script gets a test suite@setup-script:<name>with one test case, and its command as<property>. A<testsuite>has neitherhostname, nortimestamp, nortime.<testcase>: itsnameis the test’s path in the binary, e.g.tests::failing, itsclassnamethe binary ID again. In the unified data model, the test cases are therefore in a test suite (class) of the same name as the binary’s test suite. Thetimestampis kept as the test case’s start time (StartTime).Status:
<failure>is failed,<error>is errored (e.g. the test process didn’t start, or leaked handles),<skipped>is skipped, a test case without one of these passed.Retries: a test which passed in a retry (flaky) has a
<flakyFailure>(or<flakyError>) per failed attempt, a test failing in every attempt a<rerunFailure>(or<rerunError>) per retry. Their number isRerunCount; their output is not read. A failed test case carries the start time, duration and message of its first attempt, a flaky one those of its last attempt.
Known issues
A test marked
#[ignore]is missing from the report, unless the profile setsjunit.report-skippedto"ignored"or"all". The console reports12 tests run: 8 passed, 4 failed, 1 skipped, the reporttests="12" skipped="0". A reported skipped test has notimestampand atimeof zero.Every failure of a Rust test is a
<failure type="test failure with exit code 101">: a failed assertion, a panic, anErrreturned by the test and a#[should_panic]test which doesn’t panic. Only the message (the first line of the error output) and the text tell them apart. Thetypeis not kept in the data model.A flaky test configured with
flaky-result = "fail"is a<failure type="flaky failure">with the messagetest passed on attempt 2/2 but is configured to fail when flaky.A setup script counts as a test:
tests="6"in a report, whose console summary is4 tests run: 2 passed (1 flaky), 2 failed, 1 skipped.The test cases are listed in the order they finished; reported skipped tests come first.
Writing: the run ID is written only for a document read from a nextest report, as the unified data model has no field for it. Reruns and the
typeof a<failure>are not written. The test suite summary needs a start time and a duration; thehostname, start time and duration of a test suite are not written.
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.NextestJUnit import Document
xmlReport = Path("target/nextest/ci/junit.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting.JUnit.NextestJUnit import Document
# Convert to unified test data model
summary = doc.ToTestsuiteSummary()
# Convert back to a document
newXmlReport = Path("New JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.NextestJUnit import Document
xmlReport = Path("Nextest-JUnit-Report.xml")
try:
newDoc.Write(xmlReport)
except UnittestError as ex:
...
pyTest JUnit
The pyTest JUnit format written by pyTest uses <testsuites> as a
root element.
A report is validated against PyTest-JUnit.xsd.
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.PyTestJUnit import Document
xmlReport = Path("PyTestJUnit-Report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting.JUnit.PyTestJUnit import Document
# Convert to unified test data model
summary = doc.ToTestsuiteSummary()
# Convert back to a document
newXmlReport = Path("New JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.PyTestJUnit import Document
xmlReport = Path("PyTestJUnit-Report.xml")
try:
newDoc.Write(xmlReport)
except UnittestError as ex:
...
JunitXml.TestLogger JUnit
The JUnit format written by the .NET test logger JunitXml.TestLogger
(dotnet test --logger junit, see dotnet test (VSTest)) uses <testsuites> as a root element.
from pyEDAA.Reports.Unittesting.JUnit.TestLoggerJUnit import Document
xmlReport = Path("TestLoggerJUnit-Report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pyEDAA.Reports.Unittesting.JUnit.TestLoggerJUnit import Document
# Convert to unified test data model
summary = doc.ToTestsuiteSummary()
# Convert back to a document
newXmlReport = Path("New JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
from pyEDAA.Reports.Unittesting.JUnit.TestLoggerJUnit import Document
xmlReport = Path("TestLoggerJUnit-Report.xml")
try:
newDoc.Write(xmlReport)
except UnittestError as ex:
...
Reading
<testsuites>has no attributes. Each<testsuite>holds the results of one test assembly and names it by its file (nameandpackage). Itstimestampis the start of the test run in UTC, written without a time zone, and is read as UTC. The test suite summary takes its start time from the first test suite.id(always0) andpackageare not read.A test case’s
classnameis the namespace and class of the test, itsnamethe method with the arguments of a parameterized test. In the unified data model, each part of the namespace becomes a test suite of kindNamespace, the class a test suite of kindClass. A nested class (Outer+Inner) and a parameterized test class (Fixture("a",1)) keep their names.<failure>is a failed test case,<skipped/>a skipped one, a test case without either passed.<system-out>and<system-err>of a test case are its captured output; the logger appends the test’s attachments to<system-out>as[[ATTACHMENT|path]]. The test framework’s messages of the whole run in<system-out>and<system-err>of the test suite, and a test case’s traits in<properties>, are not read.
Writing
An errored test case is written as
<failure type="failure">and counted infailures;errorsis always0.A skipped test case is written as an empty
<skipped/>, without its message and details.The start time is written in UTC. A test suite needs a start time, a duration, a host name and at least one test case; a test case needs a duration, written as at least
0.0000001seconds.
Known issues of the logger
VSTest knows failed tests only: an exception escaping a test, a time-out or a cancellation is a
<failure>as well. The exception’s type is only part of the message, e.g.System.DivideByZeroException : Attempted to divide by zero.The reason of a skipped test is lost; the TRX file keeps it.
A test whose outcome is neither passed, failed nor skipped (e.g. an inconclusive NUnit test) gets no child element: it is read as passed. The logger counts it in
testsonly.A test suite’s
timeis the sum of its test cases’ durations, not the duration of the run.With the option
MethodFormat=ClassorMethodFormat=Full,namerepeats the class or the namespace and class. It’s read as written, so the class shows up in the test case’s name too.A test name the logger can’t split into namespace, class and method is written with
classnameUnknownNamespace.UnknownTypeand the whole name asname.
Open Test Reporting Data Model
Open Test Reporting describes tests and containers of tests in a tree. A test
engine is a root, its containers (test classes, nested classes, parameterized tests) and tests (test methods,
invocations of a parameterized test) are its descendants. The data model of
pyEDAA.Reports.Unittesting.OpenTestReporting keeps this tree:
An
Executionholds theInfrastructure(host, user, operating system, CPU cores, Java version, …) and the root nodes.A
Nodeis a test or container with a display name, a start time and a duration. It carries the metadata (tags, JUnit’s unique ID, legacy reporting name and type), the sources (Java class and method), the attachments (data entries, output, files) and aResult.A result has a status, a reason and the exception ending the test: its type, whether it is an assertion error, and the stack trace.
Each element knows its parent: the infrastructure and a root node their execution, a node its parent node, a result its node.
pyEDAA.Reports.Unittesting.OpenTestReporting.Events.Document reads the event-based format,
pyEDAA.Reports.Unittesting.OpenTestReporting.Hierarchy.Document the hierarchical format into this data
model. Both convert it into the unified data model.
from pyEDAA.Reports.Unittesting.OpenTestReporting.Events import Document
xmlReport = Path("build/test-results/test/open-test-report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
for node in doc.Roots:
print(f"{node.Name}: {node.Result.Status}")
from pyEDAA.Reports.Unittesting.OpenTestReporting.Hierarchy import Document
xmlReport = Path("build/test-results/test/hierarchy.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pyEDAA.Reports.Unittesting.OpenTestReporting.Events import Document
# Convert to unified test data model
summary = doc.ToTestsuiteSummary()
print(f"{summary.Tests} tests: {summary.Passed} passed, {summary.Failed} failed")
Conversion to the Unified Data Model
Open Test Reporting |
Unified data model |
|---|---|
file, e.g. |
test suite summary, named by the file name without extension |
container |
test suite, kind |
test (JUnit’s type |
test case |
JUnit’s legacy reporting name, e.g. |
name; without JUnit’s metadata, the display name |
display name ( |
title (only together with a legacy reporting name) |
|
|
|
|
|
|
|
|
no result (started, but not finished) or no status |
|
reason, else the exception’s message |
message |
stack trace of the exception ( |
details |
output of source |
standard output / standard error |
data entries, e.g. JUnit’s |
key-value pairs of the test case |
start time of the first root node; time from start to finish (event-based), |
start time; total duration |
host name of the infrastructure |
host name of the root test suites |
The names of a container’s children have to be unique: two child containers or two child tests with the same name raise an exception instead of dropping a test case. A test without a container (a root node, which is a test) can’t be converted, because a test case needs a test suite as parent.
Not in the unified data model
The Open Test Reporting data model keeps these, the unified data model doesn’t:
the result of a container: e.g. a failure in
@BeforeAllis aFAILEDcontainer, whose tests weren’t started, but a test suite’s status is computed from its test cases only;the difference between
SKIPPEDandABORTED;the exception’s type and whether it is an assertion error: a
FAILEDtest isFailedfor an assertion and for another exception alike, as the JUnit Platform reports it;tags, JUnit’s unique ID and type, the Java class and method (beside the names), file attachments, output of other sources than
stdoutandstderr, and the timestamps of attachments;the infrastructure, besides the host name: user name, operating system, CPU cores, extension values like the Java version;
a data entry reported twice under the same key: the test case keeps the last value.
Both data models truncate the times from nanoseconds to microseconds. The event-based format’s durations are computed from truncated times, the hierarchical format’s are truncated, so they can differ by a microsecond.
OSVVM
Open Source VHDL Verification Methodology writes test results as YAML files for its internal data model storage. Some YAML files are written by the VHDL code of the verification framework, others are written by OSVVM-Scripts as a test runner.
Features
Create test entities
The hierarchy of test entities (test cases, test suites and test summaries) can be constructed top-down or bottom-up.
from pyEDAA.Reports.Unittesting import Testsuite, Testcase
# Top-down
ts1 = Testsuite("ts1")
tc = Testcase("tc", parent=ts1)
# Bottom-up
tc1 = Testcase("tc1")
tc2 = Testcase("tc2")
ts2 = Testsuite("ts2", testcases=(tc1, tc2))
# ts.AddTestcase(...)
tc3 = Testcase("tc3")
tc4 = Testcase("tc4")
ts3 = Testsuite("ts3")
ts3.AddTestcase(tc3)
ts3.AddTestcase(tc4)
# ts.AddTestcases(...)
tc3 = Testcase("tc3")
tc4 = Testcase("tc4")
ts3 = Testsuite("ts3")
ts3.AddTestcases((tc3, tc4))
from pyEDAA.Reports.Unittesting import Testsuite, TestsuiteSummary
# Top-down
ts = Testsuite("ts")
ts1 = Testsuite("ts1", parent=ts)
# Bottom-up
ts2 = Testsuite("ts2")
ts3 = Testsuite("ts3")
ts4 = Testsuite("ts4", testsuites=(ts2, ts3))
# ts.AddTestsuite(...)
ts5 = Testsuite("ts5")
ts6 = Testsuite("ts6")
ts7 = Testsuite("ts7")
ts7.AddTestsuite(ts5)
ts7.AddTestsuite(ts6)
# ts.AddTestsuites(...)
ts8 = Testsuite("ts8")
ts9 = Testsuite("ts9")
ts10 = Testsuite("ts10")
ts10.AddTestsuites((ts8, ts9))
from pyEDAA.Reports.Unittesting import Testsuite, TestsuiteSummary
# Top-down
tss1 = TestsuiteSummary("tss1")
ts1 = Testsuite("ts1", parent=tss1)
# Bottom-up
ts2 = Testsuite("ts2")
ts3 = Testsuite("ts3")
tss2 = TestsuiteSummary("tss2", testsuites=(ts2, ts3))
# tss.AddTestsuite(...)
ts4 = Testsuite("ts4")
ts5 = Testsuite("ts5")
tss3 = TestsuiteSummary("tss3")
tss3.AddTestsuite(ts4)
tss3.AddTestsuite(ts5)
# tss.AddTestsuites(...)
ts6 = Testsuite("ts6")
ts7 = Testsuite("ts7")
tss4 = TestsuiteSummary("tss4")
tss4.AddTestsuites((ts6, ts7))
Reading unittest reports
A JUnit XML test report summary file can be read by creating an instance of the Document
class. Because JUnit has so many dialects, a derived subclass for the dialect might be required. By choosing the
right Document class, also the XML schema for XML schema validation gets pre-selected.
A test report written by pyTooling’s pytest plugin (--pytooling-xml) is read by
pyEDAA.Reports.Unittesting.pyTooling.Document. Its format version is taken from the report’s
xsi:noNamespaceSchemaLocation attribute.
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit import Document
xmlReport = Path("AnyJUnit-Report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.AntJUnit4 import Document
xmlReport = Path("AntJUnit4-Report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.Catch2JUnit import Document
xmlReport = Path("Catch2-JUnit-Report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.CTestJUnit import Document
xmlReport = Path("CTest-JUnit-Report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pyEDAA.Reports.Unittesting.JUnit.GoJUnitReport import Document
xmlReport = Path("go-junit-report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.GoogleTestJUnit import Document
xmlReport = Path("GoogleTest-JUnit-Report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.NextestJUnit import Document
xmlReport = Path("Nextest-JUnit-Report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.PyTestJUnit import Document
xmlReport = Path("pyTest-JUnit-Report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pyEDAA.Reports.Unittesting.JUnit.TestLoggerJUnit import Document
xmlReport = Path("TestLogger-JUnit-Report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.pyTooling import Document
xmlReport = Path("TestReport.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
Converting unittest reports
Any JUnit dialect specific data model can be converted to the generic hierarchy of test entities.
Note
This conversion is identical for all derived dialects.
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit import Document
# Read from XML file
xmlReport = Path("JUnit-Report.xml")
try:
doc = Document(xmlReport, analyzeAndConvert=True)
except UnittestError as ex:
...
# Convert to unified test data model
summary = doc.ToTestsuiteSummary()
# Convert to a tree
rootNode = doc.ToTree()
# Convert back to a document
newXmlReport = Path("New JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
# Write to XML file
newDoc.Write()
Annotations
Every test entity can be annotated with arbitrary key-value pairs.
# Add annotate a key-value pair
testcase["key"] = value
# Update existing annotation with new value
testcase["key"] = newValue
# Check if key exists
if "key" in testcase:
pass
# Access annoation by key
value = testcase["key"]
# Get number of annotations
annotationCount = len(testcase)
# Delete annotation
del testcase["key"]
# Iterate annotations
for key, value in testcases:
pass
# Add annotate a key-value pair
testsuite["key"] = value
# Update existing annotation with new value
testsuite["key"] = newValue
# Check if key exists
if "key" in testsuite:
pass
# Access annoation by key
value = testsuite["key"]
# Get number of annotations
annotationCount = len(testsuite)
# Delete annotation
del testsuite["key"]
# Iterate annotations
for key, value in testsuite:
pass
# Add annotate a key-value pair
testsuiteSummary["key"] = value
# Update existing annotation with new value
testsuiteSummary["key"] = newValue
# Check if key exists
if "key" in testsuiteSummary:
pass
# Access annoation by key
value = testsuiteSummary["key"]
# Get number of annotations
annotationCount = len(testsuiteSummary)
# Delete annotation
del testsuiteSummary["key"]
# Iterate annotations
for key, value in testsuiteSummary:
pass
Merging unittest reports
add description here
# add code here
Concatenate unittest reports
Todo
Planned feature.
Transforming the reports’ hierarchy
pytest specific transformations
add description here
# add code here
Writing unittest reports
A test suite summary can be converted to a document of any JUnit dialect. Internally a deep-copy is created to convert from a hierarchy of the unified test entities to a hierarchy of specific test entities (e.g. JUnit entities).
When the document was created, it can be written to disk.
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit import Document
# Convert a TestsuiteSummary back to a Document
newXmlReport = Path("JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
# Write to XML file
try:
newDoc.Write()
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.AntJUnit4 import Document
# Convert a TestsuiteSummary back to a Document
newXmlReport = Path("JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
# Write to XML file
try:
newDoc.Write()
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.Catch2JUnit import Document
# Convert a TestsuiteSummary back to a Document
newXmlReport = Path("JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
# Write to XML file
try:
newDoc.Write()
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.CTestJUnit import Document
# Convert a TestsuiteSummary back to a Document
newXmlReport = Path("JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
# Write to XML file
try:
newDoc.Write()
except UnittestError as ex:
...
from pyEDAA.Reports.Unittesting.JUnit.GoJUnitReport import Document
# Convert a TestsuiteSummary back to a Document
newXmlReport = Path("JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
# Write to XML file
try:
newDoc.Write()
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.GoogleTestJUnit import Document
# Convert a TestsuiteSummary back to a Document
newXmlReport = Path("JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
# Write to XML file
try:
newDoc.Write()
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.NextestJUnit import Document
# Convert a TestsuiteSummary back to a Document
newXmlReport = Path("JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
# Write to XML file
try:
newDoc.Write()
except UnittestError as ex:
...
from pathlib import Path
from pyEDAA.Reports.Unittesting import UnittestError
from pyEDAA.Reports.Unittesting.JUnit.PyTestJUnit import Document
# Convert a TestsuiteSummary back to a Document
newXmlReport = Path("JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
# Write to XML file
try:
newDoc.Write()
except UnittestError as ex:
...
from pyEDAA.Reports.Unittesting.JUnit.TestLoggerJUnit import Document
# Convert a TestsuiteSummary back to a Document
newXmlReport = Path("JUnit-Report.xml")
newDoc = Document.FromTestsuiteSummary(newXmlReport, summary)
# Write to XML file
try:
newDoc.Write()
except UnittestError as ex:
...
Command Line Tool
pyedaa-reports unittest --input=Ant-JUnit:data/JUnit.xml
File Formats
Unittest summary reports can be stored in various file formats. Usually these files are XML based. Due to missing (clear) specifications and XML schema definitions, some file formats have developed dialects. Either because the specification was unclear/not existing or because the format was specific for a single programming language, so tools added extensions or misused XML attributes instead of designing their own file format.
Ant and JUnit 4 XML
The so-called JUnit XML format was defined by Ant when running JUnit4 test suites. Because the format was not specified by JUnit4, many dialects spread out. Many tools and test frameworks have minor or major differences compared to the original format. While some modifications seam logical additions or adjustments to the needs of the respective framework, others undermine the ideas and intents of the data format.
Many issues arise because the Ant + JUnit4 format is specific to unit testing with Java. Other languages and frameworks were lazy and didn’t derive their own format, but rather stuffed their language constructs into the concepts and limitations of the Ant + JUnit4 XML format.
JUnit Dialects
🚧 Bamboo JUnit (planned)
🚧 Jenkins JUnit (planned)
✅ JunitXml.TestLogger JUnit format for
dotnet test
JUnit 5 XML
JUnit5 uses a new format called Open Test Reporting (see the following section for details). This format isn’t specific to Java (packages, classes, methods, …), but describes a generic data model. Of course an extension for Java specifics is provided too.
The JUnit Platform writes this format only on request (junit.platform.reporting.open.xml.enabled=true). Build tools
like Gradle write Ant + JUnit4 XML for JUnit 5 and 6 tests by default, see
JUnit5.
Open Test Reporting
The Open Test Alliance created a new format called Open Test Reporting (OTR) to overcome the shortcomings of a missing file format for JUnit5 as well as the problems of Ant + JUnit4.
OTR defines a structure of test groups and tests, but no specifics of a certain programming language. The logical structure of tests and test groups is decoupled from language specifics like namespaces, packages or classes hosting the individual tests.
The JUnit Platform writes OTR’s event-based format into open-test-report.xml:
The root element
<e:events>holds an<infrastructure>element (host name, user name, operating system, Java version, …) and one event element per state change.<e:started>opens a container (test engine, class, nested class, parameterized test) or a test with anid, aparentIdand aname- the display name. Its<metadata>carries tags and JUnit’s unique ID, its<sources>the Java class or method.<e:reported>adds attachments while it runs:TestReporterentries and captured output on STDOUT and STDERR.<e:finished>closes it with a<result>of statusSUCCESSFUL,FAILED,ABORTED(a failed assumption) orSKIPPED(a disabled test), with the exception or the reason.Every event has a
timein UTC with nanoseconds, e.g.2026-10-08T10:56:04.172060276Z.
The event-based format is read by pyEDAA.Reports.Unittesting.OpenTestReporting.Events.Document and converted
into the unified data model, see Open Test Reporting Data Model. The file is validated against the
schemas, which Open Test Reporting publishes for each namespace.
OTR’s command line tool converts the event-based format into its hierarchical format (convert command):
<h:execution> holds the <infrastructure> and a <h:root> per test engine, which nests the containers and
tests as <h:child> elements, each with a start time and a duration. It’s read by
pyEDAA.Reports.Unittesting.OpenTestReporting.Hierarchy.Document into the same data model.
pyTooling Test Report
pyTooling provides a pytest plugin writing a test report in a format of
its own (pytest option --pytooling-xml=PATH). Unlike JUnit XML, test suites nest, and every test suite and test case
can carry a title, a summary and a description besides its name. The format is defined by an XML schema; its version is
part of the schema’s file name (e.g. TestReport-v0.1.xsd), which every report names in its
xsi:noNamespaceSchemaLocation attribute.
The report is read by pyEDAA.Reports.Unittesting.pyTooling.Document into the unified data model. It’s
validated against the schema it names, e.g. TestReport-v0.1.xsd.
Visual Studio Test Results (TRX)
Visual Studio and dotnet test (VSTest) write a test run’s results as TRX file (--logger trx): XML in namespace
http://microsoft.com/schemas/VisualStudio/TeamTest/2010 with root <TestRun>. Its children are:
<Times>Creation, start and finish of the test run.
<Results>A
<UnitTestResult>per test: name, duration, start and end time, and itsoutcome(e.g.Passed,Failed,NotExecuted).<Output>holds the test’s standard output and, in<ErrorInfo>, the message and stack trace of a failed test or the reason of a skipped one.<TestDefinitions>A
<UnitTest>per test, naming the assembly, the class and the method of a test in<TestMethod>. Each data row of a parameterized test is a test of its own.<TestEntries>,<TestLists>Link results and definitions by GUIDs.
<ResultSummary>The run’s outcome and
<Counters>(total,executed,passed,failed,notExecuted, …), the run’s output and the attachments of data collectors, e.g. code coverage reports.
There is no reader yet.
OSVVM YAML
The Open Source VHDL Verification Methodology (OSVVM) defines its own test report format in YAML. While OSVVM is able to convert its own YAML files to JUnit XML files, it’s recommended to use the YAML files as data source, because these contain additional information, which can’t be expressed with JUnit XML.
The YAML files are created when OSVVM-based testbenches are executed with OSVVM’s embedded TCL scripting environment OSVVM-Scripts.
Hint
YAML was chosen instead of JSON or XML, because a YAML document isn’t corrupted in case of a runtime error. The document might be incomplete (content), but not corrupted (structural). Such a scenario is possible if a VHDL simulator stops execution, then the document structure can’t be finalized.
Frameworks / Tools
cargo-nextest
Rust’s test harness (libtest, run by cargo test) writes JUnit XML only on a nightly compiler
(-Z unstable-options --format junit), one document per test binary on standard output. The test runner
cargo-nextest writes one JUnit XML report per run, if the profile in .config/nextest.toml names it, e.g.
[profile.ci.junit] with path = "junit.xml"; cargo nextest run --profile ci writes it to
target/nextest/ci/junit.xml.
The report has a <testsuites> root named nextest-run with a uuid attribute, and a <testsuite> per test
binary, named after the crate - counter for the unit tests in the library, counter::sequence for the
integration test tests/sequence.rs. A <testcase>’s name is the test’s path in its crate, e.g.
tests::increment, its classname the <testsuite>’s name. Each <testcase> carries its start time in a
timestamp attribute, and the test’s output in <system-out> and <system-err>. A failed assertion, a panic,
an Err returned by a test and a #[should_panic] test that doesn’t panic are each a <failure> of type
test failure with exit code 101. Its message is the first line of the test’s error output, e.g.
thread 'tests::failing' (554412) panicked at src/lib.rs:100:9 or Error: Underflow.
Unlike Ant JUnit4, a test marked #[ignore] isn’t in the report by default, the <testsuite> has neither
timestamp, time nor hostname, and the test cases are listed in the order they finished. Doc-tests aren’t
run by cargo-nextest. Except for uuid on <testsuites> and timestamp on <testcase>, the report is
Any JUnit. It is read by the
cargo-nextest JUnit dialect.
The example in examples/Rust/Cargo is run by the pipeline job Rust-Cargo. It measures the code coverage by
Rust’s source-based coverage (-C instrument-coverage) with cargo-llvm-cov, and writes it as LLVM JSON
(llvm-cov export format), lcov tracefile and Cobertura XML by cargo-llvm-cov, and as lcov tracefile, Cobertura XML
and covdir JSON by grcov.
Catch2
Catch2 (version 3) writes a report per reporter given on its command line, e.g.
--reporter JUnit::out=catch2-junit.xml --reporter XML::out=catch2.xml:
- JUnit reporter
A
<testsuites>root without attributes, holding one<testsuite>named after the test executable. Each test case and each path of nested sections (TestCase/Section/Subsection) is a<testcase>; itsclassnameis<executable>.global, or<executable>.<fixture class>for a test case of a fixture class.SKIP()writes a<skipped>element, an exception escaping a test case an<error>element. Unlike Ant JUnit4, the<testsuite>’stestsattribute counts assertions (skips included), not test cases,hostnameistbd, and each<testcase>carriesstatus="run". The report is read by the Catch2 JUnit dialect.- XML reporter
Catch2’s own format (root
<Catch2TestRun>,xml-format-version="3"): nested<TestCase>and<Section>elements with tags, source file and line, every failed expression and the result counts. It isn’t JUnit XML, and pyEDAA.Reports doesn’t read it yet.
The example in examples/Cpp/Catch2 is built and run by the pipeline job Cpp-Catch2. It measures the code
coverage of the tested library with GCC (--coverage) and writes it as gcov JSON (gcov --json-format) and as
lcov tracefile (lcov --capture).
CTest
dotnet test (VSTest)
dotnet test runs the tests of a .NET test project with VSTest, whatever test framework the tests are written in
(e.g. xUnit.net, NUnit, MSTest). Each logger given on its command line writes a report:
--logger trxA TRX file, Visual Studio’s own format.
--logger junit(NuGet packageJunitXml.TestLogger)A
<testsuites>root without attributes, holding one<testsuite>per test assembly, named after the assembly’s file (e.g.MyLibrary.Tests.dll). Each test is a<testcase>; itsclassnameis the test class, itsnamethe method, followed by the arguments of a parameterized test, e.g.Absolute(value: -4, expected: 5). The test cases follow in the order they finished, not grouped by class. The report is read by the JunitXml.TestLogger JUnit dialect. Compared to Ant + JUnit4, the logger writes:the additional attributes
idandpackageon<testsuite>. Only these keep the Any JUnit and pyTest JUnit dialects from reading the report.an exception escaping a test as
<failure>, never as<error>: VSTest knows failed tests only. The exception’s type and message are in themessageattribute, the stack trace is the element’s text.a skipped test as an empty
<skipped/>, without its reason. The TRX file keeps the reason.the test framework’s messages of the whole run into
<system-out>and<system-err>of the<testsuite>.
The example in examples/CSharp/xUnit - a class library and its xUnit.net test project - is built and run by
the pipeline job CSharp-xUnit. It writes both reports and measures the code coverage with coverlet and with
Microsoft’s code coverage collector (see .NET: coverlet, Microsoft’s code coverage collector, ReportGenerator).
Go (go test)
go test writes no JUnit XML: it prints text, or with -json a stream of JSON events (test2json), one object
per line. Converters translate it to JUnit XML:
- gotestsum
gotestsum --junitfile gotestsum.xml --jsonfile go-test.json -- ./...runsgo test -jsonand writes a<testsuites>root with a<testsuite>per package - also for a package without tests -, each with the propertygo.version. Each test, subtest (TestName/Subtest) and example is a<testcase>; a parent test is a test case of its own beside its subtests.classnameis the package’s import path. A failure and a panic write a<failure>with the test’s output,t.Skipa<skipped>with the test’s output in itsmessageattribute. Unlike Ant JUnit4, a<testsuite>has nohostnameanderrors, andskippedonly if a test was skipped.--junitfile-testsuite-nameand--junitfile-testcase-classnameshorten the import path to its last element (short) or to the path relative to the module (relative),--junitfile-project-namenames the<testsuites>. The report is Any JUnit.- go-junit-report
go-junit-report -parser gojson -in go-test.json -out go-junit-report.xmlconverts the JSON events - by defaultgo test -v’s text output. It writes the same tree, but a test’s log in the test case’s<system-out>, a panic’s stack trace in the package’s<system-out>, andid,hostnameandtimestamp- of the conversion - on each<testsuite>; a package without tests is a<testsuite>with an empty name. The report is read by the go-junit-report JUnit dialect.
A panic ends the package’s test binary: the package’s later tests don’t run, and its code coverage is lost.
The example in examples/Go/testing is built and run by the pipeline job Go-Test. It measures the code
coverage as Go cover profile and converts it to Cobertura XML and lcov tracefile, see
Go Cover Profile.
GoogleTest (gtest)
The pipeline job Cpp-GoogleTest runs the tests in examples/Cpp/GoogleTest directly and by CTest, writing
GoogleTest’s and CTest’s JUnit XML reports. The library is compiled with --coverage; gcov writes the
gcov JSON report of the direct run.
Gradle
Gradle’s test task writes one Ant + JUnit4 XML file per test class into build/test-results/test/, named
TEST-<class>.xml like Ant’s. The
Ant + JUnit4 dialect reads them. The example
examples/Java/Gradle-JUnit4 runs JUnit 4 tests by Gradle in the pipeline. Compared to Ant’s <junit> task,
Gradle writes:
an exception other than a failed assertion as
<failure>, never as<error>.errorsis always0.an empty
<properties/>element. Ant lists the JVM’s system properties and the build’s properties.timestampin UTC with milliseconds, e.g.2026-10-08T10:41:52.384Z. Ant writes the local time without fraction or time zone.an ignored test (
@Ignore) as an empty<skipped/>, without the reason, and a failed assumption as<skipped>withmessage,typeand the stack trace. Ant writes both as<skipped message="..."/>.the exception’s class name and message into
messageof<failure>, and the unfiltered stack trace as its text. Ant writes the exception’s message only and removes JUnit’s and Ant’s stack frames.
JUnit4
JUnit5
JUnit 5 and its successor JUnit 6 run tests on the JUnit Platform. The examples examples/Java/Gradle-JUnit5
and examples/Java/Gradle-JUnit6 run JUnit 5 and JUnit 6 tests by Gradle
in the pipeline and write both: Gradle’s Ant + JUnit4 XML files, read by the
Ant + JUnit4 dialect, and the JUnit Platform’s
Open Test Reporting file, read by
Document. Compared to JUnit 4 tests, Gradle’s files
differ:
The test suite is named by the test class’ display name (
@DisplayName), the file by the class’ name.A test case is named by its display name: the method name with its parameter types, e.g.
testReturnTrue(), or@DisplayName. An invocation of a parameterized test is named by its index and arguments only, e.g.[1] 5, 5, so the invocations of two parameterized tests of one class can have the same names.A nested test class (
@Nested) is a test suite in a file of its own, e.g.TEST-my.pack.MyClassTest$Divide.xml.TestReporterentries and, if the JUnit Platform captures the output, STDOUT and STDERR are<property>elements of the<testcase>.A disabled test (
@Disabled) is an empty<skipped/>, without the reason. A failed assumption is a<skipped>withmessage,typeand the stack trace.The JUnit Platform removes JUnit’s and Gradle’s stack frames calling the test method from stack traces; the JDK’s frames remain.
Compared to JUnit 5, JUnit 6 quotes the arguments in the display name of a parameterized test (
[1] "5", "5"instead of[1] 5, 5), and removes the JDK’s frames calling the test method and the assertion’s internal frames from stack traces too.
OSVVM
pytest
The pipeline job Python-pytest runs the tests in examples/Python/pytest and writes pytest’s JUnit XML
report, measured by coverage.py, which writes its JSON and
Cobertura XML reports.