[{"data":1,"prerenderedAt":843},["ShallowReactive",2],{"page-\u002Fadvanced-pytest-architecture-configuration\u002Fassertion-introspection-and-reporting\u002Fproducing-junit-xml-reports-for-ci-dashboards\u002F":3},{"id":4,"title":5,"body":6,"description":806,"extension":807,"meta":808,"navigation":137,"path":839,"seo":840,"stem":841,"__hash__":842},"content\u002Fadvanced-pytest-architecture-configuration\u002Fassertion-introspection-and-reporting\u002Fproducing-junit-xml-reports-for-ci-dashboards\u002Findex.md","Producing JUnit XML Reports for CI Dashboards",{"type":7,"value":8,"toc":795},"minimark",[9,13,21,26,48,52,111,181,233,321,325,348,351,372,376,434,438,441,459,464,473,480,584,588,591,599,609,704,708,711,714,717,721,732,746,752,756,786,791],[10,11,12],"p",{},"Every CI platform can read JUnit XML, and every CI platform shows a better or worse dashboard depending on what is in it. A report with stable names, context attached to each test and captured output on failure turns the dashboard into a diagnostic tool; a report with unstable parametrised ids and no context turns it into a list of names that changes every run.",[10,14,15,16,20],{},"The configuration needed is small and entirely declarative: a handful of options in ",[17,18,19],"code",{},"pyproject.toml",", one autouse fixture for context, and a discipline about parametrised ids that the suite should have anyway. The payoff compounds over time, because the report becomes a history — of which tests fail, under which conditions, and how long each takes — that nobody has to collect deliberately. This guide covers the settings, what a dashboard does with each part of the file, merging reports from sharded jobs, and the questions a consistent set of reports can answer that a single run never can.",[22,23,25],"h2",{"id":24},"prerequisites","Prerequisites",[27,28,29,36,39],"ul",{},[30,31,32,35],"li",{},[17,33,34],{},"pytest >= 8.0",". JUnit XML support is built in; no plugin is needed.",[30,37,38],{},"A CI system that ingests JUnit XML — nearly all of them do.",[30,40,41,42,47],{},"Stable parametrised ids, covered in ",[43,44,46],"a",{"href":45},"\u002Fadvanced-pytest-architecture-configuration\u002Fadvanced-parametrization-techniques\u002Fgenerating-readable-test-ids\u002F","generating readable test IDs",".",[22,49,51],{"id":50},"solution","Solution",[53,54,59],"pre",{"className":55,"code":56,"language":57,"meta":58,"style":58},"language-toml shiki shiki-themes github-light github-dark","# pyproject.toml\n[tool.pytest.ini_options]\naddopts = \"--junitxml=reports\u002Fjunit.xml\"\njunit_family = \"xunit2\"            # what current parsers expect; pin it\njunit_suite_name = \"billing\"       # a meaningful suite name in the dashboard\njunit_logging = \"all\"              # stdout, stderr and log records on failure\njunit_log_passing_tests = false    # keep passing tests' output out of the file\njunit_duration_report = \"call\"     # durations exclude fixture setup\n","toml","",[17,60,61,69,75,81,87,93,99,105],{"__ignoreMap":58},[62,63,66],"span",{"class":64,"line":65},"line",1,[62,67,68],{},"# pyproject.toml\n",[62,70,72],{"class":64,"line":71},2,[62,73,74],{},"[tool.pytest.ini_options]\n",[62,76,78],{"class":64,"line":77},3,[62,79,80],{},"addopts = \"--junitxml=reports\u002Fjunit.xml\"\n",[62,82,84],{"class":64,"line":83},4,[62,85,86],{},"junit_family = \"xunit2\"            # what current parsers expect; pin it\n",[62,88,90],{"class":64,"line":89},5,[62,91,92],{},"junit_suite_name = \"billing\"       # a meaningful suite name in the dashboard\n",[62,94,96],{"class":64,"line":95},6,[62,97,98],{},"junit_logging = \"all\"              # stdout, stderr and log records on failure\n",[62,100,102],{"class":64,"line":101},7,[62,103,104],{},"junit_log_passing_tests = false    # keep passing tests' output out of the file\n",[62,106,108],{"class":64,"line":107},8,[62,109,110],{},"junit_duration_report = \"call\"     # durations exclude fixture setup\n",[53,112,116],{"className":113,"code":114,"language":115,"meta":58,"style":58},"language-python shiki shiki-themes github-light github-dark","# conftest.py — context stamped on every test case\nimport os\nimport platform\n\nimport pytest\n\n\n@pytest.fixture(autouse=True)\ndef _report_context(record_property):\n    record_property(\"commit\", os.environ.get(\"GIT_COMMIT\", \"local\"))\n    record_property(\"python\", platform.python_version())\n    record_property(\"worker\", os.environ.get(\"PYTEST_XDIST_WORKER\", \"master\"))\n","python",[17,117,118,123,128,133,139,144,148,152,157,163,169,175],{"__ignoreMap":58},[62,119,120],{"class":64,"line":65},[62,121,122],{},"# conftest.py — context stamped on every test case\n",[62,124,125],{"class":64,"line":71},[62,126,127],{},"import os\n",[62,129,130],{"class":64,"line":77},[62,131,132],{},"import platform\n",[62,134,135],{"class":64,"line":83},[62,136,138],{"emptyLinePlaceholder":137},true,"\n",[62,140,141],{"class":64,"line":89},[62,142,143],{},"import pytest\n",[62,145,146],{"class":64,"line":95},[62,147,138],{"emptyLinePlaceholder":137},[62,149,150],{"class":64,"line":101},[62,151,138],{"emptyLinePlaceholder":137},[62,153,154],{"class":64,"line":107},[62,155,156],{},"@pytest.fixture(autouse=True)\n",[62,158,160],{"class":64,"line":159},9,[62,161,162],{},"def _report_context(record_property):\n",[62,164,166],{"class":64,"line":165},10,[62,167,168],{},"    record_property(\"commit\", os.environ.get(\"GIT_COMMIT\", \"local\"))\n",[62,170,172],{"class":64,"line":171},11,[62,173,174],{},"    record_property(\"python\", platform.python_version())\n",[62,176,178],{"class":64,"line":177},12,[62,179,180],{},"    record_property(\"worker\", os.environ.get(\"PYTEST_XDIST_WORKER\", \"master\"))\n",[53,182,186],{"className":183,"code":184,"language":185,"meta":58,"style":58},"language-xml shiki shiki-themes github-light github-dark","\u003Ctestcase classname=\"tests.test_invoices\" name=\"test_total[gbp-standard-rate]\" time=\"0.012\">\n  \u003Cproperties>\n    \u003Cproperty name=\"commit\" value=\"9f31ac2\"\u002F>\n    \u003Cproperty name=\"python\" value=\"3.12.4\"\u002F>\n    \u003Cproperty name=\"worker\" value=\"gw3\"\u002F>\n  \u003C\u002Fproperties>\n  \u003Cfailure message=\"assert 1230 == 1234\">…\u003C\u002Ffailure>\n  \u003Csystem-out>INFO billing charge started customer=cus_1 …\u003C\u002Fsystem-out>\n\u003C\u002Ftestcase>\n","xml",[17,187,188,193,198,203,208,213,218,223,228],{"__ignoreMap":58},[62,189,190],{"class":64,"line":65},[62,191,192],{},"\u003Ctestcase classname=\"tests.test_invoices\" name=\"test_total[gbp-standard-rate]\" time=\"0.012\">\n",[62,194,195],{"class":64,"line":71},[62,196,197],{},"  \u003Cproperties>\n",[62,199,200],{"class":64,"line":77},[62,201,202],{},"    \u003Cproperty name=\"commit\" value=\"9f31ac2\"\u002F>\n",[62,204,205],{"class":64,"line":83},[62,206,207],{},"    \u003Cproperty name=\"python\" value=\"3.12.4\"\u002F>\n",[62,209,210],{"class":64,"line":89},[62,211,212],{},"    \u003Cproperty name=\"worker\" value=\"gw3\"\u002F>\n",[62,214,215],{"class":64,"line":95},[62,216,217],{},"  \u003C\u002Fproperties>\n",[62,219,220],{"class":64,"line":101},[62,221,222],{},"  \u003Cfailure message=\"assert 1230 == 1234\">…\u003C\u002Ffailure>\n",[62,224,225],{"class":64,"line":107},[62,226,227],{},"  \u003Csystem-out>INFO billing charge started customer=cus_1 …\u003C\u002Fsystem-out>\n",[62,229,230],{"class":64,"line":159},[62,231,232],{},"\u003C\u002Ftestcase>\n",[234,235,238,317],"figure",{"className":236},[237],"diagram",[239,240,247,248,247,252,247,256,247,264,247,274,247,284,247,290,247,295,247,299,247,304,247,308,247,313],"svg",{"viewBox":241,"role":242,"ariaLabelledBy":243,"xmlns":246},"0 0 820 262","img",[244,245],"jx-t","jx-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","\n  ",[249,250,251],"title",{"id":244},"Anatomy of a useful JUnit test case",[253,254,255],"desc",{"id":245},"A single testcase element broken into four parts. A stable name built from explicit parameter ids lets the dashboard track history. Properties carry the commit, interpreter and worker. The failure element carries the assertion message. System-out carries captured logs for the failing test only.",[257,258],"rect",{"x":259,"y":259,"width":260,"height":261,"rx":262,"fill":263},"0","820","262","14","#fffdf8",[265,266,273],"text",{"x":267,"y":268,"textAnchor":269,"fontSize":270,"fontWeight":271,"fill":272},"410","28","middle","16","700","#3d405b","Four parts, four questions answered",[257,275],{"x":276,"y":277,"width":278,"height":279,"rx":280,"fill":281,"stroke":282,"strokeWidth":283},"26","50","768","42","10","#e6f0ea","#81b29a","2",[265,285,289],{"x":286,"y":287,"fontSize":288,"fill":272},"46","76","11.5","name=\"test_total[gbp-standard-rate]\" — which test, stable across runs",[257,291],{"x":276,"y":292,"width":278,"height":279,"rx":280,"fill":293,"stroke":294,"strokeWidth":283},"102","#f7f0da","#f2cc8f",[265,296,298],{"x":286,"y":297,"fontSize":288,"fill":272},"128","properties: commit, python, worker — under what conditions",[257,300],{"x":276,"y":301,"width":278,"height":279,"rx":280,"fill":302,"stroke":303,"strokeWidth":283},"154","#fbe9e3","#e07a5f",[265,305,307],{"x":286,"y":306,"fontSize":288,"fill":272},"180","failure message=\"assert 1230 == 1234\" — what went wrong",[257,309],{"x":276,"y":310,"width":278,"height":279,"rx":280,"fill":311,"stroke":272,"strokeWidth":312},"206","#f4f1de","1.6",[265,314,316],{"x":286,"y":315,"fontSize":288,"fill":272},"232","system-out: captured logs — what the code was doing at the time",[318,319,320],"figcaption",{},"Default output provides only the third part reliably. The configuration above supplies the other three for the cost of a few lines.",[22,322,324],{"id":323},"why-this-works","Why this works",[10,326,327,328,331,332,335,336,339,340,343,344,347],{},"pytest's JUnit writer turns each test report into a ",[17,329,330],{},"\u003Ctestcase>"," element, using the node id for the name and class, the call-phase duration for ",[17,333,334],{},"time",", and the failure or error representation for the child element. ",[17,337,338],{},"record_property"," and ",[17,341,342],{},"user_properties"," attach arbitrary key-value pairs as ",[17,345,346],{},"\u003Cproperty>"," children, which most dashboards display alongside the result and many can filter or group by.",[10,349,350],{},"The node id is also why stable names come for free for ordinary tests and need attention only for parametrised ones: the module path, class and function name are fixed by the source, while the bracketed parameter id is whatever pytest or the author generated.",[10,352,353,356,357,339,360,363,364,367,368,371],{},[17,354,355],{},"junit_logging"," controls whether captured stdout, stderr and log records are copied into ",[17,358,359],{},"\u003Csystem-out>",[17,361,362],{},"\u003Csystem-err>",". Setting it to ",[17,365,366],{},"all"," while leaving ",[17,369,370],{},"junit_log_passing_tests"," off keeps the file small — passing tests contribute no output — while guaranteeing that every failure carries the logs needed to diagnose it.",[22,373,375],{"id":374},"edge-cases-and-failure-modes","Edge cases and failure modes",[27,377,378,392,408,418,428],{},[30,379,380,384,385,388,389,47],{},[381,382,383],"strong",{},"Unstable parametrised names."," Ids generated from ",[17,386,387],{},"repr"," of objects with addresses or unordered sets change every run, so the dashboard's history and flake detection break. Use explicit ",[17,390,391],{},"ids=",[30,393,394,400,401,404,405,47],{},[381,395,396,399],{},[17,397,398],{},"xunit1"," left as the family."," Several parsers ignore ",[17,402,403],{},"\u003Cproperties>"," in the legacy format. Pin ",[17,406,407],{},"xunit2",[30,409,410,413,414,417],{},[381,411,412],{},"Huge report files."," ",[17,415,416],{},"junit_log_passing_tests = true"," on a large suite produces hundreds of megabytes. Keep it off.",[30,419,420,423,424,427],{},[381,421,422],{},"Duration including setup."," The default duration covers setup, so every test sharing a slow session fixture looks slow. ",[17,425,426],{},"junit_duration_report = \"call\""," isolates the body.",[30,429,430,433],{},[381,431,432],{},"Properties with non-string values."," They are stringified, which is fine for numbers and awkward for dicts. Flatten them into separate properties.",[22,435,437],{"id":436},"what-a-dashboard-does-with-the-file","What a dashboard does with the file",[10,439,440],{},"It helps to know what the consuming side does with each element, because that decides which parts of the report deserve care and which are decoration.",[10,442,443,444,339,447,450,451,454,455,458],{},"The ",[17,445,446],{},"name",[17,448,449],{},"classname"," attributes are the identity of a test. Every history view, flake detector, \"tests that started failing in this build\" list and trend chart keys on them. A dashboard cannot tell that ",[17,452,453],{},"test_total[obj0]"," today and ",[17,456,457],{},"test_total[obj1]"," tomorrow are the same test, so anything that makes names vary between runs silently disables all of those features at once. That makes stable naming the single most important property of the report, well ahead of anything else in it.",[10,460,443,461,463],{},[17,462,334],{}," attribute drives the \"slowest tests\" views and any duration trend. It is only meaningful if it measures the same thing every run, which is why reporting the call phase alone is preferable: setup time depends on which test happened to trigger a session fixture, and that changes with ordering.",[10,465,443,466,339,469,472],{},[17,467,468],{},"\u003Cfailure>",[17,470,471],{},"\u003Cerror>"," elements are what most people read. The distinction between them is worth preserving rather than collapsing — a failure is an assertion that did not hold, an error is an exception in setup, teardown or the test body — because the two lead to different investigations. pytest keeps them apart; a custom report hook that converts errors into failures throws that information away.",[10,474,443,475,339,477,479],{},[17,476,403],{},[17,478,359],{}," children are the context. Few dashboards chart them directly, but all of them display them next to a failing test, and that is where they earn their place: the reader of a failure sees the commit, the interpreter and the logs without leaving the page.",[234,481,483,581],{"className":482},[237],[239,484,247,489,247,492,247,495,247,512,247,516,247,521,247,527,247,532,247,535,247,539,247,542,247,546,247,550,247,554,247,560,247,563,247,566,247,568,247,572,247,575,247,578],{"viewBox":485,"role":242,"ariaLabelledBy":486,"xmlns":246},"0 0 615 236",[487,488],"dash-t","dash-d",[249,490,491],{"id":487},"Which dashboard features depend on which report fields",[253,493,494],{"id":488},"Four report fields mapped to the features that consume them. Stable names feed history, flake detection and new-failure lists. Call-phase durations feed slowest-test views and trends. Failure versus error elements feed triage. Properties and captured output feed the detail pane shown beside a failing test.",[496,497,498,499,247],"defs",{},"\n    ",[500,501,508],"marker",{"id":502,"viewBox":503,"refX":504,"refY":505,"markerWidth":506,"markerHeight":506,"orient":507},"dash-a","0 0 10 10","9","5","7","auto-start-reverse",[509,510],"path",{"d":511,"fill":282},"M0 0 L10 5 L0 10 z",[257,513],{"x":259,"y":259,"width":514,"height":515,"rx":262,"fill":263},"615","236",[265,517,520],{"x":518,"y":268,"textAnchor":269,"fontSize":519,"fontWeight":271,"fill":272},"307","15.5","Every feature keys on one field",[257,522],{"x":523,"y":277,"width":524,"height":525,"rx":504,"fill":281,"stroke":282,"strokeWidth":526},"34","220","36","1.8",[265,528,531],{"x":529,"y":530,"textAnchor":269,"fontSize":288,"fill":272},"144","73","stable name",[257,533],{"x":523,"y":534,"width":524,"height":525,"rx":504,"fill":293,"stroke":294,"strokeWidth":526},"96",[265,536,538],{"x":529,"y":537,"textAnchor":269,"fontSize":288,"fill":272},"119","call-phase time",[257,540],{"x":523,"y":541,"width":524,"height":525,"rx":504,"fill":302,"stroke":303,"strokeWidth":526},"142",[265,543,545],{"x":529,"y":544,"textAnchor":269,"fontSize":288,"fill":272},"165","failure vs error",[257,547],{"x":523,"y":548,"width":524,"height":525,"rx":504,"fill":311,"stroke":272,"strokeWidth":549},"188","1.5",[265,551,553],{"x":529,"y":552,"textAnchor":269,"fontSize":288,"fill":272},"211","properties + output",[64,555],{"x1":556,"y1":557,"x2":558,"y2":557,"stroke":282,"strokeWidth":312,"markerEnd":559},"258","68","330","url(#dash-a)",[64,561],{"x1":556,"y1":562,"x2":558,"y2":562,"stroke":282,"strokeWidth":312,"markerEnd":559},"114",[64,564],{"x1":556,"y1":565,"x2":558,"y2":565,"stroke":282,"strokeWidth":312,"markerEnd":559},"160",[64,567],{"x1":556,"y1":310,"x2":558,"y2":310,"stroke":282,"strokeWidth":312,"markerEnd":559},[265,569,571],{"x":570,"y":530,"fontSize":288,"fill":272},"340","history · flake detection · \"newly failing\" lists",[265,573,574],{"x":570,"y":537,"fontSize":288,"fill":272},"slowest tests · duration trends",[265,576,577],{"x":570,"y":544,"fontSize":288,"fill":272},"triage: broken assertion or broken setup?",[265,579,580],{"x":570,"y":552,"fontSize":288,"fill":272},"the detail pane beside a failing test",[318,582,583],{},"Unstable names disable the whole top row at once, which is why they matter more than any other detail in the file.",[22,585,587],{"id":586},"merging-reports-from-sharded-jobs","Merging reports from sharded jobs",[10,589,590],{},"When a suite is split across several CI jobs, each job writes its own XML file, and the dashboard needs to see them as one run. Most platforms handle this natively if every shard uploads its report under a distinct name — the platform then aggregates by test name. Where that is not available, a final job can merge them.",[10,592,593,594,598],{},"The requirement that matters in either case is that test names are identical regardless of which shard ran the test. A name that includes the shard number, the worker id or anything derived from execution order breaks aggregation, because the same test appears under a different name depending on where it landed. This is another reason to put execution context in ",[595,596,597],"em",{},"properties"," rather than in names: properties vary per run and are meant to, names must not.",[10,600,601,602,604,605,608],{},"Merging by hand is a small script over the standard library's XML parser, concatenating ",[17,603,330],{}," elements under one ",[17,606,607],{},"\u003Ctestsuite>"," and summing the counts. It is worth writing only when the platform cannot do it, and worth deleting as soon as the platform can.",[234,610,612,701],{"className":611},[237],[239,613,247,618,247,621,247,624,247,631,247,635,247,639,247,644,247,649,247,652,247,656,247,659,247,663,247,669,247,672,247,675,247,679,247,685,247,689,247,694,247,698],{"viewBox":614,"role":242,"ariaLabelledBy":615,"xmlns":246},"0 0 800 234",[616,617],"mrg-t","mrg-d",[249,619,620],{"id":616},"Aggregating per-shard reports by stable test name",[253,622,623],{"id":617},"Three shards each upload their own JUnit file. The dashboard aggregates them by test name into one run. Because names are stable and execution context lives in properties, the same test is recognised as the same test whichever shard ran it.",[496,625,498,626,247],{},[500,627,629],{"id":628,"viewBox":503,"refX":504,"refY":505,"markerWidth":506,"markerHeight":506,"orient":507},"mrg-a",[509,630],{"d":511,"fill":282},[257,632],{"x":259,"y":259,"width":633,"height":634,"rx":262,"fill":263},"800","234",[265,636,638],{"x":637,"y":268,"textAnchor":269,"fontSize":519,"fontWeight":271,"fill":272},"400","Names identify tests; properties describe runs",[257,640],{"x":523,"y":641,"width":642,"height":643,"rx":280,"fill":311,"stroke":272,"strokeWidth":549},"52","200","44",[265,645,648],{"x":646,"y":647,"textAnchor":269,"fontSize":288,"fill":272},"134","79","shard-1\u002Fjunit.xml",[257,650],{"x":523,"y":651,"width":642,"height":643,"rx":280,"fill":311,"stroke":272,"strokeWidth":549},"108",[265,653,655],{"x":646,"y":654,"textAnchor":269,"fontSize":288,"fill":272},"135","shard-2\u002Fjunit.xml",[257,657],{"x":523,"y":658,"width":642,"height":643,"rx":280,"fill":311,"stroke":272,"strokeWidth":549},"164",[265,660,662],{"x":646,"y":661,"textAnchor":269,"fontSize":288,"fill":272},"191","shard-3\u002Fjunit.xml",[64,664],{"x1":665,"y1":666,"x2":558,"y2":667,"stroke":282,"strokeWidth":526,"markerEnd":668},"238","74","118","url(#mrg-a)",[64,670],{"x1":665,"y1":671,"x2":558,"y2":671,"stroke":282,"strokeWidth":526,"markerEnd":668},"130",[64,673],{"x1":665,"y1":674,"x2":558,"y2":541,"stroke":282,"strokeWidth":526,"markerEnd":668},"186",[257,676],{"x":677,"y":534,"width":524,"height":557,"rx":678,"fill":281,"stroke":282,"strokeWidth":283},"336","11",[265,680,684],{"x":681,"y":682,"textAnchor":269,"fontSize":683,"fontWeight":271,"fill":272},"446","124","12","one run in the dashboard",[265,686,688],{"x":681,"y":529,"textAnchor":269,"fontSize":678,"fill":687},"#2a5f49","aggregated by test name",[257,690],{"x":691,"y":534,"width":692,"height":557,"rx":678,"fill":263,"stroke":693,"strokeWidth":549},"580","194","rgba(61,64,91,0.35)",[265,695,697],{"x":696,"y":682,"textAnchor":269,"fontSize":678,"fill":272},"677","shard id lives in a",[265,699,700],{"x":696,"y":529,"textAnchor":269,"fontSize":678,"fill":272},"property, not the name",[318,702,703],{},"If a test's name depended on its shard, history and flake detection would treat it as a different test every time the shard assignment changed.",[22,705,707],{"id":706},"using-the-report-beyond-pass-and-fail","Using the report beyond pass and fail",[10,709,710],{},"A JUnit file with stable names and consistent properties is a small dataset, and a few simple questions asked of it across runs are worth more than any dashboard's default view.",[10,712,713],{},"Which tests fail intermittently? Group by name over the last fifty runs and count distinct outcomes; any test with both passes and failures on the same commit is flaky by definition, and the properties show whether the failures cluster on one worker, one interpreter or one shard. Which tests are getting slower? Plot the call duration per name over time; a steady climb is a regression in the code or its fixtures long before it becomes a timeout. Which failures correlate with a dependency? Filter by the version property and compare failure rates before and after an upgrade.",[10,715,716],{},"None of these need a specialised product. A scheduled job that parses the last few dozen reports into a table and flags the obvious anomalies costs an afternoon to write and changes how a team responds to flakiness — from re-running the build to reading a list of the specific tests that are intermittent, with the conditions under which they fail. That shift is the real return on getting the report's names and properties right, and it is only available if the data was recorded consistently from the start, which is why the configuration above is worth putting in place before it seems necessary rather than after the first flaky-test crisis.",[22,718,720],{"id":719},"frequently-asked-questions","Frequently Asked Questions",[10,722,723,726,728,729,731],{},[381,724,725],{},"Which junit_family should I use?",[17,727,407],{},". It is the format current CI parsers expect, and the legacy ",[17,730,398],{}," output drops per-test properties in several of them. Set it explicitly so a pytest upgrade cannot change it underneath you.",[10,733,734,737,738,741,742,745],{},[381,735,736],{},"Does pytest-xdist produce one XML file or many?","\nOne. The controller collects results from every worker and writes a single report, so ",[17,739,740],{},"--junitxml"," works unchanged under ",[17,743,744],{},"-n",". Sharding across separate CI jobs is different: each job writes its own file, and the dashboard or a merge step combines them.",[10,747,748,751],{},[381,749,750],{},"Why do my test names change between runs in the dashboard?","\nUsually because parametrised ids are generated from object reprs that include memory addresses or unordered data. The dashboard then sees a new test every run and cannot track history. Give parameters explicit ids.",[22,753,755],{"id":754},"related","Related",[27,757,758,765,772,779],{},[30,759,760,764],{},[43,761,763],{"href":762},"\u002Fadvanced-pytest-architecture-configuration\u002Fassertion-introspection-and-reporting\u002F","Assertion Introspection & Test Reporting"," — the report hooks that feed this output.",[30,766,767,771],{},[43,768,770],{"href":769},"\u002Fadvanced-pytest-architecture-configuration\u002Fassertion-introspection-and-reporting\u002Fcustomizing-failure-output-with-assertrepr-compare\u002F","Customizing Failure Output with assertrepr_compare"," — making the failure message itself more useful.",[30,773,774,778],{},[43,775,777],{"href":776},"\u002Fadvanced-pytest-architecture-configuration\u002Foptimizing-test-discovery\u002Fsharding-a-test-suite-across-ci-runners\u002F","Sharding a Test Suite Across CI Runners"," — producing one report per shard.",[30,780,781,785],{},[43,782,784],{"href":783},"\u002Fsystematic-debugging-performance-profiling\u002Fdebugging-tests-in-ci-and-containers\u002Fcapturing-artifacts-from-a-failed-ci-test-run\u002F","Capturing Artifacts from a Failed CI Test Run"," — what to keep alongside the XML.",[10,787,788,789],{},"← Back to ",[43,790,763],{"href":762},[792,793,794],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":58,"searchDepth":71,"depth":71,"links":796},[797,798,799,800,801,802,803,804,805],{"id":24,"depth":71,"text":25},{"id":50,"depth":71,"text":51},{"id":323,"depth":71,"text":324},{"id":374,"depth":71,"text":375},{"id":436,"depth":71,"text":437},{"id":586,"depth":71,"text":587},{"id":706,"depth":71,"text":707},{"id":719,"depth":71,"text":720},{"id":754,"depth":71,"text":755},"Generate JUnit XML from pytest that CI dashboards read well: xunit2 family, stable test names, per-test properties, captured logs, and merging reports across xdist and shards.","md",{"slug":809,"type":810,"breadcrumb":811,"datePublished":812,"dateModified":812,"faq":813,"howto":820},"producing-junit-xml-reports-for-ci-dashboards","article","JUnit XML","2026-09-18",[814,816,818],{"q":725,"a":815},"xunit2. It is the format current CI parsers expect, and the legacy xunit1 output drops per-test properties in several of them. Set it explicitly so a pytest upgrade cannot change it underneath you.",{"q":736,"a":817},"One. The controller collects results from every worker and writes a single report, so --junitxml works unchanged under -n. Sharding across separate CI jobs is different: each job writes its own file, and the dashboard or a merge step combines them.",{"q":750,"a":819},"Usually because parametrised ids are generated from object reprs that include memory addresses or unordered data. The dashboard then sees a new test every run and cannot track history. Give parameters explicit ids.",{"name":821,"description":822,"steps":823},"How to produce useful JUnit XML from pytest","Pin the format, stabilise names, attach context as properties, include captured output, and merge reports from sharded jobs.",[824,827,830,833,836],{"name":825,"text":826},"Enable the report with the xunit2 family","Add --junitxml and junit_family = xunit2 to the configuration so every run writes the same format.",{"name":828,"text":829},"Stabilise test identifiers","Give parametrised cases explicit ids so a test's name is the same across runs.",{"name":831,"text":832},"Attach context as properties","Use record_property or user_properties to add build, version and environment details to each test case.",{"name":834,"text":835},"Include captured output on failure","Set junit_logging so stdout, stderr and log records appear in the report for failed tests.",{"name":837,"text":838},"Merge reports from sharded jobs","Upload each shard's file and let the CI system combine them, or merge them in a final job.","\u002Fadvanced-pytest-architecture-configuration\u002Fassertion-introspection-and-reporting\u002Fproducing-junit-xml-reports-for-ci-dashboards",{"title":5,"description":806},"advanced-pytest-architecture-configuration\u002Fassertion-introspection-and-reporting\u002Fproducing-junit-xml-reports-for-ci-dashboards\u002Findex","xFPZ-KC955VVxBGtNSA0WkDwYE6CXf_Kt10zy68uGqQ",1789718768801]