junitxml.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700
  1. """Report test results in JUnit-XML format, for use with Jenkins and build
  2. integration servers.
  3. Based on initial code from Ross Lawley.
  4. Output conforms to
  5. https://github.com/jenkinsci/xunit-plugin/blob/master/src/main/resources/org/jenkinsci/plugins/xunit/types/model/xsd/junit-10.xsd
  6. """
  7. import functools
  8. import os
  9. import platform
  10. import re
  11. import xml.etree.ElementTree as ET
  12. from datetime import datetime
  13. from typing import Callable
  14. from typing import Dict
  15. from typing import List
  16. from typing import Match
  17. from typing import Optional
  18. from typing import Tuple
  19. from typing import Union
  20. import pytest
  21. from _pytest import nodes
  22. from _pytest import timing
  23. from _pytest._code.code import ExceptionRepr
  24. from _pytest._code.code import ReprFileLocation
  25. from _pytest.config import Config
  26. from _pytest.config import filename_arg
  27. from _pytest.config.argparsing import Parser
  28. from _pytest.fixtures import FixtureRequest
  29. from _pytest.reports import TestReport
  30. from _pytest.stash import StashKey
  31. from _pytest.terminal import TerminalReporter
  32. xml_key = StashKey["LogXML"]()
  33. def bin_xml_escape(arg: object) -> str:
  34. r"""Visually escape invalid XML characters.
  35. For example, transforms
  36. 'hello\aworld\b'
  37. into
  38. 'hello#x07world#x08'
  39. Note that the #xABs are *not* XML escapes - missing the ampersand &#xAB.
  40. The idea is to escape visually for the user rather than for XML itself.
  41. """
  42. def repl(matchobj: Match[str]) -> str:
  43. i = ord(matchobj.group())
  44. if i <= 0xFF:
  45. return "#x%02X" % i
  46. else:
  47. return "#x%04X" % i
  48. # The spec range of valid chars is:
  49. # Char ::= #x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]
  50. # For an unknown(?) reason, we disallow #x7F (DEL) as well.
  51. illegal_xml_re = (
  52. "[^\u0009\u000A\u000D\u0020-\u007E\u0080-\uD7FF\uE000-\uFFFD\u10000-\u10FFFF]"
  53. )
  54. return re.sub(illegal_xml_re, repl, str(arg))
  55. def merge_family(left, right) -> None:
  56. result = {}
  57. for kl, vl in left.items():
  58. for kr, vr in right.items():
  59. if not isinstance(vl, list):
  60. raise TypeError(type(vl))
  61. result[kl] = vl + vr
  62. left.update(result)
  63. families = {}
  64. families["_base"] = {"testcase": ["classname", "name"]}
  65. families["_base_legacy"] = {"testcase": ["file", "line", "url"]}
  66. # xUnit 1.x inherits legacy attributes.
  67. families["xunit1"] = families["_base"].copy()
  68. merge_family(families["xunit1"], families["_base_legacy"])
  69. # xUnit 2.x uses strict base attributes.
  70. families["xunit2"] = families["_base"]
  71. class _NodeReporter:
  72. def __init__(self, nodeid: Union[str, TestReport], xml: "LogXML") -> None:
  73. self.id = nodeid
  74. self.xml = xml
  75. self.add_stats = self.xml.add_stats
  76. self.family = self.xml.family
  77. self.duration = 0.0
  78. self.properties: List[Tuple[str, str]] = []
  79. self.nodes: List[ET.Element] = []
  80. self.attrs: Dict[str, str] = {}
  81. def append(self, node: ET.Element) -> None:
  82. self.xml.add_stats(node.tag)
  83. self.nodes.append(node)
  84. def add_property(self, name: str, value: object) -> None:
  85. self.properties.append((str(name), bin_xml_escape(value)))
  86. def add_attribute(self, name: str, value: object) -> None:
  87. self.attrs[str(name)] = bin_xml_escape(value)
  88. def make_properties_node(self) -> Optional[ET.Element]:
  89. """Return a Junit node containing custom properties, if any."""
  90. if self.properties:
  91. properties = ET.Element("properties")
  92. for name, value in self.properties:
  93. properties.append(ET.Element("property", name=name, value=value))
  94. return properties
  95. return None
  96. def record_testreport(self, testreport: TestReport) -> None:
  97. names = mangle_test_address(testreport.nodeid)
  98. existing_attrs = self.attrs
  99. classnames = names[:-1]
  100. if self.xml.prefix:
  101. classnames.insert(0, self.xml.prefix)
  102. attrs: Dict[str, str] = {
  103. "classname": ".".join(classnames),
  104. "name": bin_xml_escape(names[-1]),
  105. "file": testreport.location[0],
  106. }
  107. if testreport.location[1] is not None:
  108. attrs["line"] = str(testreport.location[1])
  109. if hasattr(testreport, "url"):
  110. attrs["url"] = testreport.url
  111. self.attrs = attrs
  112. self.attrs.update(existing_attrs) # Restore any user-defined attributes.
  113. # Preserve legacy testcase behavior.
  114. if self.family == "xunit1":
  115. return
  116. # Filter out attributes not permitted by this test family.
  117. # Including custom attributes because they are not valid here.
  118. temp_attrs = {}
  119. for key in self.attrs.keys():
  120. if key in families[self.family]["testcase"]:
  121. temp_attrs[key] = self.attrs[key]
  122. self.attrs = temp_attrs
  123. def to_xml(self) -> ET.Element:
  124. testcase = ET.Element("testcase", self.attrs, time="%.3f" % self.duration)
  125. properties = self.make_properties_node()
  126. if properties is not None:
  127. testcase.append(properties)
  128. testcase.extend(self.nodes)
  129. return testcase
  130. def _add_simple(self, tag: str, message: str, data: Optional[str] = None) -> None:
  131. node = ET.Element(tag, message=message)
  132. node.text = bin_xml_escape(data)
  133. self.append(node)
  134. def write_captured_output(self, report: TestReport) -> None:
  135. if not self.xml.log_passing_tests and report.passed:
  136. return
  137. content_out = report.capstdout
  138. content_log = report.caplog
  139. content_err = report.capstderr
  140. if self.xml.logging == "no":
  141. return
  142. content_all = ""
  143. if self.xml.logging in ["log", "all"]:
  144. content_all = self._prepare_content(content_log, " Captured Log ")
  145. if self.xml.logging in ["system-out", "out-err", "all"]:
  146. content_all += self._prepare_content(content_out, " Captured Out ")
  147. self._write_content(report, content_all, "system-out")
  148. content_all = ""
  149. if self.xml.logging in ["system-err", "out-err", "all"]:
  150. content_all += self._prepare_content(content_err, " Captured Err ")
  151. self._write_content(report, content_all, "system-err")
  152. content_all = ""
  153. if content_all:
  154. self._write_content(report, content_all, "system-out")
  155. def _prepare_content(self, content: str, header: str) -> str:
  156. return "\n".join([header.center(80, "-"), content, ""])
  157. def _write_content(self, report: TestReport, content: str, jheader: str) -> None:
  158. tag = ET.Element(jheader)
  159. tag.text = bin_xml_escape(content)
  160. self.append(tag)
  161. def append_pass(self, report: TestReport) -> None:
  162. self.add_stats("passed")
  163. def append_failure(self, report: TestReport) -> None:
  164. # msg = str(report.longrepr.reprtraceback.extraline)
  165. if hasattr(report, "wasxfail"):
  166. self._add_simple("skipped", "xfail-marked test passes unexpectedly")
  167. else:
  168. assert report.longrepr is not None
  169. reprcrash: Optional[ReprFileLocation] = getattr(
  170. report.longrepr, "reprcrash", None
  171. )
  172. if reprcrash is not None:
  173. message = reprcrash.message
  174. else:
  175. message = str(report.longrepr)
  176. message = bin_xml_escape(message)
  177. self._add_simple("failure", message, str(report.longrepr))
  178. def append_collect_error(self, report: TestReport) -> None:
  179. # msg = str(report.longrepr.reprtraceback.extraline)
  180. assert report.longrepr is not None
  181. self._add_simple("error", "collection failure", str(report.longrepr))
  182. def append_collect_skipped(self, report: TestReport) -> None:
  183. self._add_simple("skipped", "collection skipped", str(report.longrepr))
  184. def append_error(self, report: TestReport) -> None:
  185. assert report.longrepr is not None
  186. reprcrash: Optional[ReprFileLocation] = getattr(
  187. report.longrepr, "reprcrash", None
  188. )
  189. if reprcrash is not None:
  190. reason = reprcrash.message
  191. else:
  192. reason = str(report.longrepr)
  193. if report.when == "teardown":
  194. msg = f'failed on teardown with "{reason}"'
  195. else:
  196. msg = f'failed on setup with "{reason}"'
  197. self._add_simple("error", bin_xml_escape(msg), str(report.longrepr))
  198. def append_skipped(self, report: TestReport) -> None:
  199. if hasattr(report, "wasxfail"):
  200. xfailreason = report.wasxfail
  201. if xfailreason.startswith("reason: "):
  202. xfailreason = xfailreason[8:]
  203. xfailreason = bin_xml_escape(xfailreason)
  204. skipped = ET.Element("skipped", type="pytest.xfail", message=xfailreason)
  205. self.append(skipped)
  206. else:
  207. assert isinstance(report.longrepr, tuple)
  208. filename, lineno, skipreason = report.longrepr
  209. if skipreason.startswith("Skipped: "):
  210. skipreason = skipreason[9:]
  211. details = f"{filename}:{lineno}: {skipreason}"
  212. skipped = ET.Element("skipped", type="pytest.skip", message=skipreason)
  213. skipped.text = bin_xml_escape(details)
  214. self.append(skipped)
  215. self.write_captured_output(report)
  216. def finalize(self) -> None:
  217. data = self.to_xml()
  218. self.__dict__.clear()
  219. # Type ignored because mypy doesn't like overriding a method.
  220. # Also the return value doesn't match...
  221. self.to_xml = lambda: data # type: ignore[assignment]
  222. def _warn_incompatibility_with_xunit2(
  223. request: FixtureRequest, fixture_name: str
  224. ) -> None:
  225. """Emit a PytestWarning about the given fixture being incompatible with newer xunit revisions."""
  226. from _pytest.warning_types import PytestWarning
  227. xml = request.config.stash.get(xml_key, None)
  228. if xml is not None and xml.family not in ("xunit1", "legacy"):
  229. request.node.warn(
  230. PytestWarning(
  231. "{fixture_name} is incompatible with junit_family '{family}' (use 'legacy' or 'xunit1')".format(
  232. fixture_name=fixture_name, family=xml.family
  233. )
  234. )
  235. )
  236. @pytest.fixture
  237. def record_property(request: FixtureRequest) -> Callable[[str, object], None]:
  238. """Add extra properties to the calling test.
  239. User properties become part of the test report and are available to the
  240. configured reporters, like JUnit XML.
  241. The fixture is callable with ``name, value``. The value is automatically
  242. XML-encoded.
  243. Example::
  244. def test_function(record_property):
  245. record_property("example_key", 1)
  246. """
  247. _warn_incompatibility_with_xunit2(request, "record_property")
  248. def append_property(name: str, value: object) -> None:
  249. request.node.user_properties.append((name, value))
  250. return append_property
  251. @pytest.fixture
  252. def record_xml_attribute(request: FixtureRequest) -> Callable[[str, object], None]:
  253. """Add extra xml attributes to the tag for the calling test.
  254. The fixture is callable with ``name, value``. The value is
  255. automatically XML-encoded.
  256. """
  257. from _pytest.warning_types import PytestExperimentalApiWarning
  258. request.node.warn(
  259. PytestExperimentalApiWarning("record_xml_attribute is an experimental feature")
  260. )
  261. _warn_incompatibility_with_xunit2(request, "record_xml_attribute")
  262. # Declare noop
  263. def add_attr_noop(name: str, value: object) -> None:
  264. pass
  265. attr_func = add_attr_noop
  266. xml = request.config.stash.get(xml_key, None)
  267. if xml is not None:
  268. node_reporter = xml.node_reporter(request.node.nodeid)
  269. attr_func = node_reporter.add_attribute
  270. return attr_func
  271. def _check_record_param_type(param: str, v: str) -> None:
  272. """Used by record_testsuite_property to check that the given parameter name is of the proper
  273. type."""
  274. __tracebackhide__ = True
  275. if not isinstance(v, str):
  276. msg = "{param} parameter needs to be a string, but {g} given" # type: ignore[unreachable]
  277. raise TypeError(msg.format(param=param, g=type(v).__name__))
  278. @pytest.fixture(scope="session")
  279. def record_testsuite_property(request: FixtureRequest) -> Callable[[str, object], None]:
  280. """Record a new ``<property>`` tag as child of the root ``<testsuite>``.
  281. This is suitable to writing global information regarding the entire test
  282. suite, and is compatible with ``xunit2`` JUnit family.
  283. This is a ``session``-scoped fixture which is called with ``(name, value)``. Example:
  284. .. code-block:: python
  285. def test_foo(record_testsuite_property):
  286. record_testsuite_property("ARCH", "PPC")
  287. record_testsuite_property("STORAGE_TYPE", "CEPH")
  288. :param name:
  289. The property name.
  290. :param value:
  291. The property value. Will be converted to a string.
  292. .. warning::
  293. Currently this fixture **does not work** with the
  294. `pytest-xdist <https://github.com/pytest-dev/pytest-xdist>`__ plugin. See
  295. :issue:`7767` for details.
  296. """
  297. __tracebackhide__ = True
  298. def record_func(name: str, value: object) -> None:
  299. """No-op function in case --junit-xml was not passed in the command-line."""
  300. __tracebackhide__ = True
  301. _check_record_param_type("name", name)
  302. xml = request.config.stash.get(xml_key, None)
  303. if xml is not None:
  304. record_func = xml.add_global_property # noqa
  305. return record_func
  306. def pytest_addoption(parser: Parser) -> None:
  307. group = parser.getgroup("terminal reporting")
  308. group.addoption(
  309. "--junitxml",
  310. "--junit-xml",
  311. action="store",
  312. dest="xmlpath",
  313. metavar="path",
  314. type=functools.partial(filename_arg, optname="--junitxml"),
  315. default=None,
  316. help="Create junit-xml style report file at given path",
  317. )
  318. group.addoption(
  319. "--junitprefix",
  320. "--junit-prefix",
  321. action="store",
  322. metavar="str",
  323. default=None,
  324. help="Prepend prefix to classnames in junit-xml output",
  325. )
  326. parser.addini(
  327. "junit_suite_name", "Test suite name for JUnit report", default="pytest"
  328. )
  329. parser.addini(
  330. "junit_logging",
  331. "Write captured log messages to JUnit report: "
  332. "one of no|log|system-out|system-err|out-err|all",
  333. default="no",
  334. )
  335. parser.addini(
  336. "junit_log_passing_tests",
  337. "Capture log information for passing tests to JUnit report: ",
  338. type="bool",
  339. default=True,
  340. )
  341. parser.addini(
  342. "junit_duration_report",
  343. "Duration time to report: one of total|call",
  344. default="total",
  345. ) # choices=['total', 'call'])
  346. parser.addini(
  347. "junit_family",
  348. "Emit XML for schema: one of legacy|xunit1|xunit2",
  349. default="xunit2",
  350. )
  351. def pytest_configure(config: Config) -> None:
  352. xmlpath = config.option.xmlpath
  353. # Prevent opening xmllog on worker nodes (xdist).
  354. if xmlpath and not hasattr(config, "workerinput"):
  355. junit_family = config.getini("junit_family")
  356. config.stash[xml_key] = LogXML(
  357. xmlpath,
  358. config.option.junitprefix,
  359. config.getini("junit_suite_name"),
  360. config.getini("junit_logging"),
  361. config.getini("junit_duration_report"),
  362. junit_family,
  363. config.getini("junit_log_passing_tests"),
  364. )
  365. config.pluginmanager.register(config.stash[xml_key])
  366. def pytest_unconfigure(config: Config) -> None:
  367. xml = config.stash.get(xml_key, None)
  368. if xml:
  369. del config.stash[xml_key]
  370. config.pluginmanager.unregister(xml)
  371. def mangle_test_address(address: str) -> List[str]:
  372. path, possible_open_bracket, params = address.partition("[")
  373. names = path.split("::")
  374. # Convert file path to dotted path.
  375. names[0] = names[0].replace(nodes.SEP, ".")
  376. names[0] = re.sub(r"\.py$", "", names[0])
  377. # Put any params back.
  378. names[-1] += possible_open_bracket + params
  379. return names
  380. class LogXML:
  381. def __init__(
  382. self,
  383. logfile,
  384. prefix: Optional[str],
  385. suite_name: str = "pytest",
  386. logging: str = "no",
  387. report_duration: str = "total",
  388. family="xunit1",
  389. log_passing_tests: bool = True,
  390. ) -> None:
  391. logfile = os.path.expanduser(os.path.expandvars(logfile))
  392. self.logfile = os.path.normpath(os.path.abspath(logfile))
  393. self.prefix = prefix
  394. self.suite_name = suite_name
  395. self.logging = logging
  396. self.log_passing_tests = log_passing_tests
  397. self.report_duration = report_duration
  398. self.family = family
  399. self.stats: Dict[str, int] = dict.fromkeys(
  400. ["error", "passed", "failure", "skipped"], 0
  401. )
  402. self.node_reporters: Dict[
  403. Tuple[Union[str, TestReport], object], _NodeReporter
  404. ] = {}
  405. self.node_reporters_ordered: List[_NodeReporter] = []
  406. self.global_properties: List[Tuple[str, str]] = []
  407. # List of reports that failed on call but teardown is pending.
  408. self.open_reports: List[TestReport] = []
  409. self.cnt_double_fail_tests = 0
  410. # Replaces convenience family with real family.
  411. if self.family == "legacy":
  412. self.family = "xunit1"
  413. def finalize(self, report: TestReport) -> None:
  414. nodeid = getattr(report, "nodeid", report)
  415. # Local hack to handle xdist report order.
  416. workernode = getattr(report, "node", None)
  417. reporter = self.node_reporters.pop((nodeid, workernode))
  418. for propname, propvalue in report.user_properties:
  419. reporter.add_property(propname, str(propvalue))
  420. if reporter is not None:
  421. reporter.finalize()
  422. def node_reporter(self, report: Union[TestReport, str]) -> _NodeReporter:
  423. nodeid: Union[str, TestReport] = getattr(report, "nodeid", report)
  424. # Local hack to handle xdist report order.
  425. workernode = getattr(report, "node", None)
  426. key = nodeid, workernode
  427. if key in self.node_reporters:
  428. # TODO: breaks for --dist=each
  429. return self.node_reporters[key]
  430. reporter = _NodeReporter(nodeid, self)
  431. self.node_reporters[key] = reporter
  432. self.node_reporters_ordered.append(reporter)
  433. return reporter
  434. def add_stats(self, key: str) -> None:
  435. if key in self.stats:
  436. self.stats[key] += 1
  437. def _opentestcase(self, report: TestReport) -> _NodeReporter:
  438. reporter = self.node_reporter(report)
  439. reporter.record_testreport(report)
  440. return reporter
  441. def pytest_runtest_logreport(self, report: TestReport) -> None:
  442. """Handle a setup/call/teardown report, generating the appropriate
  443. XML tags as necessary.
  444. Note: due to plugins like xdist, this hook may be called in interlaced
  445. order with reports from other nodes. For example:
  446. Usual call order:
  447. -> setup node1
  448. -> call node1
  449. -> teardown node1
  450. -> setup node2
  451. -> call node2
  452. -> teardown node2
  453. Possible call order in xdist:
  454. -> setup node1
  455. -> call node1
  456. -> setup node2
  457. -> call node2
  458. -> teardown node2
  459. -> teardown node1
  460. """
  461. close_report = None
  462. if report.passed:
  463. if report.when == "call": # ignore setup/teardown
  464. reporter = self._opentestcase(report)
  465. reporter.append_pass(report)
  466. elif report.failed:
  467. if report.when == "teardown":
  468. # The following vars are needed when xdist plugin is used.
  469. report_wid = getattr(report, "worker_id", None)
  470. report_ii = getattr(report, "item_index", None)
  471. close_report = next(
  472. (
  473. rep
  474. for rep in self.open_reports
  475. if (
  476. rep.nodeid == report.nodeid
  477. and getattr(rep, "item_index", None) == report_ii
  478. and getattr(rep, "worker_id", None) == report_wid
  479. )
  480. ),
  481. None,
  482. )
  483. if close_report:
  484. # We need to open new testcase in case we have failure in
  485. # call and error in teardown in order to follow junit
  486. # schema.
  487. self.finalize(close_report)
  488. self.cnt_double_fail_tests += 1
  489. reporter = self._opentestcase(report)
  490. if report.when == "call":
  491. reporter.append_failure(report)
  492. self.open_reports.append(report)
  493. if not self.log_passing_tests:
  494. reporter.write_captured_output(report)
  495. else:
  496. reporter.append_error(report)
  497. elif report.skipped:
  498. reporter = self._opentestcase(report)
  499. reporter.append_skipped(report)
  500. self.update_testcase_duration(report)
  501. if report.when == "teardown":
  502. reporter = self._opentestcase(report)
  503. reporter.write_captured_output(report)
  504. self.finalize(report)
  505. report_wid = getattr(report, "worker_id", None)
  506. report_ii = getattr(report, "item_index", None)
  507. close_report = next(
  508. (
  509. rep
  510. for rep in self.open_reports
  511. if (
  512. rep.nodeid == report.nodeid
  513. and getattr(rep, "item_index", None) == report_ii
  514. and getattr(rep, "worker_id", None) == report_wid
  515. )
  516. ),
  517. None,
  518. )
  519. if close_report:
  520. self.open_reports.remove(close_report)
  521. def update_testcase_duration(self, report: TestReport) -> None:
  522. """Accumulate total duration for nodeid from given report and update
  523. the Junit.testcase with the new total if already created."""
  524. if self.report_duration == "total" or report.when == self.report_duration:
  525. reporter = self.node_reporter(report)
  526. reporter.duration += getattr(report, "duration", 0.0)
  527. def pytest_collectreport(self, report: TestReport) -> None:
  528. if not report.passed:
  529. reporter = self._opentestcase(report)
  530. if report.failed:
  531. reporter.append_collect_error(report)
  532. else:
  533. reporter.append_collect_skipped(report)
  534. def pytest_internalerror(self, excrepr: ExceptionRepr) -> None:
  535. reporter = self.node_reporter("internal")
  536. reporter.attrs.update(classname="pytest", name="internal")
  537. reporter._add_simple("error", "internal error", str(excrepr))
  538. def pytest_sessionstart(self) -> None:
  539. self.suite_start_time = timing.time()
  540. def pytest_sessionfinish(self) -> None:
  541. dirname = os.path.dirname(os.path.abspath(self.logfile))
  542. # exist_ok avoids filesystem race conditions between checking path existence and requesting creation
  543. os.makedirs(dirname, exist_ok=True)
  544. with open(self.logfile, "w", encoding="utf-8") as logfile:
  545. suite_stop_time = timing.time()
  546. suite_time_delta = suite_stop_time - self.suite_start_time
  547. numtests = (
  548. self.stats["passed"]
  549. + self.stats["failure"]
  550. + self.stats["skipped"]
  551. + self.stats["error"]
  552. - self.cnt_double_fail_tests
  553. )
  554. logfile.write('<?xml version="1.0" encoding="utf-8"?>')
  555. suite_node = ET.Element(
  556. "testsuite",
  557. name=self.suite_name,
  558. errors=str(self.stats["error"]),
  559. failures=str(self.stats["failure"]),
  560. skipped=str(self.stats["skipped"]),
  561. tests=str(numtests),
  562. time="%.3f" % suite_time_delta,
  563. timestamp=datetime.fromtimestamp(self.suite_start_time).isoformat(),
  564. hostname=platform.node(),
  565. )
  566. global_properties = self._get_global_properties_node()
  567. if global_properties is not None:
  568. suite_node.append(global_properties)
  569. for node_reporter in self.node_reporters_ordered:
  570. suite_node.append(node_reporter.to_xml())
  571. testsuites = ET.Element("testsuites")
  572. testsuites.append(suite_node)
  573. logfile.write(ET.tostring(testsuites, encoding="unicode"))
  574. def pytest_terminal_summary(self, terminalreporter: TerminalReporter) -> None:
  575. terminalreporter.write_sep("-", f"generated xml file: {self.logfile}")
  576. def add_global_property(self, name: str, value: object) -> None:
  577. __tracebackhide__ = True
  578. _check_record_param_type("name", name)
  579. self.global_properties.append((name, bin_xml_escape(value)))
  580. def _get_global_properties_node(self) -> Optional[ET.Element]:
  581. """Return a Junit node containing custom properties, if any."""
  582. if self.global_properties:
  583. properties = ET.Element("properties")
  584. for name, value in self.global_properties:
  585. properties.append(ET.Element("property", name=name, value=value))
  586. return properties
  587. return None