
Product news and testing tips.
JUnit XML is the de facto standard for test results in CI, but there is no official specification for it. It began as an output format of Apache Ant's JUnit task and spread by imitation, so every framework emits a slightly different dialect. This guide documents the elements that are genuinely common, the eight places dialects actually diverge, and the things the format cannot express at all.
Key takeaways
- There is no official JUnit XML specification — the closest things to one are Maven Surefire's XSD and community reference docs.
- The root element is not fixed: some writers emit <testsuites>, others emit a bare <testsuite>.
- Attribute names drift between writers and versions — pytest emits `skipped`, ancient pytest emitted `skips`.
- Timestamps come in at least three layouts and the classic one carries no timezone at all.
- The format cannot express retries, flakiness, shard identity, or attachments — every tool that needs those invents a <property> convention.
Every CI system on the market can ingest JUnit XML. Jenkins, GitHub Actions, GitLab CI, CircleCI, Azure DevOps, Buildkite — all of them accept it, and most of them accept nothing else without a plugin. It is the closest thing the testing industry has to a universal interchange format.
It also has no specification.
That is not an exaggeration or a simplification. There is no document published by JUnit, or by any standards body, that defines what a valid JUnit XML file contains. The format is a fossil: it began as the XML that Apache Ant’s JUnit task wrote out for its report generator, other tools copied the shape well enough to be consumed by the same readers, and twenty years of imitation later it is everywhere and specified nowhere.
The practical consequence is that “we emit JUnit XML” tells you much less than it sounds like it does. Two files that both claim the name can disagree about the root element, the attribute names, the timestamp format, and what counts as a failure. If you are writing something that reads these files — or debugging why your CI system reports 400 tests when you ran 500 — the divergences are the whole story.
This guide documents them. It is written from the perspective of code that has to parse all of these dialects in production, so the emphasis is on where files actually differ rather than on an idealized schema nobody emits.
Where did the JUnit XML format come from?
The lineage runs through the Java build tooling of the early 2000s. Apache Ant shipped a <junit> task that ran tests and a <junitreport> task that turned their XML output into browsable HTML. The XML that sat between those two steps was an implementation detail, not an interface.
It became an interface anyway, because it was the thing available. Maven’s Surefire plugin wrote something compatible. Jenkins — then Hudson — learned to read it, and once the dominant CI server of the era could display your test results from a file on disk, every test framework in every language had a reason to produce that file.
None of that process involved anyone writing down what the format was. It spread the way a folk song spreads: by people copying what they heard, approximately.
Is there an official JUnit XML schema?
No, but there are three documents that get used as though there were one, and it is worth knowing what each actually covers.
Maven Surefire’s XSD is the most schema-like artifact in circulation. Surefire publishes an XSD describing the reports it writes. It is genuine and precise, and it describes exactly one dialect: Surefire’s. Files from pytest or Playwright are not required to validate against it, and frequently do not.
Jenkins’ JUnit plugin is the de facto compatibility target, because for years “does Jenkins display it?” was the only test that mattered. Jenkins ships its own XSD in the jenkinsci/xunit-plugin repository, and — tellingly — it is that schema the JUnit team reaches for when they need to validate JUnit XML, not Surefire’s. When the project the format is named after has to borrow someone else’s schema to check its own output, “no official specification” is not an overstatement. A third lineage, the original Ant / “Windy Road” schema, is also still in circulation, and the three disagree on real details such as whether classname is required on a <testcase>. The plugin’s parser is deliberately tolerant, which is why so many divergent dialects coexist: they all rendered fine in the one place people looked.
Community reference documentation, most usefully the testmoapp/junitxml repository, documents the union of what real tools emit. This is descriptive rather than normative — it tells you what you will encounter, not what is correct. In the absence of a real spec it is the most useful document available, and the fact that the best available reference is a GitHub repository maintained by a test-management vendor is itself a fair summary of the situation.
There is also a long-running Ministry of Testing thread of practitioners asking where the spec is and concluding, correctly, that there isn’t one.
What does a minimal JUnit XML file look like?
Here is a file that essentially every consumer will accept:
<?xml version="1.0" encoding="UTF-8"?>
<testsuites name="checkout" tests="3" failures="1" errors="0" skipped="1" time="4.271">
<testsuite name="checkout.payment" tests="3" failures="1" errors="0" skipped="1" time="4.271" timestamp="2026-09-09T10:14:22">
<testcase name="applies_discount_code" classname="checkout.payment.DiscountTest" time="0.812"/>
<testcase name="rejects_expired_card" classname="checkout.payment.CardTest" time="3.211">
<failure message="expected 402, got 500" type="AssertionError">
Traceback (most recent call last):
...
</failure>
</testcase>
<testcase name="handles_3ds_challenge" classname="checkout.payment.CardTest" time="0.248">
<skipped message="requires sandbox credentials"/>
</testcase>
</testsuite>
</testsuites>
Three elements carry almost all the meaning. <testsuites> is a container. <testsuite> is a group of tests, usually mapping to a class, module, or file. <testcase> is one test, and its outcome is expressed by which child element it carries — none for a pass, <failure>, <error>, or <skipped> otherwise.
That is the part everyone agrees on. Everything below is the part they don’t.
Where the dialects actually diverge
Eight divergences account for nearly every real-world parsing failure. Each one is the sort of thing that produces a plausible-looking number that happens to be wrong, which is worse than an outright crash.
| Divergence | What varies | Typical symptom when mishandled |
|---|---|---|
| Root element | <testsuites> vs a bare <testsuite> | Parse error, or zero tests found |
| Nested suites | <testsuite> inside <testsuite> | Tests and failures silently dropped |
| Attribute names | skipped vs skips; extra file, line | Counts disagree with the case list |
| Timestamps | Three layouts, often no timezone | Wrong ordering, wrong run date |
| Durations | Suite time missing, zero, or unparseable | Whole suite reports as 0s |
| Outcome elements | Multiple outcomes on one <testcase> | Failures rolled up as skipped |
| Retries | retries vs retryCount vs nothing | Flaky runs indistinguishable from clean ones |
| Encoding | Non-UTF-8 declared in the prolog | Entire upload rejected |
The root element is not fixed
Some writers always wrap their output in <testsuites>, even for a single suite. Others emit a bare <testsuite> as the document root. Both are common, and neither is wrong, because there is no document that could make one of them wrong.
A parser therefore cannot simply decode into a TestSuites struct and move on. It has to attempt that, and on failure rewind the reader and try again as a single TestSuite. Every mature JUnit XML consumer contains that fallback.
Suites can nest, and nesting hides tests
<testsuite> elements can contain other <testsuite> elements. Maven Surefire aggregate reports do this, and so do several of the Android and iOS converters that translate native results into JUnit XML.
This is the most dangerous divergence in the list, because the failure mode is silent. A parser that reads only the top level of each suite gets a valid-looking result with a plausible test count — it just quietly omits every test nested one level down, including their failures. A build goes green because the failing tests were never counted. Recursion into nested suites is not an optimization; it is a correctness requirement.
Attribute names drift between tools and versions
The header attributes on <testsuite> — tests, failures, errors, skipped, time — look stable and are not.
pytest is the canonical example. Current pytest emits skipped. Older pytest emitted skips. A parser reading only the modern name against an older file gets zero, and since many consumers derive the passed count by subtracting the other counters from tests, a wrong skip count inflates the passed count rather than producing an obvious error.
The lesson generalizes past that one attribute: never trust the header counters. They are a summary written by the producing tool, and they can disagree with the actual <testcase> elements in the same file. Deriving every count from the case list is the only way to guarantee the summary and the detail agree.
Writers also add attributes freely. pytest puts file and line on each <testcase>, which classic Ant-era JUnit XML never had. These extras are harmless to ignore and genuinely useful when present.
Timestamps come in at least three shapes
The timestamp attribute on <testsuite> appears in at least three layouts in the wild:
2026-09-09T10:14:22— the classic Ant form, with no timezone at all2026-09-09T10:14:22Zor with an offset — RFC 33392026-09-09T10:14:22.481920— with fractional seconds
The first is the historical default and the most troublesome, because a timestamp without a timezone is ambiguous by construction. A parser has to try each layout in turn, and when none matches, leave the value unset rather than substituting the current time — silently stamping upload wall-clock time onto a suite makes a report look fresh when it is not, and quietly corrupts any analysis that depends on when the run actually happened.
Durations are frequently missing or wrong
The suite-level time attribute is supposed to hold the suite’s total duration. It is often absent, sometimes unparseable, and sometimes present but zero.
Trusting it blindly means a whole suite reports as taking no time, which distorts every duration trend built on top of it. The reliable approach is to prefer the suite-level value when it parses to something greater than zero, and otherwise fall back to summing the durations of the suite’s own cases.
A single test case can carry several outcomes
Nothing in the format prevents a <testcase> from containing both a <failure> and a <skipped>, and real reports do it — usually when a framework marks a test skipped during teardown after it had already failed.
Consumers need an explicit precedence order, and the only defensible one is by severity: failure, then error, then skipped, then passed. Taking whichever element appears first in document order means a report that says both “failed” and “skipped” can roll up as skipped, which turns a red build green. Of all the ways to get this format wrong, that is the one that actually costs money.
Retries are not part of the format
JUnit XML has no concept of a retry. It records the outcome of a test, not the history of attempts that produced it.
Since retries are ubiquitous in modern CI, tools improvise using <properties>, the format’s general-purpose extension point. Some write a property named retries, others retryCount. There is no convention, only convergence-by-accident on those two names.
There is one real exception worth knowing, and it cuts against the blanket claim: Surefire’s XSD defines rerunFailure, flakyFailure and flakyError elements, so a Surefire-produced file genuinely can carry retry information. Nothing outside Surefire’s dialect is obliged to write or understand them, which makes this a local extension rather than a capability of JUnit XML — but it is a good illustration of how the format actually evolves. A dialect invents what it needs, and everyone else keeps improvising.
This has a direct consequence for flaky test detection: a single JUnit XML file usually cannot tell you whether a test is flaky. Even when a retry property is present, “passed after two retries” is a property of one run. Flakiness is a property of a test’s behaviour across many runs, which is why detection has to be built on stored history rather than on parsing a report harder. Google’s 2016 analysis of its own test corpus found that almost 16% of its tests showed some level of flakiness, and none of that would be visible from any single result file.
The same limitation applies to shard identity. When a suite is split across parallel runners, nothing in the format says which shard ran which test. Playwright exposes a worker index natively; other runners can be coaxed into writing a shard property through mechanisms like pytest’s record_property. Both are conventions layered on top of a format that has no opinion on the subject. If you are sharding a suite across runners, reassembling it afterwards is your problem, not the format’s.
Encoding is declared but not guaranteed to be UTF-8
The XML prolog can declare any encoding, and files in the wild do — ISO-8859-1 turns up regularly from older Java toolchains, and UTF-16 occasionally from Windows runners.
A consumer that assumes UTF-8 will hard-fail on the entire file, which in a multi-file upload can mean discarding an otherwise complete run because one report came from a legacy runner. Honouring the declared encoding costs very little and prevents an all-or-nothing failure.
What JUnit XML cannot express
Beyond the divergences, there are things the format simply has no vocabulary for. Knowing the boundary matters, because most of the frustration people have with JUnit XML comes from expecting it to carry information it was never designed to hold.
- Retries and flakiness. Covered above. No native representation.
- Attachments. Screenshots, videos, traces, HAR files. Some tools encode paths into
system-out; nothing standardizes it. - Steps. A test is atomic. BDD scenarios, Playwright steps, and Newman’s per-request assertions all have internal structure that flattens away.
- Shard and worker identity. Property conventions only.
- History. The format describes one run. Every question worth asking about a test suite — is this getting slower, is this newly failing, has this always been unreliable — is a question about many runs.
That last one is the important one, and it is not a defect in the format. JUnit XML is an interchange format for a single execution, and it does that job well enough to have outlived every alternative proposed since. It was never trying to be a database.
How to produce JUnit XML from each framework
Most frameworks can emit it, usually via a flag or a reporter package:
| Framework | How to emit JUnit XML |
|---|---|
| pytest | pytest --junitxml=report.xml |
| PHPUnit | phpunit --log-junit report.xml |
| Playwright | --reporter=junit, path via PLAYWRIGHT_JUNIT_OUTPUT_NAME |
| Jest / Vitest | jest-junit reporter |
| Go | gotestsum --junitfile report.xml, or go test -json | go-junit-report |
| RSpec | rspec_junit_formatter gem |
| Newman | newman run … -r junit |
| Maven Surefire | Automatic, in target/surefire-reports/ |
| Gradle | Automatic, in build/test-results/test/ |
| TestNG | Native testng-results.xml, plus JUnit XML via Surefire |
| Maestro | maestro test --format junit |
| Espresso | Emitted natively by the Gradle test tasks |
| XCTest / XCUITest | Convert the .xcresult bundle, e.g. with xcresultparser |
A caution on that last row: the tool most tutorials still recommend for .xcresult conversion, fastlane-community/trainer, has been unmaintained for years. Check the maintenance status of any converter before you build a pipeline on it — this is a corner of the ecosystem where a lot of published advice has quietly gone stale.
How to validate a JUnit XML file
There is no authoritative validator, because there is no authoritative schema. What you can do:
- Validate the XML itself.
xmllint --noout report.xmlcatches malformed documents, which is a surprising share of real problems — an unescaped character in a stack trace will do it. - Validate against Surefire’s XSD if you are producing Surefire-flavoured output. It will reject legitimate files from other writers, so treat failures as informational unless Surefire is your target.
- Check counts against cases. Compare the header attributes to the number of
<testcase>elements you can actually find, including nested suites. A mismatch means either the writer’s counters are wrong or your reader is missing tests, and both are worth knowing. - Round-trip it through your actual consumer. Ultimately the only validation that matters is whether the system you are feeding reports the numbers you expect.
When to use something else
JUnit XML is the right default for interoperability, and will remain so for years, because it is the only format with universal support. Reach for something else when you specifically need what it cannot carry.
CTRF, the Common Test Report Format, is a JSON format designed with retries, flakiness, and richer metadata as first-class fields rather than property conventions. Your framework’s native JSON output — Playwright’s, Jest’s, go test -json — is generally the richest thing available and the least portable. Many teams emit both: JUnit XML for the CI system’s built-in display, and something richer for whatever actually analyses the results. A side-by-side comparison of what each format can express is the fastest way to pick.
The most interesting development here is that JUnit itself is building a replacement. Open Test Reporting is a language-agnostic XML and HTML reporting format maintained by the JUnit team, designed with a schema and attachment support from the start. If JUnit XML is eventually displaced, there is a reasonable chance it is displaced by JUnit rather than by anyone else.
If you are aggregating results from several frameworks at once, the format question gets sharper, because you are reconciling several dialects rather than one. That problem is worth reading about separately in the context of unifying multi-framework test results and aggregating tests across a monorepo.
Where this leaves you
The honest summary of JUnit XML is that it is a folk format that works. It has no specification, its dialects diverge in eight documented and several undocumented ways, and it cannot express half of what a modern test run produces. It is also supported everywhere, which beats every technically superior alternative that isn’t.
If you are consuming it, the defensive posture is: accept both root elements, recurse into nested suites, derive counts from cases rather than headers, try multiple timestamp layouts and give up gracefully, fall back on summed case durations, resolve multiple outcomes by severity, treat retry properties as a convention rather than a guarantee, and honour the declared encoding. That list is not theoretical — it is what a parser needs to survive contact with real CI output.
For context on where this fits: Qualflare — our product — is a test observability layer that ingests results from CI and analyses them across runs. It does not run your tests, and it is not a device cloud. Its CLI registers 26 result formats, and five of those — generic JUnit, TestNG, Maestro, XCTest, and Espresso — are thin wrappers over one shared JUnit XML implementation, which is where most of the divergences documented above were found the hard way. pytest and PHPUnit both nominally emit JUnit XML and both needed their own parsers anyway, which tells you most of what you need to know about how standard the standard is.
The genuinely useful thing to take from this is not a parsing checklist, though. It is that the format describes one run, and almost every question you actually want answered — is this test flaky, is this suite getting slower, is this failure new — requires comparing many runs. The file format is the transport. It was never the answer.
Frequently asked questions
Is there an official JUnit XML schema?
No. There is no official specification published by JUnit itself. The format originated as output from Apache Ant’s JUnit task and spread by imitation. The closest things to a normative schema are Maven Surefire’s published XSD, which describes the dialect Surefire writes, and community reference documentation such as the testmoapp/junitxml repository. Tools that consume the format generally parse it tolerantly rather than validating it.
What is the difference between <testsuites> and <testsuite>?
<testsuites> is a container holding one or more <testsuite> elements, and <testsuite> is a single suite holding <testcase> elements. Some writers always wrap output in <testsuites>; others emit a bare <testsuite> as the root when there is only one suite. A robust parser has to accept both, because which one you get depends entirely on the tool that wrote the file.
What is the difference between <failure> and <error> in JUnit XML?
By convention, <failure> means an assertion did not hold — the test ran and the result was wrong. <error> means the test could not complete, for example because of an unhandled exception, a timeout, or a setup problem. Not every writer respects the distinction, and a single <testcase> can carry more than one outcome element, so consumers need a defined precedence order rather than trusting the first one they find.
How do you record a retry or a flaky test in JUnit XML?
There is no standard way. The format has no concept of a retry, so tools improvise using the <properties> extension point — commonly a property named retries or retryCount, though the name is not standardized. This is why flaky-test detection generally cannot be done from a single JUnit XML file and has to be derived from the pass/fail history of the same test across many runs.
Which frameworks can emit JUnit XML?
Almost all of them, usually through a reporter or a flag: pytest with --junitxml, PHPUnit with --log-junit, Playwright with its junit reporter, Jest through jest-junit, Go through gotestsum or go-junit-report, RSpec through rspec_junit_formatter, Newman with -r junit, Maestro with --format junit, and Maven Surefire and Gradle automatically. The files they produce are all called JUnit XML and none of them are quite identical.
Should you still use JUnit XML in 2026?
For interoperability, yes — it is the one format nearly every CI system and reporting tool already accepts, and that ubiquity is worth more than elegance. For anything the format cannot express, such as retries, attachments, or step-level detail, expect to supplement it with a richer format like CTRF or with your framework’s native JSON output.
Sources
- Apache Ant — JUnitReport Task
- Maven Surefire — surefire-test-report.xsd
- testmoapp/junitxml — JUnit XML reference documentation
- pytest — Creating JUnitXML format files
- Jenkins — JUnit plugin
- Google Testing Blog — Flaky Tests at Google and How We Mitigate Them (2016)
- ota4j-team/open-test-reporting — maintained by the JUnit team
- Ministry of Testing — JUnit-style XML file spec (community thread)


