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.
The configuration needed is small and entirely declarative: a handful of options in 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.
Prerequisites
pytest >= 8.0. JUnit XML support is built in; no plugin is needed.- A CI system that ingests JUnit XML — nearly all of them do.
- Stable parametrised ids, covered in generating readable test IDs.
Solution
# pyproject.toml
[tool.pytest.ini_options]
addopts = "--junitxml=reports/junit.xml"
junit_family = "xunit2" # what current parsers expect; pin it
junit_suite_name = "billing" # a meaningful suite name in the dashboard
junit_logging = "all" # stdout, stderr and log records on failure
junit_log_passing_tests = false # keep passing tests' output out of the file
junit_duration_report = "call" # durations exclude fixture setup
# conftest.py — context stamped on every test case
import os
import platform
import pytest
@pytest.fixture(autouse=True)
def _report_context(record_property):
record_property("commit", os.environ.get("GIT_COMMIT", "local"))
record_property("python", platform.python_version())
record_property("worker", os.environ.get("PYTEST_XDIST_WORKER", "master"))
<testcase classname="tests.test_invoices" name="test_total[gbp-standard-rate]" time="0.012">
<properties>
<property name="commit" value="9f31ac2"/>
<property name="python" value="3.12.4"/>
<property name="worker" value="gw3"/>
</properties>
<failure message="assert 1230 == 1234">…</failure>
<system-out>INFO billing charge started customer=cus_1 …</system-out>
</testcase>
Why this works
pytest's JUnit writer turns each test report into a <testcase> element, using the node id for the name and class, the call-phase duration for time, and the failure or error representation for the child element. record_property and user_properties attach arbitrary key-value pairs as <property> children, which most dashboards display alongside the result and many can filter or group by.
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.
junit_logging controls whether captured stdout, stderr and log records are copied into <system-out> and <system-err>. Setting it to all while leaving 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.
Edge cases and failure modes
- Unstable parametrised names. Ids generated from
reprof objects with addresses or unordered sets change every run, so the dashboard's history and flake detection break. Use explicitids=. xunit1left as the family. Several parsers ignore<properties>in the legacy format. Pinxunit2.- Huge report files.
junit_log_passing_tests = trueon a large suite produces hundreds of megabytes. Keep it off. - Duration including setup. The default duration covers setup, so every test sharing a slow session fixture looks slow.
junit_duration_report = "call"isolates the body. - Properties with non-string values. They are stringified, which is fine for numbers and awkward for dicts. Flatten them into separate properties.
What a dashboard does with the file
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.
The name and 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 test_total[obj0] today and 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.
The time 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.
The <failure> and <error> 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.
The <properties> and <system-out> 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.
Merging reports from sharded jobs
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.
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 properties rather than in names: properties vary per run and are meant to, names must not.
Merging by hand is a small script over the standard library's XML parser, concatenating <testcase> elements under one <testsuite> and summing the counts. It is worth writing only when the platform cannot do it, and worth deleting as soon as the platform can.
Using the report beyond pass and fail
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.
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.
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.
Frequently Asked Questions
Which junit_family should I use?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.
Does pytest-xdist produce one XML file or many?
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.
Why do my test names change between runs in the dashboard? 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.
Related
- Assertion Introspection & Test Reporting — the report hooks that feed this output.
- Customizing Failure Output with assertrepr_compare — making the failure message itself more useful.
- Sharding a Test Suite Across CI Runners — producing one report per shard.
- Capturing Artifacts from a Failed CI Test Run — what to keep alongside the XML.
← Back to Assertion Introspection & Test Reporting