From af2ee1e80a2e261754e16148960b0424000458ee Mon Sep 17 00:00:00 2001 From: Joseph Hunkeler Date: Sun, 2 Dec 2018 02:45:31 -0500 Subject: [PATCH 01/10] Emit JUnit compatible XML * Remove non-standard testcase elements: 'file' and 'line' * Replace testcase element 'skips' with 'skipped' * Time resolution uses the standard format: 0.000 * Tests use corrected XML output with proper attributes --- AUTHORS | 1 + changelog/3547.bugfix.rst | 1 + src/_pytest/junitxml.py | 12 +--- testing/test_junitxml.py | 126 ++++++++++---------------------------- 4 files changed, 36 insertions(+), 104 deletions(-) create mode 100644 changelog/3547.bugfix.rst diff --git a/AUTHORS b/AUTHORS index 06947d17b..ad185729b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -118,6 +118,7 @@ Jonas Obrist Jordan Guymon Jordan Moldow Jordan Speicher +Joseph Hunkeler Joshua Bronson Jurko Gospodnetić Justyna Janczyszyn diff --git a/changelog/3547.bugfix.rst b/changelog/3547.bugfix.rst new file mode 100644 index 000000000..2c20661d5 --- /dev/null +++ b/changelog/3547.bugfix.rst @@ -0,0 +1 @@ +``--junitxml`` emits XML data compatible with JUnit's offical schema releases. diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index a426f537b..6286e442a 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -107,20 +107,14 @@ class _NodeReporter(object): classnames = names[:-1] if self.xml.prefix: classnames.insert(0, self.xml.prefix) - attrs = { - "classname": ".".join(classnames), - "name": bin_xml_escape(names[-1]), - "file": testreport.location[0], - } - if testreport.location[1] is not None: - attrs["line"] = testreport.location[1] + attrs = {"classname": ".".join(classnames), "name": bin_xml_escape(names[-1])} if hasattr(testreport, "url"): attrs["url"] = testreport.url self.attrs = attrs self.attrs.update(existing_attrs) # restore any user-defined attributes def to_xml(self): - testcase = Junit.testcase(time=self.duration, **self.attrs) + testcase = Junit.testcase(time="%.3f" % self.duration, **self.attrs) testcase.append(self.make_properties_node()) for node in self.nodes: testcase.append(node) @@ -545,7 +539,7 @@ class LogXML(object): name=self.suite_name, errors=self.stats["error"], failures=self.stats["failure"], - skips=self.stats["skipped"], + skipped=self.stats["skipped"], tests=numtests, time="%.3f" % suite_time_delta, ).unicode(indent=0) diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 59c11fa00..1021c728b 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -107,7 +107,7 @@ class TestPython(object): result, dom = runandparse(testdir) assert result.ret node = dom.find_first_by_tag("testsuite") - node.assert_attr(name="pytest", errors=0, failures=1, skips=2, tests=5) + node.assert_attr(name="pytest", errors=0, failures=1, skipped=2, tests=5) def test_summing_simple_with_errors(self, testdir): testdir.makepyfile( @@ -133,7 +133,7 @@ class TestPython(object): result, dom = runandparse(testdir) assert result.ret node = dom.find_first_by_tag("testsuite") - node.assert_attr(name="pytest", errors=1, failures=2, skips=1, tests=5) + node.assert_attr(name="pytest", errors=1, failures=2, skipped=1, tests=5) def test_timing_function(self, testdir): testdir.makepyfile( @@ -201,12 +201,7 @@ class TestPython(object): node = dom.find_first_by_tag("testsuite") node.assert_attr(errors=1, tests=1) tnode = node.find_first_by_tag("testcase") - tnode.assert_attr( - file="test_setup_error.py", - line="5", - classname="test_setup_error", - name="test_function", - ) + tnode.assert_attr(classname="test_setup_error", name="test_function") fnode = tnode.find_first_by_tag("error") fnode.assert_attr(message="test setup failure") assert "ValueError" in fnode.toxml() @@ -228,12 +223,7 @@ class TestPython(object): assert result.ret node = dom.find_first_by_tag("testsuite") tnode = node.find_first_by_tag("testcase") - tnode.assert_attr( - file="test_teardown_error.py", - line="6", - classname="test_teardown_error", - name="test_function", - ) + tnode.assert_attr(classname="test_teardown_error", name="test_function") fnode = tnode.find_first_by_tag("error") fnode.assert_attr(message="test teardown failure") assert "ValueError" in fnode.toxml() @@ -274,14 +264,9 @@ class TestPython(object): result, dom = runandparse(testdir) assert result.ret == 0 node = dom.find_first_by_tag("testsuite") - node.assert_attr(skips=1) + node.assert_attr(skipped=1) tnode = node.find_first_by_tag("testcase") - tnode.assert_attr( - file="test_skip_contains_name_reason.py", - line="1", - classname="test_skip_contains_name_reason", - name="test_skip", - ) + tnode.assert_attr(classname="test_skip_contains_name_reason", name="test_skip") snode = tnode.find_first_by_tag("skipped") snode.assert_attr(type="pytest.skip", message="hello23") @@ -297,13 +282,10 @@ class TestPython(object): result, dom = runandparse(testdir) assert result.ret == 0 node = dom.find_first_by_tag("testsuite") - node.assert_attr(skips=1) + node.assert_attr(skipped=1) tnode = node.find_first_by_tag("testcase") tnode.assert_attr( - file="test_mark_skip_contains_name_reason.py", - line="1", - classname="test_mark_skip_contains_name_reason", - name="test_skip", + classname="test_mark_skip_contains_name_reason", name="test_skip" ) snode = tnode.find_first_by_tag("skipped") snode.assert_attr(type="pytest.skip", message="hello24") @@ -321,13 +303,10 @@ class TestPython(object): result, dom = runandparse(testdir) assert result.ret == 0 node = dom.find_first_by_tag("testsuite") - node.assert_attr(skips=1) + node.assert_attr(skipped=1) tnode = node.find_first_by_tag("testcase") tnode.assert_attr( - file="test_mark_skipif_contains_name_reason.py", - line="2", - classname="test_mark_skipif_contains_name_reason", - name="test_skip", + classname="test_mark_skipif_contains_name_reason", name="test_skip" ) snode = tnode.find_first_by_tag("skipped") snode.assert_attr(type="pytest.skip", message="hello25") @@ -360,10 +339,7 @@ class TestPython(object): node.assert_attr(failures=1) tnode = node.find_first_by_tag("testcase") tnode.assert_attr( - file="test_classname_instance.py", - line="1", - classname="test_classname_instance.TestClass", - name="test_method", + classname="test_classname_instance.TestClass", name="test_method" ) def test_classname_nested_dir(self, testdir): @@ -374,12 +350,7 @@ class TestPython(object): node = dom.find_first_by_tag("testsuite") node.assert_attr(failures=1) tnode = node.find_first_by_tag("testcase") - tnode.assert_attr( - file=os.path.join("sub", "test_hello.py"), - line="0", - classname="sub.test_hello", - name="test_func", - ) + tnode.assert_attr(classname="sub.test_hello", name="test_func") def test_internal_error(self, testdir): testdir.makeconftest("def pytest_runtest_protocol(): 0 / 0") @@ -415,12 +386,7 @@ class TestPython(object): node = dom.find_first_by_tag("testsuite") node.assert_attr(failures=1, tests=1) tnode = node.find_first_by_tag("testcase") - tnode.assert_attr( - file="test_failure_function.py", - line="3", - classname="test_failure_function", - name="test_fail", - ) + tnode.assert_attr(classname="test_failure_function", name="test_fail") fnode = tnode.find_first_by_tag("failure") fnode.assert_attr(message="ValueError: 42") assert "ValueError" in fnode.toxml() @@ -477,10 +443,7 @@ class TestPython(object): tnode = node.find_nth_by_tag("testcase", index) tnode.assert_attr( - file="test_failure_escape.py", - line="1", - classname="test_failure_escape", - name="test_func[%s]" % char, + classname="test_failure_escape", name="test_func[%s]" % char ) sysout = tnode.find_first_by_tag("system-out") text = sysout.text @@ -501,18 +464,10 @@ class TestPython(object): node = dom.find_first_by_tag("testsuite") node.assert_attr(failures=1, tests=2) tnode = node.find_first_by_tag("testcase") - tnode.assert_attr( - file="test_junit_prefixing.py", - line="0", - classname="xyz.test_junit_prefixing", - name="test_func", - ) + tnode.assert_attr(classname="xyz.test_junit_prefixing", name="test_func") tnode = node.find_nth_by_tag("testcase", 1) tnode.assert_attr( - file="test_junit_prefixing.py", - line="3", - classname="xyz.test_junit_prefixing.TestHello", - name="test_hello", + classname="xyz.test_junit_prefixing.TestHello", name="test_hello" ) def test_xfailure_function(self, testdir): @@ -526,14 +481,9 @@ class TestPython(object): result, dom = runandparse(testdir) assert not result.ret node = dom.find_first_by_tag("testsuite") - node.assert_attr(skips=1, tests=1) + node.assert_attr(skipped=1, tests=1) tnode = node.find_first_by_tag("testcase") - tnode.assert_attr( - file="test_xfailure_function.py", - line="1", - classname="test_xfailure_function", - name="test_xfail", - ) + tnode.assert_attr(classname="test_xfailure_function", name="test_xfail") fnode = tnode.find_first_by_tag("skipped") fnode.assert_attr(message="expected test failure") # assert "ValueError" in fnode.toxml() @@ -569,14 +519,9 @@ class TestPython(object): result, dom = runandparse(testdir) # assert result.ret node = dom.find_first_by_tag("testsuite") - node.assert_attr(skips=0, tests=1) + node.assert_attr(skipped=0, tests=1) tnode = node.find_first_by_tag("testcase") - tnode.assert_attr( - file="test_xfailure_xpass.py", - line="1", - classname="test_xfailure_xpass", - name="test_xpass", - ) + tnode.assert_attr(classname="test_xfailure_xpass", name="test_xpass") def test_xfailure_xpass_strict(self, testdir): testdir.makepyfile( @@ -590,14 +535,9 @@ class TestPython(object): result, dom = runandparse(testdir) # assert result.ret node = dom.find_first_by_tag("testsuite") - node.assert_attr(skips=0, tests=1) + node.assert_attr(skipped=0, tests=1) tnode = node.find_first_by_tag("testcase") - tnode.assert_attr( - file="test_xfailure_xpass_strict.py", - line="1", - classname="test_xfailure_xpass_strict", - name="test_xpass", - ) + tnode.assert_attr(classname="test_xfailure_xpass_strict", name="test_xpass") fnode = tnode.find_first_by_tag("failure") fnode.assert_attr(message="[XPASS(strict)] This needs to fail!") @@ -608,8 +548,6 @@ class TestPython(object): node = dom.find_first_by_tag("testsuite") node.assert_attr(errors=1, tests=1) tnode = node.find_first_by_tag("testcase") - tnode.assert_attr(file="test_collect_error.py", name="test_collect_error") - assert tnode["line"] is None fnode = tnode.find_first_by_tag("error") fnode.assert_attr(message="collection failure") assert "SyntaxError" in fnode.toxml() @@ -792,7 +730,7 @@ class TestNonPython(object): result, dom = runandparse(testdir) assert result.ret node = dom.find_first_by_tag("testsuite") - node.assert_attr(errors=0, failures=1, skips=0, tests=1) + node.assert_attr(errors=0, failures=1, skipped=0, tests=1) tnode = node.find_first_by_tag("testcase") tnode.assert_attr(name="myfile.xyz") fnode = tnode.find_first_by_tag("failure") @@ -1155,20 +1093,18 @@ def test_fancy_items_regression(testdir): assert "INTERNALERROR" not in result.stdout.str() - items = sorted( - "%(classname)s %(name)s %(file)s" % x for x in dom.find_by_tag("testcase") - ) + items = sorted("%(classname)s %(name)s" % x for x in dom.find_by_tag("testcase")) import pprint pprint.pprint(items) assert items == [ - u"conftest a conftest.py", - u"conftest a conftest.py", - u"conftest b conftest.py", - u"test_fancy_items_regression a test_fancy_items_regression.py", - u"test_fancy_items_regression a test_fancy_items_regression.py", - u"test_fancy_items_regression b test_fancy_items_regression.py", - u"test_fancy_items_regression test_pass" u" test_fancy_items_regression.py", + u"conftest a", + u"conftest a", + u"conftest b", + u"test_fancy_items_regression a", + u"test_fancy_items_regression a", + u"test_fancy_items_regression b", + u"test_fancy_items_regression test_pass", ] From 2e551c32b6fd42352c2ef20d324b7a91955c9af6 Mon Sep 17 00:00:00 2001 From: Joseph Hunkeler Date: Mon, 3 Dec 2018 09:56:21 -0500 Subject: [PATCH 02/10] Add junit_family config option --- src/_pytest/junitxml.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 6286e442a..80ca92471 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -309,6 +309,9 @@ def pytest_addoption(parser): "Duration time to report: one of total|call", default="total", ) # choices=['total', 'call']) + parser.addini( + "junit_family", "Emit XML for schema: one of old|xunit1|xunit2", default="old" + ) def pytest_configure(config): @@ -321,6 +324,7 @@ def pytest_configure(config): config.getini("junit_suite_name"), config.getini("junit_logging"), config.getini("junit_duration_report"), + config.getini("junit_family"), ) config.pluginmanager.register(config._xml) From 335cc5d65185a2823528ecf95e908002e544b4f2 Mon Sep 17 00:00:00 2001 From: Joseph Hunkeler Date: Mon, 3 Dec 2018 17:21:11 -0500 Subject: [PATCH 03/10] Handle backwards-compatiblity --- src/_pytest/junitxml.py | 64 ++++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 80ca92471..62e8a772b 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -66,12 +66,37 @@ def bin_xml_escape(arg): return py.xml.raw(illegal_xml_re.sub(repl, py.xml.escape(arg))) +def merge_family(left, right): + result = {} + for kl, vl in left.items(): + for kr, vr in right.items(): + if not isinstance(vl, list): + raise NotImplementedError(type(vl)) + result[kl] = vl + vr + left.update(result) + + +families = {} +families["_base"] = {"testcase": ["classname", "name"]} +families["_base_old"] = {"testcase": ["file", "line", "url"]} + +# xUnit 1.x inherits old attributes +families["xunit1"] = families["_base"].copy() +merge_family(families["xunit1"], families["_base_old"]) + +# Alias "old" to xUnit 1.x +families["old"] = families["xunit1"] + +# xUnit 2.x uses strict base attributes +families["xunit2"] = families["_base"] + + class _NodeReporter(object): def __init__(self, nodeid, xml): - self.id = nodeid self.xml = xml self.add_stats = self.xml.add_stats + self.family = self.xml.family self.duration = 0 self.properties = [] self.nodes = [] @@ -107,12 +132,31 @@ class _NodeReporter(object): classnames = names[:-1] if self.xml.prefix: classnames.insert(0, self.xml.prefix) - attrs = {"classname": ".".join(classnames), "name": bin_xml_escape(names[-1])} + attrs = { + "classname": ".".join(classnames), + "name": bin_xml_escape(names[-1]), + "file": testreport.location[0], + } + if testreport.location[1] is not None: + attrs["line"] = testreport.location[1] if hasattr(testreport, "url"): attrs["url"] = testreport.url self.attrs = attrs self.attrs.update(existing_attrs) # restore any user-defined attributes + # Preserve old testcase behavior + if self.family == "old": + return + + # Purge attributes not permitted by this test family + # This includes custom attributes, because they are not valid here. + # TODO: Convert invalid attributes to properties to preserve "something" + temp_attrs = {} + for key in self.attrs.keys(): + if key in families[self.family]["testcase"]: + temp_attrs[key] = self.attrs[key] + self.attrs = temp_attrs + def to_xml(self): testcase = Junit.testcase(time="%.3f" % self.duration, **self.attrs) testcase.append(self.make_properties_node()) @@ -231,7 +275,7 @@ class _NodeReporter(object): def finalize(self): data = self.to_xml().unicode(indent=0) self.__dict__.clear() - self.to_xml = lambda: py.xml.raw(data) + self.raw = lambda: py.xml.raw(data) @pytest.fixture @@ -353,12 +397,7 @@ def mangle_test_address(address): class LogXML(object): def __init__( - self, - logfile, - prefix, - suite_name="pytest", - logging="no", - report_duration="total", + self, logfile, prefix, suite_name="pytest", logging="no", report_duration="total", family="old" ): logfile = os.path.expanduser(os.path.expandvars(logfile)) self.logfile = os.path.normpath(os.path.abspath(logfile)) @@ -366,6 +405,7 @@ class LogXML(object): self.suite_name = suite_name self.logging = logging self.report_duration = report_duration + self.family = family self.stats = dict.fromkeys(["error", "passed", "failure", "skipped"], 0) self.node_reporters = {} # nodeid -> _NodeReporter self.node_reporters_ordered = [] @@ -539,7 +579,7 @@ class LogXML(object): logfile.write( Junit.testsuite( self._get_global_properties_node(), - [x.to_xml() for x in self.node_reporters_ordered], + [x.raw() for x in self.node_reporters_ordered], name=self.suite_name, errors=self.stats["error"], failures=self.stats["failure"], @@ -550,6 +590,10 @@ class LogXML(object): ) logfile.close() + # TODO: GET RID OF + with open(self.logfile) as logfile: + print(logfile.read()) + def pytest_terminal_summary(self, terminalreporter): terminalreporter.write_sep("-", "generated xml file: %s" % (self.logfile)) From 343430c537a8a7f933f74ff17109c3f7072432f5 Mon Sep 17 00:00:00 2001 From: Joseph Hunkeler Date: Mon, 14 Jan 2019 10:27:47 -0500 Subject: [PATCH 04/10] Replace family "old" with "legacy" --- src/_pytest/junitxml.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 62e8a772b..aabce4734 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -78,14 +78,14 @@ def merge_family(left, right): families = {} families["_base"] = {"testcase": ["classname", "name"]} -families["_base_old"] = {"testcase": ["file", "line", "url"]} +families["_base_legacy"] = {"testcase": ["file", "line", "url"]} -# xUnit 1.x inherits old attributes +# xUnit 1.x inherits legacy attributes families["xunit1"] = families["_base"].copy() -merge_family(families["xunit1"], families["_base_old"]) +merge_family(families["xunit1"], families["_base_legacy"]) -# Alias "old" to xUnit 1.x -families["old"] = families["xunit1"] +# Alias "legacy" to xUnit 1.x +families["legacy"] = families["xunit1"] # xUnit 2.x uses strict base attributes families["xunit2"] = families["_base"] @@ -144,8 +144,8 @@ class _NodeReporter(object): self.attrs = attrs self.attrs.update(existing_attrs) # restore any user-defined attributes - # Preserve old testcase behavior - if self.family == "old": + # Preserve legacy testcase behavior + if self.family == "legacy": return # Purge attributes not permitted by this test family @@ -354,7 +354,9 @@ def pytest_addoption(parser): default="total", ) # choices=['total', 'call']) parser.addini( - "junit_family", "Emit XML for schema: one of old|xunit1|xunit2", default="old" + "junit_family", + "Emit XML for schema: one of legacy|xunit1|xunit2", + default="legacy", ) @@ -397,7 +399,13 @@ def mangle_test_address(address): class LogXML(object): def __init__( - self, logfile, prefix, suite_name="pytest", logging="no", report_duration="total", family="old" + self, + logfile, + prefix, + suite_name="pytest", + logging="no", + report_duration="total", + family="legacy", ): logfile = os.path.expanduser(os.path.expandvars(logfile)) self.logfile = os.path.normpath(os.path.abspath(logfile)) From 8937e39afde63016c24bc32b6569a478358c8a57 Mon Sep 17 00:00:00 2001 From: Joseph Hunkeler Date: Mon, 14 Jan 2019 10:34:03 -0500 Subject: [PATCH 05/10] Raise TypeError instead of NotImplementedError if not list type --- src/_pytest/junitxml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index aabce4734..6a02da406 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -71,7 +71,7 @@ def merge_family(left, right): for kl, vl in left.items(): for kr, vr in right.items(): if not isinstance(vl, list): - raise NotImplementedError(type(vl)) + raise TypeError(type(vl)) result[kl] = vl + vr left.update(result) From aaa7d36bc9ffca4970fa779922763e3db402614f Mon Sep 17 00:00:00 2001 From: Joseph Hunkeler Date: Mon, 14 Jan 2019 13:14:44 -0500 Subject: [PATCH 06/10] Change family behavior: * "legacy" is no longer a copy of "xunit1" * Attempts to use "legacy" will redirect to "xunit1" * record_xml_attribute is not compatible outside of legacy family * Replace call to method/override raw() with to_xml() --- src/_pytest/junitxml.py | 45 ++++++++++++++++++++++++----------------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 6a02da406..115067b70 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -84,9 +84,6 @@ families["_base_legacy"] = {"testcase": ["file", "line", "url"]} families["xunit1"] = families["_base"].copy() merge_family(families["xunit1"], families["_base_legacy"]) -# Alias "legacy" to xUnit 1.x -families["legacy"] = families["xunit1"] - # xUnit 2.x uses strict base attributes families["xunit2"] = families["_base"] @@ -145,7 +142,7 @@ class _NodeReporter(object): self.attrs.update(existing_attrs) # restore any user-defined attributes # Preserve legacy testcase behavior - if self.family == "legacy": + if self.family == "xunit1": return # Purge attributes not permitted by this test family @@ -275,7 +272,7 @@ class _NodeReporter(object): def finalize(self): data = self.to_xml().unicode(indent=0) self.__dict__.clear() - self.raw = lambda: py.xml.raw(data) + self.to_xml = lambda: py.xml.raw(data) @pytest.fixture @@ -307,16 +304,26 @@ def record_xml_attribute(request): from _pytest.warning_types import PytestWarning request.node.warn(PytestWarning("record_xml_attribute is an experimental feature")) + + # Declare noop + def add_attr_noop(name, value): + pass + + attr_func = add_attr_noop xml = getattr(request.config, "_xml", None) - if xml is not None: + + if xml.family != "xunit1": + request.node.warn( + PytestWarning( + "record_xml_attribute is incompatible with junit_family: " + "%s (use: legacy|xunit1)" % xml.family + ) + ) + elif xml is not None: node_reporter = xml.node_reporter(request.node.nodeid) - return node_reporter.add_attribute - else: + attr_func = node_reporter.add_attribute - def add_attr_noop(name, value): - pass - - return add_attr_noop + return attr_func def pytest_addoption(parser): @@ -356,7 +363,7 @@ def pytest_addoption(parser): parser.addini( "junit_family", "Emit XML for schema: one of legacy|xunit1|xunit2", - default="legacy", + default="xunit1", ) @@ -405,7 +412,7 @@ class LogXML(object): suite_name="pytest", logging="no", report_duration="total", - family="legacy", + family="xunit1", ): logfile = os.path.expanduser(os.path.expandvars(logfile)) self.logfile = os.path.normpath(os.path.abspath(logfile)) @@ -422,6 +429,10 @@ class LogXML(object): self.open_reports = [] self.cnt_double_fail_tests = 0 + # Replaces convenience family with real family + if self.family == "legacy": + self.family = "xunit1" + def finalize(self, report): nodeid = getattr(report, "nodeid", report) # local hack to handle xdist report order @@ -587,7 +598,7 @@ class LogXML(object): logfile.write( Junit.testsuite( self._get_global_properties_node(), - [x.raw() for x in self.node_reporters_ordered], + [x.to_xml() for x in self.node_reporters_ordered], name=self.suite_name, errors=self.stats["error"], failures=self.stats["failure"], @@ -598,10 +609,6 @@ class LogXML(object): ) logfile.close() - # TODO: GET RID OF - with open(self.logfile) as logfile: - print(logfile.read()) - def pytest_terminal_summary(self, terminalreporter): terminalreporter.write_sep("-", "generated xml file: %s" % (self.logfile)) From 4ecf29380abafd27a01503ed1335d36bc350acf6 Mon Sep 17 00:00:00 2001 From: Joseph Hunkeler Date: Mon, 14 Jan 2019 13:21:06 -0500 Subject: [PATCH 07/10] Adds xunit2 version of test_record_attribute --- testing/test_junitxml.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/testing/test_junitxml.py b/testing/test_junitxml.py index 1021c728b..2765dfc60 100644 --- a/testing/test_junitxml.py +++ b/testing/test_junitxml.py @@ -980,6 +980,12 @@ def test_record_property_same_name(testdir): @pytest.mark.filterwarnings("default") def test_record_attribute(testdir): + testdir.makeini( + """ + [pytest] + junit_family = xunit1 + """ + ) testdir.makepyfile( """ import pytest @@ -1001,6 +1007,38 @@ def test_record_attribute(testdir): ) +@pytest.mark.filterwarnings("default") +def test_record_attribute_xunit2(testdir): + """Ensure record_xml_attribute drops values when outside of legacy family + """ + testdir.makeini( + """ + [pytest] + junit_family = xunit2 + """ + ) + testdir.makepyfile( + """ + import pytest + + @pytest.fixture + def other(record_xml_attribute): + record_xml_attribute("bar", 1) + def test_record(record_xml_attribute, other): + record_xml_attribute("foo", "<1"); + """ + ) + + result, dom = runandparse(testdir, "-rw") + result.stdout.fnmatch_lines( + [ + "*test_record_attribute_xunit2.py:6:*record_xml_attribute is an experimental feature", + "*test_record_attribute_xunit2.py:6:*record_xml_attribute is incompatible with " + "junit_family: xunit2 (use: legacy|xunit1)", + ] + ) + + def test_random_report_log_xdist(testdir, monkeypatch): """xdist calls pytest_runtest_logreport as they are executed by the slaves, with nodes from several nodes overlapping, so junitxml must cope with that From bcacc40775460306ca16738012fbb3196accb98e Mon Sep 17 00:00:00 2001 From: Joseph Hunkeler Date: Mon, 14 Jan 2019 14:24:10 -0500 Subject: [PATCH 08/10] Update comment text --- changelog/3547.bugfix.rst | 2 +- src/_pytest/junitxml.py | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/changelog/3547.bugfix.rst b/changelog/3547.bugfix.rst index 2c20661d5..b53e49c5c 100644 --- a/changelog/3547.bugfix.rst +++ b/changelog/3547.bugfix.rst @@ -1 +1 @@ -``--junitxml`` emits XML data compatible with JUnit's offical schema releases. +``--junitxml`` emits XML data compatible with Jenkins xUnit schemas via ``junit_family`` INI config option diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 115067b70..96755cf5b 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -145,9 +145,8 @@ class _NodeReporter(object): if self.family == "xunit1": return - # Purge attributes not permitted by this test family - # This includes custom attributes, because they are not valid here. - # TODO: Convert invalid attributes to properties to preserve "something" + # Filter out attributes not permitted by this test family. + # Including custom attributes because they are not valid here. temp_attrs = {} for key in self.attrs.keys(): if key in families[self.family]["testcase"]: From 8967976443f6eb13f037e031b015b1764006033c Mon Sep 17 00:00:00 2001 From: Joseph Hunkeler Date: Mon, 14 Jan 2019 14:38:58 -0500 Subject: [PATCH 09/10] Ensure xml object is viable before testing family type --- src/_pytest/junitxml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_pytest/junitxml.py b/src/_pytest/junitxml.py index 96755cf5b..e2e1d43bb 100644 --- a/src/_pytest/junitxml.py +++ b/src/_pytest/junitxml.py @@ -311,7 +311,7 @@ def record_xml_attribute(request): attr_func = add_attr_noop xml = getattr(request.config, "_xml", None) - if xml.family != "xunit1": + if xml is not None and xml.family != "xunit1": request.node.warn( PytestWarning( "record_xml_attribute is incompatible with junit_family: " From 85c5fa9f64dbdae04729b38ca495eb38d4bb17cd Mon Sep 17 00:00:00 2001 From: Joseph Hunkeler Date: Mon, 14 Jan 2019 15:07:40 -0500 Subject: [PATCH 10/10] Update changelog --- changelog/3547.bugfix.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/changelog/3547.bugfix.rst b/changelog/3547.bugfix.rst index b53e49c5c..b2ec00364 100644 --- a/changelog/3547.bugfix.rst +++ b/changelog/3547.bugfix.rst @@ -1 +1,2 @@ -``--junitxml`` emits XML data compatible with Jenkins xUnit schemas via ``junit_family`` INI config option +``--junitxml`` can emit XML compatible with Jenkins xUnit. +``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