Skip to content

Commit 9905a73

Browse files
authored
Merge pull request #4511 from jhunkeler/junit-strict
Toggle JUnit behavior with INI option
2 parents 51dd738 + 85c5fa9 commit 9905a73

File tree

4 files changed

+138
-105
lines changed

4 files changed

+138
-105
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ Jonas Obrist
118118
Jordan Guymon
119119
Jordan Moldow
120120
Jordan Speicher
121+
Joseph Hunkeler
121122
Joshua Bronson
122123
Jurko Gospodnetić
123124
Justyna Janczyszyn

changelog/3547.bugfix.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
``--junitxml`` can emit XML compatible with Jenkins xUnit.
2+
``junit_family`` INI option accepts ``legacy|xunit1``, which produces old style output, and ``xunit2`` that conforms more strictly to https://github.com/jenkinsci/xunit-plugin/blob/xunit-2.3.2/src/main/resources/org/jenkinsci/plugins/xunit/types/model/xsd/junit-10.xsd

src/_pytest/junitxml.py

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -66,12 +66,34 @@ def repl(matchobj):
6666
return py.xml.raw(illegal_xml_re.sub(repl, py.xml.escape(arg)))
6767

6868

69+
def merge_family(left, right):
70+
result = {}
71+
for kl, vl in left.items():
72+
for kr, vr in right.items():
73+
if not isinstance(vl, list):
74+
raise TypeError(type(vl))
75+
result[kl] = vl + vr
76+
left.update(result)
77+
78+
79+
families = {}
80+
families["_base"] = {"testcase": ["classname", "name"]}
81+
families["_base_legacy"] = {"testcase": ["file", "line", "url"]}
82+
83+
# xUnit 1.x inherits legacy attributes
84+
families["xunit1"] = families["_base"].copy()
85+
merge_family(families["xunit1"], families["_base_legacy"])
86+
87+
# xUnit 2.x uses strict base attributes
88+
families["xunit2"] = families["_base"]
89+
90+
6991
class _NodeReporter(object):
7092
def __init__(self, nodeid, xml):
71-
7293
self.id = nodeid
7394
self.xml = xml
7495
self.add_stats = self.xml.add_stats
96+
self.family = self.xml.family
7597
self.duration = 0
7698
self.properties = []
7799
self.nodes = []
@@ -119,8 +141,20 @@ def record_testreport(self, testreport):
119141
self.attrs = attrs
120142
self.attrs.update(existing_attrs) # restore any user-defined attributes
121143

144+
# Preserve legacy testcase behavior
145+
if self.family == "xunit1":
146+
return
147+
148+
# Filter out attributes not permitted by this test family.
149+
# Including custom attributes because they are not valid here.
150+
temp_attrs = {}
151+
for key in self.attrs.keys():
152+
if key in families[self.family]["testcase"]:
153+
temp_attrs[key] = self.attrs[key]
154+
self.attrs = temp_attrs
155+
122156
def to_xml(self):
123-
testcase = Junit.testcase(time=self.duration, **self.attrs)
157+
testcase = Junit.testcase(time="%.3f" % self.duration, **self.attrs)
124158
testcase.append(self.make_properties_node())
125159
for node in self.nodes:
126160
testcase.append(node)
@@ -269,16 +303,26 @@ def record_xml_attribute(request):
269303
from _pytest.warning_types import PytestWarning
270304

271305
request.node.warn(PytestWarning("record_xml_attribute is an experimental feature"))
306+
307+
# Declare noop
308+
def add_attr_noop(name, value):
309+
pass
310+
311+
attr_func = add_attr_noop
272312
xml = getattr(request.config, "_xml", None)
273-
if xml is not None:
274-
node_reporter = xml.node_reporter(request.node.nodeid)
275-
return node_reporter.add_attribute
276-
else:
277313

278-
def add_attr_noop(name, value):
279-
pass
314+
if xml is not None and xml.family != "xunit1":
315+
request.node.warn(
316+
PytestWarning(
317+
"record_xml_attribute is incompatible with junit_family: "
318+
"%s (use: legacy|xunit1)" % xml.family
319+
)
320+
)
321+
elif xml is not None:
322+
node_reporter = xml.node_reporter(request.node.nodeid)
323+
attr_func = node_reporter.add_attribute
280324

281-
return add_attr_noop
325+
return attr_func
282326

283327

284328
def pytest_addoption(parser):
@@ -315,6 +359,11 @@ def pytest_addoption(parser):
315359
"Duration time to report: one of total|call",
316360
default="total",
317361
) # choices=['total', 'call'])
362+
parser.addini(
363+
"junit_family",
364+
"Emit XML for schema: one of legacy|xunit1|xunit2",
365+
default="xunit1",
366+
)
318367

319368

320369
def pytest_configure(config):
@@ -327,6 +376,7 @@ def pytest_configure(config):
327376
config.getini("junit_suite_name"),
328377
config.getini("junit_logging"),
329378
config.getini("junit_duration_report"),
379+
config.getini("junit_family"),
330380
)
331381
config.pluginmanager.register(config._xml)
332382

@@ -361,13 +411,15 @@ def __init__(
361411
suite_name="pytest",
362412
logging="no",
363413
report_duration="total",
414+
family="xunit1",
364415
):
365416
logfile = os.path.expanduser(os.path.expandvars(logfile))
366417
self.logfile = os.path.normpath(os.path.abspath(logfile))
367418
self.prefix = prefix
368419
self.suite_name = suite_name
369420
self.logging = logging
370421
self.report_duration = report_duration
422+
self.family = family
371423
self.stats = dict.fromkeys(["error", "passed", "failure", "skipped"], 0)
372424
self.node_reporters = {} # nodeid -> _NodeReporter
373425
self.node_reporters_ordered = []
@@ -376,6 +428,10 @@ def __init__(
376428
self.open_reports = []
377429
self.cnt_double_fail_tests = 0
378430

431+
# Replaces convenience family with real family
432+
if self.family == "legacy":
433+
self.family = "xunit1"
434+
379435
def finalize(self, report):
380436
nodeid = getattr(report, "nodeid", report)
381437
# local hack to handle xdist report order
@@ -545,7 +601,7 @@ def pytest_sessionfinish(self):
545601
name=self.suite_name,
546602
errors=self.stats["error"],
547603
failures=self.stats["failure"],
548-
skips=self.stats["skipped"],
604+
skipped=self.stats["skipped"],
549605
tests=numtests,
550606
time="%.3f" % suite_time_delta,
551607
).unicode(indent=0)

0 commit comments

Comments
 (0)