Skip to content

Commit ef77869

Browse files
authored
Merge pull request #104 from testing-cabal/junitxml2subunit
Add junitxml2subunit script for JUnit XML test reports
2 parents a111907 + e4d7f2a commit ef77869

4 files changed

Lines changed: 566 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ where = ["python"]
6767
"subunit2junitxml" = "subunit.filter_scripts.subunit2junitxml:main"
6868
"subunit2pyunit" = "subunit.filter_scripts.subunit2pyunit:main"
6969
"gojson2subunit" = "subunit.filter_scripts.gojson2subunit:main"
70+
"junitxml2subunit" = "subunit.filter_scripts.junitxml2subunit:main"
7071
"tap2subunit" = "subunit.filter_scripts.tap2subunit:main"
7172

7273
[tool.setuptools.dynamic]

python/subunit/__init__.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,6 +1261,159 @@ def make_test_id(pkg, test):
12611261
return 1 if any_failed else 0
12621262

12631263

1264+
def JUnitXML2SubUnit(xml_files, output_stream):
1265+
"""Convert JUnit XML test reports to a subunit v2 byte stream.
1266+
1267+
Reads each path in ``xml_files`` (in the supplied order) and emits
1268+
one subunit packet pair per ``<testcase>`` element. The packet pair
1269+
is ``inprogress`` followed by the terminal status, with synthetic
1270+
timestamps spaced by the testcase's ``time`` attribute so consumers
1271+
can recover the recorded duration.
1272+
1273+
Test IDs are formed as ``<classname>::<name>`` from the testcase's
1274+
``classname`` and ``name`` attributes. Maven Surefire and Gradle
1275+
both populate ``classname`` with the fully-qualified Java class
1276+
(e.g. ``com.example.FooTest``), so the resulting ID is
1277+
``com.example.FooTest::testBar``.
1278+
1279+
Mapping rules:
1280+
* ``<failure>`` child → status ``fail`` (an assertion failure).
1281+
* ``<error>`` child → status ``fail`` (an unexpected exception);
1282+
subunit doesn't distinguish these and the JUnit author intent is
1283+
identical for our purposes (both mean "did not pass").
1284+
* ``<skipped>`` child → status ``skip``.
1285+
* Otherwise → status ``success``.
1286+
1287+
The body text and ``message``/``type`` attributes of the failure
1288+
element are folded into a single ``text/plain`` attachment on the
1289+
terminal packet, mirroring how ``GoJSON2SubUnit`` attaches captured
1290+
stdout to its results.
1291+
1292+
Per-class ``<system-out>`` / ``<system-err>`` blocks aren't attributed
1293+
to individual testcases by the JUnit schema (they cover the whole
1294+
suite). They're dropped — preserving them would require attaching
1295+
them to a synthetic suite-level packet, and most consumers don't
1296+
surface that.
1297+
1298+
:param xml_files: Iterable of file paths containing JUnit XML.
1299+
:param output_stream: A binary stream to write subunit v2 bytes to.
1300+
:return: 0 if no testcase failed or errored, 1 otherwise. Files that
1301+
fail to parse are reported on stderr and counted as a failure so
1302+
the broken XML doesn't get silently swallowed.
1303+
"""
1304+
import datetime
1305+
import xml.etree.ElementTree as ET
1306+
1307+
output = StreamResultToBytes(output_stream)
1308+
UTF8_TEXT = "text/plain; charset=UTF8"
1309+
any_failed = False
1310+
# Synthetic timestamps. We don't know when the JUnit run actually
1311+
# happened, but spacing the inprogress/terminal packets by each
1312+
# testcase's recorded `time` attribute lets consumers compute the
1313+
# right duration without making up wall-clock data.
1314+
clock = datetime.datetime(2000, 1, 1, tzinfo=datetime.timezone.utc)
1315+
1316+
def parse_time(value):
1317+
if value is None:
1318+
return 0.0
1319+
try:
1320+
return float(value)
1321+
except (TypeError, ValueError):
1322+
return 0.0
1323+
1324+
def iter_testsuites(root):
1325+
# JUnit XML files come in two shapes: a single ``<testsuite>``
1326+
# at the root, or a ``<testsuites>`` wrapper containing many.
1327+
if root.tag == "testsuite":
1328+
yield root
1329+
elif root.tag == "testsuites":
1330+
for ts in root.findall("testsuite"):
1331+
yield ts
1332+
# Anything else is silently ignored — a non-JUnit document.
1333+
1334+
for path in xml_files:
1335+
try:
1336+
tree = ET.parse(path)
1337+
except (OSError, ET.ParseError) as exc:
1338+
sys.stderr.write("JUnitXML2SubUnit: failed to parse {}: {}\n".format(path, exc))
1339+
any_failed = True
1340+
continue
1341+
1342+
root = tree.getroot()
1343+
for suite in iter_testsuites(root):
1344+
for case in suite.findall("testcase"):
1345+
classname = case.get("classname") or ""
1346+
name = case.get("name") or ""
1347+
if not name:
1348+
# Without a name there's no usable test_id; skip
1349+
# rather than emit a malformed ID.
1350+
continue
1351+
test_id = "{}::{}".format(classname, name) if classname else name
1352+
duration = parse_time(case.get("time"))
1353+
1354+
failure = case.find("failure")
1355+
error = case.find("error")
1356+
skipped = case.find("skipped")
1357+
1358+
if failure is not None or error is not None:
1359+
status = "fail"
1360+
detail = failure if failure is not None else error
1361+
file_bytes = _format_junit_detail(detail)
1362+
any_failed = True
1363+
elif skipped is not None:
1364+
status = "skip"
1365+
file_bytes = _format_junit_detail(skipped)
1366+
else:
1367+
status = "success"
1368+
file_bytes = None
1369+
1370+
start_ts = clock
1371+
end_ts = clock + datetime.timedelta(seconds=duration)
1372+
clock = end_ts
1373+
1374+
output.status(
1375+
test_id=test_id,
1376+
test_status="inprogress",
1377+
timestamp=start_ts,
1378+
)
1379+
output.status(
1380+
test_id=test_id,
1381+
test_status=status,
1382+
eof=True,
1383+
file_name="junit detail" if file_bytes else None,
1384+
file_bytes=file_bytes,
1385+
mime_type=UTF8_TEXT if file_bytes else None,
1386+
timestamp=end_ts,
1387+
)
1388+
1389+
return 1 if any_failed else 0
1390+
1391+
1392+
def _format_junit_detail(element):
1393+
"""Serialise a ``<failure>``/``<error>``/``<skipped>`` body to bytes.
1394+
1395+
JUnit elements carry the message and exception type as attributes and
1396+
the stack trace as element text. Combine both into a single
1397+
text/plain blob so the consumer sees everything on one packet. Returns
1398+
``None`` when there's nothing to attach (an empty ``<skipped/>``).
1399+
"""
1400+
parts = []
1401+
msg = element.get("message")
1402+
typ = element.get("type")
1403+
if typ and msg:
1404+
parts.append("{}: {}".format(typ, msg))
1405+
elif typ:
1406+
parts.append(typ)
1407+
elif msg:
1408+
parts.append(msg)
1409+
body = (element.text or "").strip()
1410+
if body:
1411+
parts.append(body)
1412+
if not parts:
1413+
return None
1414+
return ("\n".join(parts) + "\n").encode("utf-8")
1415+
1416+
12641417
def tag_stream(original, filtered, tags):
12651418
"""Alter tags on a stream.
12661419
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#!/usr/bin/env python3
2+
# subunit: extensions to python unittest to get test results from subprocesses.
3+
# Copyright (C) 2026 Jelmer Vernooij <jelmer@samba.org>
4+
#
5+
# Licensed under either the Apache License, Version 2.0 or the BSD 3-clause
6+
# license at the users choice. A copy of both licenses are available in the
7+
# project source as Apache-2.0 and BSD. You may not use this file except in
8+
# compliance with one of these two licences.
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under these licenses is distributed on an "AS IS" BASIS, WITHOUT
12+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
# license you chose for the specific language governing permissions and
14+
# limitations under that license.
15+
#
16+
17+
"""A filter that reads JUnit XML test reports and emits a subunit v2 stream.
18+
19+
JUnit XML is the de-facto interchange format for JVM test runners (Maven
20+
Surefire, Gradle, Ant) and many other ecosystems. Maven and Gradle write
21+
one XML file per test class into a reports directory, so this script
22+
accepts directories as well as individual files.
23+
24+
Typical use with Maven::
25+
26+
mvn clean test ; junitxml2subunit -d target/surefire-reports
27+
28+
Typical use with Gradle::
29+
30+
gradle clean test ; junitxml2subunit -d build/test-results/test
31+
"""
32+
33+
import argparse
34+
import os
35+
import sys
36+
37+
from subunit import JUnitXML2SubUnit
38+
39+
40+
def parse_args(argv):
41+
parser = argparse.ArgumentParser(
42+
description=(
43+
"Convert JUnit XML test reports to a subunit v2 stream on stdout. "
44+
"Pass individual files as positional arguments or use -d/--dir to "
45+
"walk a reports directory for *.xml files."
46+
),
47+
)
48+
parser.add_argument(
49+
"-d",
50+
"--dir",
51+
dest="dirs",
52+
action="append",
53+
default=[],
54+
metavar="DIR",
55+
help=(
56+
"Directory to walk for *.xml report files. May be repeated. "
57+
"Files inside the directory are converted in lexical order so "
58+
"the output is deterministic across runs."
59+
),
60+
)
61+
parser.add_argument(
62+
"files",
63+
nargs="*",
64+
help="Individual JUnit XML report files to convert.",
65+
)
66+
return parser.parse_args(argv)
67+
68+
69+
def collect_files(dirs, files):
70+
"""Combine `--dir DIR` walks with explicit FILE arguments.
71+
72+
Within each directory we sort by filename so the resulting subunit
73+
stream is reproducible. Across directories we preserve the user's
74+
argv order (some workflows feed multiple module-specific report
75+
directories and care about the suite ordering).
76+
"""
77+
out = []
78+
for d in dirs:
79+
if not os.path.isdir(d):
80+
sys.stderr.write("junitxml2subunit: not a directory: {}\n".format(d))
81+
continue
82+
for root, _dirs, names in sorted(os.walk(d)):
83+
for name in sorted(names):
84+
if name.endswith(".xml"):
85+
out.append(os.path.join(root, name))
86+
out.extend(files)
87+
return out
88+
89+
90+
def main(argv=None):
91+
args = parse_args(argv if argv is not None else sys.argv[1:])
92+
inputs = collect_files(args.dirs, args.files)
93+
if not inputs:
94+
sys.stderr.write("junitxml2subunit: no input files found (pass FILE arguments or use -d DIR)\n")
95+
return 2
96+
return JUnitXML2SubUnit(inputs, sys.stdout.buffer)
97+
98+
99+
if __name__ == "__main__":
100+
sys.exit(main())

0 commit comments

Comments
 (0)