{"title":"Find Flaky Tests by Comparing JUnit XML Runs","excerpt":"A single JUnit report can record a failed-then-passed test as a plain pass. Three real Playwright builds show what each producer writes for a retry and how comparing runs exposes the flake.","canonicalUrl":"https://automationtester.in/blog/tools-reviews/find-flaky-tests-across-junit-xml-runs","category":{"name":"Tools & Reviews","slug":"tools-reviews"},"tags":["junit","flaky-tests","ci-debugging","test-reports"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-10-23T05:30:00.000Z","dateModified":"2026-10-23T05:30:00.000Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/find-flaky-tests-across-junit-xml-runs.webp","alt":"Three report sheets with matching rows; one row reads green, orange, green and is pulled out to a ringed green node, while a row that is orange in every sheet flows into its own orange panel."},"contentHtml":"<div class=\"callout callout-info\"> Load the JUnit XML report from each of several CI builds into the <a href=\"https://automationtester.in/tools/junit-xml-viewer\">JUnit XML Viewer</a>, one run per build. It lists tests that passed in one build and failed in another, with a pass/fail sequence per test, and keeps tests that failed in every build in a separate list. Read the retry elements too: a single report can record a test that failed and then passed as an ordinary pass.</div>\n\n<p>A Playwright build finishes with one red test, <code>rejects an expired card</code>. The team fixes it and moves on. The same report also contains <code>applies a coupon</code>, which failed its first attempt, passed on retry, and appears in the XML as a passing <code>&lt;testcase&gt;</code> with no <code>&lt;failure&gt;</code> child. Nothing in that file says it was ever red. The only way to see it is to compare it with other builds, or to make the reporter keep the failed attempt.</p>\n\n<h2 id=\"retry-records-by-producer\">How each producer writes a retried test</h2>\n\n<p>There is no single JUnit XML standard. The Ant JUnit task made the format common; Maven Surefire publishes its own XSD; the rest follow the Ant shape loosely. What differs most, and matters most for flakes, is what happens to a failed attempt that is followed by a pass.</p>\n\n<table>\n<thead><tr><th>Producer</th><th>Failed attempt, then a pass</th><th>Recorded <code>time</code></th></tr></thead>\n<tbody>\n<tr><td>Maven Surefire and Failsafe with <code>rerunFailingTestsCount</code></td><td>One <code>&lt;testcase&gt;</code> with a <code>&lt;flakyFailure&gt;</code> or <code>&lt;flakyError&gt;</code> per failed attempt; the XSD also defines a <code>flakes</code> count on the suite</td><td>The last successful run</td></tr>\n<tr><td>Maven Surefire, every rerun fails</td><td><code>&lt;failure&gt;</code> for the first run plus one <code>&lt;rerunFailure&gt;</code> per rerun</td><td>The first failing run</td></tr>\n<tr><td>Gradle, default</td><td>Each execution is a separate <code>&lt;testcase&gt;</code> with the same name</td><td>Per execution</td></tr>\n<tr><td>Gradle with <code>mergeReruns = true</code></td><td>One <code>&lt;testcase&gt;</code> with <code>&lt;flakyFailure&gt;</code>, the Surefire shape</td><td>Not stated in the manual</td></tr>\n<tr><td>Playwright <code>junit</code> reporter, default</td><td>A plain passing <code>&lt;testcase&gt;</code></td><td>The sum of every attempt</td></tr>\n</tbody>\n</table>\n\n<p>Two details in that table cause most misreadings. In Surefire's schema, a rerun's trace sits inside a <code>&lt;stackTrace&gt;</code> child element, while the first failure's trace is the <code>&lt;failure&gt;</code> element's own text, so a parser that only reads element text finds empty reruns. And Gradle warns that a CI server that does not understand <code>&lt;flakyFailure&gt;</code> \"will indicate that the test succeeded\". The manual names Jenkins' JUnit plugin, which leaves those failed executions out of its test result views.</p>\n\n<p>Other producers differ in ways that affect matching rather than retries. pytest wraps a single <code>&lt;testsuite&gt;</code> in a <code>&lt;testsuites&gt;</code> root and, when a test fails in its call and then errors in teardown, writes two <code>&lt;testcase&gt;</code> elements for it. Its <code>junit_duration_report</code> setting defaults to <code>total</code>, so a test's time includes setup and teardown. Vitest names each case with its ancestor <code>describe</code> blocks joined by <code> &gt; </code>; jest-junit's default <code>titleTemplate</code> is <code>{classname} {title}</code>. Change either template between builds and the same test gets a new identity.</p>\n\n<h2 id=\"three-playwright-builds\">Three real Playwright builds, compared</h2>\n\n<p>The reports below came from Playwright 1.62.1 with <code>retries: 1</code>. A <code>BUILD</code> variable stands in for a coupon service that is sometimes slow: build 1 fails the coupon test's first attempt only, build 2 fails both attempts, and build 3 never fails. The expired-card test is a real bug and fails every time.</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// playwright.config.ts\nexport default defineConfig({\n  retries: 1,\n  reporter: [[\"junit\", { outputFile: `results/build-${process.env.BUILD}.xml` }]],\n})\n\n// tests/checkout.spec.ts\ntest(\"applies a coupon\", async ({}, testInfo) =&gt; {\n  const fails = (build === 1 &amp;&amp; testInfo.retry === 0) || build === 2\n  await new Promise(resolve =&gt; setTimeout(resolve, fails ? 2000 : 300))\n  expect(fails ? \"timeout\" : \"applied\", \"coupon status\").toBe(\"applied\")\n})</code></pre>\n\n<pre class=\"language-bash\"><code class=\"language-bash\">for b in 1 2 3; do BUILD=$b npx playwright test; done</code></pre>\n\n<p>Build 1's report records the flaky test like this:</p>\n\n<pre class=\"language-xml\"><code class=\"language-xml\">&lt;testcase name=\"checkout › applies a coupon\" classname=\"checkout.spec.ts\" time=\"2.306\"&gt;\n&lt;system-out&gt;\n&lt;![CDATA[\n[[ATTACHMENT|../test-results/checkout-checkout-applies-a-coupon/error-context.md]]\n]]&gt;\n&lt;/system-out&gt;\n&lt;/testcase&gt;</code></pre>\n\n<p>No failure element. The only traces of the failed attempt are the attachment line for its error context and the duration: 2.306 seconds is the 2-second failed attempt plus the 0.3-second pass. A duration dashboard built from this file would call the coupon test slow, when its passing run took 0.3 seconds.</p>\n\n<p>Loading the three files into the viewer, one run each, and copying its Markdown summary gave these lines:</p>\n\n<pre class=\"language-markdown\"><code class=\"language-markdown\">| Run | Files | Tests | Passed | Failed | Errors | Skipped | Passed on retry | Time |\n| Run 1 | 1 | 3 | 2 | 1 | 0 | 0 | 0 | 2.31 s |\n| Run 2 | 1 | 3 | 1 | 2 | 0 | 0 | 0 | 4.03 s |\n| Run 3 | 1 | 3 | 2 | 1 | 0 | 0 | 0 | 308 ms |\n\n## Flaky candidates\n- `P F P` checkout.spec.ts › checkout › applies a coupon: coupon status\n\n## Consistent failures\n- checkout.spec.ts › checkout › rejects an expired card: payment status for card expiring 01/24</code></pre>\n\n<p>The comparison catches the flake because build 2 failed it outright. Build 1's hidden retry still reads as <code>P</code>. The Playwright 1.62 and 1.63 source accepts an <code>includeRetries</code> reporter option that writes Surefire-style <code>&lt;flakyFailure&gt;</code> elements, although the options table on the reporters page does not list it. Rerunning build 1 with <code>includeRetries: true</code> changed the testcase's time to <code>0.307</code>, added a <code>&lt;flakyFailure message=\"coupon status\" type=\"expect.toBe\"&gt;</code>, and the sequence became <code>R F P</code>, with run 1 counted under \"Passed on retry\". Because the option is undocumented, test it on each Playwright upgrade before a gate depends on it.</p>\n\n<h2 id=\"reading-the-viewer\">Reading a set of reports in the viewer</h2>\n\n<ol>\n<li>Choose how a selection of files maps to runs. Surefire and Gradle write one file per test class, so a build is many files. The default grouping puts files that share no test ID into one run and starts a new run when a file repeats IDs. That splits <code>build1/TEST-*.xml</code> and <code>build2/TEST-*.xml</code> correctly when both are selected together. When every file is already a complete build, \"One run per file\" is the unambiguous choice.</li>\n<li>Select the files, drop them on the page, or paste one report. Each addition becomes one or more runs, in the order added. Order matters, because the sequence reads oldest to newest.</li>\n<li>Read \"Flaky candidates\" first. <code>R</code> means the run itself recorded a failed attempt followed by a pass; <code>P F</code> patterns across runs are the ones a single report cannot show.</li>\n<li>Treat \"Fails in every run\" as a bug list, not a flake list. Retrying a test that fails every time only delays the red build.</li>\n<li>Use \"Failures and errors\" for the messages. Traces are trimmed by hiding JUnit, Surefire, Gradle, reflection, <code>node_modules</code> and pytest frames; the full text and <code>system-out</code> sit under each entry.</li>\n<li>Copy the Markdown summary into the pull request or issue, or download the JSON for a script.</li>\n</ol>\n\n<p>Parsing uses the browser's <code>DOMParser</code>, and the comparison rules are fixed. Nothing is uploaded. A malformed file is rejected with its line and column, and the other files still load.</p>\n\n<h2 id=\"matching-limits\">Where the comparison misleads</h2>\n\n<p>Tests are matched by <code>classname</code> plus <code>name</code>. A parameterised name that includes a timestamp, a random port, or a generated ID is a different test in every build, so its failures never line up and it never appears as flaky. Playwright's <code>includeProjectInTestName</code> and Vitest's name templates change identity the same way when they differ between builds.</p>\n\n<p>\"Fails in every run\" only means every run you loaded. Three builds taken during a sandbox outage will list environment failures there. A test missing from any run is left out of that list, and skipped runs count as neither pass nor fail.</p>\n\n<p>Durations are not comparable across producers. Surefire reports a flaky test's last successful run, Playwright's default sums attempts, and pytest includes fixtures unless <code>junit_duration_report = call</code>. Rank slow tests within one toolchain.</p>\n\n<p>Large reports are parsed in memory. Twenty thousand test cases parse in well under a second in tests, but a file of hundreds of megabytes is limited by the tab's memory, and files over 100 MB are refused.</p>\n\n<h2 id=\"junit-viewer-questions\">Questions about comparing JUnit reports</h2>\n\n<p><strong>Does a green build mean no test failed?</strong> No. With Surefire reruns, Gradle's <code>mergeReruns</code>, or Playwright retries, a test that failed and then passed leaves the build green. Surefire at least prints a <code>Flakes:</code> count in its summary line, and <code>failOnFlakeCount</code>, available since 3.0.0-M6, can fail the build on it.</p>\n\n<p><strong>Should Gradle's <code>mergeReruns</code> be on?</strong> Only if something reads <code>&lt;flakyFailure&gt;</code>. With it off, the failed execution stays visible as its own testcase, which the viewer reads as <code>R</code> for that run; with it on, a CI server that ignores the element shows a clean pass.</p>\n\n<p><strong>How many builds are enough?</strong> Enough to include the conditions the failure depends on. A flake that needs a slow dependency will not appear in three builds on a quiet day; a longer window, or builds on different runners, is a better sample than more builds in the same hour.</p>\n\n<h2 id=\"compare-this-week\">Compare this week's builds</h2>\n\n<p>Download the JUnit XML artifact from the last five builds on your main branch and load them into the <a href=\"https://automationtester.in/tools/junit-xml-viewer\">JUnit XML Viewer</a> as five runs. Save the Markdown summary with the build numbers. For each flaky candidate, paste its failure message into <a href=\"https://automationtester.in/tools/test-failure-triage\">Test Failure Triage</a> before editing the test. If you run Playwright, the guide to <a href=\"https://automationtester.in/blog/automation-tutorials/fail-ci-flaky-playwright-tests-evidence\">failing CI on flaky Playwright tests without hiding evidence</a> covers the gate; on the JVM, <a href=\"https://automationtester.in/blog/automation-tutorials/junit-failure-reports-useful-ci\">making JUnit failures useful in CI</a> covers stable names and retry configuration, and <a href=\"https://automationtester.in/blog/automation-tutorials/debug-junit-parallel-execution-flakes\">debugging JUnit parallel-execution flakes</a> covers the common cause once you have the list.</p>\n\n<h2 id=\"junit-xml-references\">JUnit XML format references</h2>\n<ul>\n<li><a href=\"https://maven.apache.org/surefire/maven-surefire-plugin/xsd/surefire-test-report.xsd\">Maven Surefire test report XSD</a> (version 3.0.2): the bare <code>testsuite</code> root, the <code>flakes</code> attribute, and <code>flakyFailure</code>, <code>flakyError</code>, <code>rerunFailure</code> and <code>rerunError</code> with a <code>stackTrace</code> child.</li>\n<li><a href=\"https://maven.apache.org/surefire/maven-surefire-plugin/examples/rerun-failing-tests.html\">Maven Surefire 3.6.0: Rerun Failing Tests</a>: which elements a flaky and an always-failing test produce, which run's time is recorded in each case, and <code>failOnFlakeCount</code> since 3.0.0-M6.</li>\n<li><a href=\"https://maven.apache.org/surefire/maven-failsafe-plugin/integration-test-mojo.html\">Maven Failsafe 3.6.0: failsafe:integration-test</a>: <code>rerunFailingTestsCount</code>, with every failing attempt recorded.</li>\n<li><a href=\"https://github.com/windyroad/JUnit-Schema\">JUnit XML schema for the Apache Ant JUnit and JUnitReport tasks</a>: the Ant-derived <code>testsuites</code> aggregate, with no retry elements.</li>\n<li><a href=\"https://docs.gradle.org/current/userguide/java_testing.html\">Gradle 9.8.0 user manual: Testing in Java and JVM projects</a>: the \"JUnit XML\" pseudo standard, one file per test class, separate testcases per execution by default, and <code>mergeReruns</code>.</li>\n<li><a href=\"https://docs.junit.org/current/advanced-topics/junit-platform-reporting.html\">JUnit 6.1.3 user guide: JUnit Platform Reporting</a>: the legacy XML listener, compatible with the Ant-era de facto standard and used by the Console Launcher.</li>\n<li><a href=\"https://docs.pytest.org/en/stable/how-to/output.html\">pytest: Managing output</a> and <a href=\"https://docs.pytest.org/en/stable/reference/reference.html\">pytest configuration reference</a>: <code>--junit-xml</code>, <code>junit_family</code> defaulting to <code>xunit2</code>, and <code>junit_duration_report</code>.</li>\n<li><a href=\"https://github.com/pytest-dev/pytest/blob/9.1.1/src/_pytest/junitxml.py\">pytest 9.1.1 junitxml.py</a>: the <code>testsuites</code> wrapper and the separate testcase opened for a call failure followed by a teardown error.</li>\n<li><a href=\"https://playwright.dev/docs/test-reporters\">Playwright: Reporters</a> and <a href=\"https://playwright.dev/docs/test-retries\">Playwright: Test retries</a>: documented JUnit reporter options, and the definition of flaky as failing first and passing on retry.</li>\n<li><a href=\"https://github.com/microsoft/playwright/blob/v1.63.0/packages/playwright/src/reporters/junit.ts\">Playwright 1.63.0 JUnit reporter source</a>: summed attempt durations by default and the <code>includeRetries</code> branch.</li>\n<li><a href=\"https://vitest.dev/guide/reporters\">Vitest: Reporters</a> and <a href=\"https://github.com/jest-community/jest-junit\">jest-junit README</a>: root names and the default test name templates.</li>\n</ul>"}