{"title":"Make JUnit Failures Useful in CI","excerpt":"A red CI job that prints AssertionFailedError and a line number forces the next engineer to rebuild the scenario by hand. Using a parameterized contract test for GET /orders/{id}, this guide covers why Gradle's LIFECYCLE console omits assertion messages, keeping timestamps and generated ids out of test names, path/expected/actual messages with Supplier and assertAll, TestReporter entries and sanitized file attachments that Gradle 9.4 now carries into HTML and XML, redacting before anything reaches stdout, and why mergeReruns can make a flaky failure read as an ordinary pass.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/junit-failure-reports-useful-ci","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["junit","gradle","java","ci-cd","test-reporting","flaky-tests","contract-testing"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-10-02T13:30:00.000Z","dateModified":"2026-10-14T11:09:52.936Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/junit-failure-reports-useful-ci.webp","alt":"Dark technical illustration read left to right. On the left, five tall grey panels stand side by side, the two leftmost fading off the edge, each holding a column of short horizontal pills. A thick straight green line runs through all five panels at the same height, with a green dot where it crosses each one. In the three brighter panels a single orange pill sits at a different height in each, and dashed orange segments zig-zag between them before curving down to a crossed-out orange ring near the bottom. The green line ends in an arrowhead at a large ringed green dot in the centre. Above the dot, a green panel holds three stacked pills of shrinking length; below it, a dotted tether drops to a larger green panel of paired pills in which two of the right-hand pills are replaced by solid grey blocks. To the right, the green line enters a dashed green rounded boundary containing an orange ringed dot and a green ringed dot side by side, each with a pill beneath, then leaves the boundary and runs to an arrowhead at the right edge. Below the boundary, four small green dots each lead a fading green pill, and thin grey rules and faint pills sit at the top and bottom of the frame."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> A useful CI failure carries four things without anyone opening the source: an identity that is the same on every run, a message naming path, expectation and actual value, evidence attached to that test rather than a log, and an outcome that separates \"failed, then passed\" from \"passed\". In Gradle: set <code class=\"language-text\">exceptionFormat = 'full'</code> (the default console drops your assertion message), build names from fixed case slugs, attach sanitized evidence with <code class=\"language-text\">TestReporter</code> (captured into reports since Gradle 9.4), and make sure something reads the flaky outcome of a retry.</div>\n\n<p>Here is a failed contract test as it appears in a CI console, in full:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">OrderContractTest &gt; contract(Order) &gt; [2] order = Order[id=ord_7f3a9c21, createdAt=2026-09-30T08:14:22.391Z, lines=3] FAILED\n    org.opentest4j.AssertionFailedError at OrderContractTest.java:47</code></pre>\n\n<p>The identity contains a generated id and a timestamp, so this failure has no history: yesterday's run of the same case had a different name, and a dashboard keyed on names sees a new test that failed once. There is no endpoint, no field, no expected value, and not even the assertion message, although the test may well have had one.</p>\n\n<p>The fix is a handful of decisions made once, in the build and in a shared helper, so individual tests get them for free. The worked example is a parameterized contract test for <code class=\"language-text\">GET /orders/{id}</code>, where one case, an order stored before currencies were recorded, comes back without <code class=\"language-text\">$.total.currency</code>.</p>\n\n<div class=\"callout callout-warning\"><strong>Versions checked on 2026-09-30.</strong> JUnit 6.1.3, the current release (Platform, Jupiter and Vintage), which requires Java 17 or later at runtime; Gradle 9.8.0, released 2026-09-29; the Gradle Test Retry plugin <code class=\"language-text\">org.gradle.test-retry</code> 1.6.6; Maven Surefire 3.6.0. Two behaviours below are recent enough to depend on your versions: file and directory attachments on <code class=\"language-text\">TestReporter</code> arrived in Jupiter 5.12, and Gradle only started carrying <code class=\"language-text\">TestReporter</code> data into its HTML and XML reports in 9.4.0.</div>\n\n<h2 id=\"the-default-console-drops-your-assertion-message\">The default console drops your assertion message</h2>\n\n<p>Gradle logs test events per log level, and CI runs at <code class=\"language-text\">LIFECYCLE</code>. At that level the documented default is \"Test failures\", with \"Exception format is SHORT\". The Javadoc for <code class=\"language-text\">TestExceptionFormat.SHORT</code> is precise about what that leaves out: it \"shows the exception types and locations as well as the hierarchy of causes, but not exception messages or full stack traces.\"</p>\n\n<p>So the assertion message never reaches the console by default. The XML and the HTML report under <code class=\"language-text\">build/reports/tests</code> have it, but the first thing anyone reads when a pull request goes red is the job log, which says <code class=\"language-text\">AssertionFailedError at OrderContractTest.java:47</code>. <code class=\"language-text\">FULL</code> is only the default at <code class=\"language-text\">INFO</code> and <code class=\"language-text\">DEBUG</code>, and nobody runs CI at those levels.</p>\n\n<p>Set it once, for every test task:</p>\n\n<pre class=\"language-groovy\"><code class=\"language-groovy\">tasks.withType(Test).configureEach {\n    testLogging {\n        events 'failed', 'skipped'\n        exceptionFormat = 'full'\n        // Deliberately not showStandardStreams: see the section on stdout.\n    }\n    reports.junitXml {\n        outputPerTestCase = true\n        mergeReruns = true\n    }\n}</code></pre>\n\n<p>The cost is log volume on a bad day: a hundred failures print a hundred messages and stack traces. That is the right trade against a hundred people opening a report to read a message that could have been in the log.</p>\n\n<p><code class=\"language-text\">outputPerTestCase</code> \"associates any output logging generated during a test case to that test case in the results\"; with it off, the default, output belongs to the class as a whole, so the lines printed by the failing invocation sit beside the lines from every passing one. <code class=\"language-text\">mergeReruns</code> is less harmless than it looks; it gets its own section below.</p>\n\n<h2 id=\"identity-that-is-the-same-on-every-run\">Identity that is the same on every run</h2>\n\n<p>The reported name is a test's key everywhere downstream: CI history, flaky-test dashboards, \"failing since\" columns, and Gradle's own rerun handling. Gradle's documentation for <code class=\"language-text\">mergeReruns</code> says so directly: \"Tests are grouped and merged based on their reported name\", and it asks that names from parameterization or \"any other kind of mechanism that produces a potentially dynamic test name\" be kept stable.</p>\n\n<p>The opening example breaks that rule without anyone deciding to. Its argument source builds orders with a random id and <code class=\"language-text\">Instant.now()</code>, and the default display name prints the index and each argument's string form. <code class=\"language-text\">Order</code> is a record, and a record's implicitly declared <code class=\"language-text\">toString()</code> is, in the Java SE API's words, \"derived from all of the component fields\", so the identity of the test is now partly a clock reading.</p>\n\n<pre class=\"language-java\"><code class=\"language-java\">// Wrong: the display name is derived from generated data.\nstatic Stream&lt;Order&gt; orders() {\n    return Stream.of(\n            Order.random(),\n            Order.legacy(Instant.now()),   // prints createdAt into the test name\n            Order.withLines(3));\n}\n\n@ParameterizedTest\n@MethodSource(\"orders\")\nvoid contract(Order order) {\n    var response = client.get(\"/orders/\" + order.id());\n    assertTrue(response.body().contains(\"\\\"currency\\\"\"));\n}</code></pre>\n\n<p>Part two of this series covered <a href=\"/blog/junit-parameterized-tests-without-matrix-explosion\">naming rows with <code class=\"language-text\">argumentSet</code></a> so that a reorder does not change what a name points at. CI needs a stricter version of that rule: build the name only from things written in the source file. A case slug qualifies. A generated id, a timestamp, a port, a container hostname or a random seed never does, however useful while debugging. Those values belong in the attached evidence, not in the key, where they fragment history.</p>\n\n<pre class=\"language-java\"><code class=\"language-java\">static Stream&lt;Arguments&gt; storedOrders() {\n    return Stream.of(\n            argumentSet(\"complete-order\",   StoredOrder.complete(\"ord_complete\")),\n            argumentSet(\"multi-line-order\", StoredOrder.withLines(\"ord_three_lines\", 3)),\n            argumentSet(\"missing-currency\", StoredOrder.legacyWithoutCurrency(\"ord_legacy_no_ccy\")));\n}\n\n@ParameterizedTest(name = \"GET /orders/'{id}' [{argumentSetName}]\")\n@MethodSource(\"storedOrders\")\nvoid honoursTheOrderContract(StoredOrder stored, TestReporter reporter) throws Exception {\n    // body in the next two sections\n}</code></pre>\n\n<p>One detail in that <code class=\"language-text\">name</code> attribute will bite anyone who copies an endpoint template into it. The JUnit guide states that the attribute \"is a MessageFormat pattern\", and in <code class=\"language-text\">MessageFormat</code> a brace pair is a format element whose index must be digits. To print a literal <code class=\"language-text\">{id}</code>, quote it: the <code class=\"language-text\">MessageFormat</code> Javadoc gives the rule as \"pattern string <code class=\"language-text\">\"'{0}'\"</code> represents string <code class=\"language-text\">\"{0}\"</code>, not a FormatElement\". The rendered name is then <code class=\"language-text\">GET /orders/{id} [missing-currency]</code>, which is exactly what someone will paste into a search box.</p>\n\n<h2 id=\"a-message-that-answers-path-expected-actual\">A message that answers path, expected and actual</h2>\n\n<p>The failure message has one job: say which part of the contract broke, what it requires, and what came back. Aim for a record a reviewer can read without the code:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">test:       GET /orders/{id} [missing-currency]\npath:       $.total.currency\nexpected:   required string\nactual:     absent\nattachment: order-missing-currency.json (sanitized response)</code></pre>\n\n<p>Build that once as a helper, rather than trusting each test author to format it:</p>\n\n<pre class=\"language-java\"><code class=\"language-java\">import com.fasterxml.jackson.databind.JsonNode;   // Jackson 2.x\n\nfinal class Contract {\n\n    /** Fails with \"path: expected required string, actual …\". */\n    static void requiredString(JsonNode body, String jsonPath, String pointer) {\n        JsonNode value = body.at(pointer);\n        assertTrue(value.isTextual(), () -&gt; jsonPath\n                + \": expected required string, actual \"\n                + describe(value));\n    }\n\n    private static String describe(JsonNode value) {\n        if (value.isMissingNode()) return \"absent\";\n        if (value.isNull()) return \"null\";\n        return value.getNodeType().name().toLowerCase() + \" \" + value;\n    }\n}</code></pre>\n\n<p>The message is a <code class=\"language-text\">Supplier&lt;String&gt;</code> because Jupiter documents that a supplier \"is only evaluated when the assertion fails\", so the formatting work is paid for only on failures.</p>\n\n<p>A wrong response is often wrong in more than one place, and stopping at the first failed assertion hides the second until the next CI run. In <code class=\"language-text\">assertAll</code>, \"all assertions are executed, and all failures will be reported together\" under a heading you supply. Use the test identity as the heading.</p>\n\n<pre class=\"language-java\"><code class=\"language-java\">JsonNode body = json.readTree(response.body());\nassertAll(\"GET /orders/{id} [\" + stored.slug() + \"]\",\n        () -&gt; assertEquals(200, response.statusCode(), \"HTTP status\"),\n        () -&gt; Contract.requiredString(body, \"$.total.currency\", \"/total/currency\"),\n        () -&gt; Contract.requiredString(body, \"$.total.amount\", \"/total/amount\"),\n        () -&gt; Contract.requiredString(body, \"$.status\", \"/status\"));</code></pre>\n\n<p>The path is written twice, as a reader expects it and as the JSON Pointer Jackson's <code class=\"language-text\">at()</code> takes. If the expected shape lives in a JSON Schema, the <a href=\"/tools/json-schema\">JSON Schema generator</a> is a quick check that the fields your helper calls required match the schema's.</p>\n\n<p>One caution: <code class=\"language-text\">describe()</code> prints the actual value, and the message goes verbatim into the XML, the console and every CI log archive. For a currency code that is fine. For a field that can hold an email or a token, print type and length only.</p>\n\n<h2 id=\"attach-the-response-to-the-test-that-saw-it\">Attach the response to the test that saw it</h2>\n\n<p>The message says what broke; the response body says why. The wrong place for that body is standard output. The right place is <code class=\"language-text\">TestReporter</code>, which the user guide describes as the Jupiter replacement for printing: \"you should use TestReporter where you used to print information to stdout or stderr\".</p>\n\n<p><code class=\"language-text\">publishEntry</code> for key-value data has been there since Jupiter 5.0. <code class=\"language-text\">publishFile</code> and <code class=\"language-text\">publishDirectory</code>, which attach files to the current test, arrived in 5.12. In 5.14 the file overloads moved to <code class=\"language-text\">org.junit.jupiter.api.MediaType</code>; the ones taking <code class=\"language-text\">org.junit.jupiter.api.extension.MediaType</code> are deprecated for removal, so check that import after an upgrade.</p>\n\n<pre class=\"language-java\"><code class=\"language-java\">@ParameterizedTest(name = \"GET /orders/'{id}' [{argumentSetName}]\")\n@MethodSource(\"storedOrders\")\nvoid honoursTheOrderContract(StoredOrder stored, TestReporter reporter) throws Exception {\n    orders.seed(stored);\n    HttpResponse&lt;String&gt; response = client.get(\"/orders/\" + stored.id());\n\n    reporter.publishEntry(Map.of(\n            \"endpoint\", \"GET /orders/{id}\",\n            \"case\", stored.slug(),\n            \"orderId\", stored.id(),\n            \"status\", String.valueOf(response.statusCode()),\n            \"requestId\", response.headers().firstValue(\"x-request-id\").orElse(\"none\")));\n\n    try {\n        JsonNode body = json.readTree(response.body());\n        assertAll(\"GET /orders/{id} [\" + stored.slug() + \"]\",\n                () -&gt; assertEquals(200, response.statusCode(), \"HTTP status\"),\n                () -&gt; Contract.requiredString(body, \"$.total.currency\", \"/total/currency\"),\n                () -&gt; Contract.requiredString(body, \"$.total.amount\", \"/total/amount\"));\n    } catch (AssertionError failure) {\n        // Evidence only for failures, so passing runs do not fill the artefact store.\n        reporter.publishFile(\"order-\" + stored.slug() + \".json\", MediaType.APPLICATION_JSON,\n                file -&gt; Files.writeString(file, Redactor.redact(response.body())));\n        throw failure;\n    }\n}</code></pre>\n\n<p>The request id goes into an entry because it is the join key to server logs, and exactly the per-run value that must stay out of the name. The file is published only on failure, so the artefact store grows with problems rather than with the suite. The price is that a green run leaves nothing to compare against; for the few tests where \"what did it look like when it passed\" matters, publish unconditionally.</p>\n\n<h3 id=\"what-gradle-does-with-reporter-data\">What Gradle does with it, and since when</h3>\n\n<p>For years, Jupiter report entries could show up in an IDE but not in Gradle's reports. Gradle 9.4.0, released 2026-03-04, changed that: it \"now captures this metadata and integrates it directly into both the HTML test report and the XML test results\". The HTML report gains a <em>Data</em> tab for key-value entries and an <em>Attachments</em> tab linking to published files, and the release notes say some known media types, \"such as images and videos, are rendered directly\". In the JUnit XML, entries are \"mapped to <code class=\"language-text\">&lt;properties/&gt;</code>\" and each file is written in the form <code class=\"language-text\">[[ATTACHMENT|/path/to/file]]</code>, a convention those notes attribute to Jenkins, Azure Pipelines and GitLab.</p>\n\n<p>On an older Gradle the same code runs and the data goes nowhere a CI server can see. The alternative JUnit documents is the Open Test Reporting listener in <code class=\"language-text\">junit-platform-reporting</code>, off until <code class=\"language-text\">junit.platform.reporting.open.xml.enabled=true</code>, which writes one XML file that \"supports all features of the JUnit Platform such as hierarchical test structures, display names, tags, etc.\" It helps only if your CI server reads that format.</p>\n\n<p>Put together, the console line and the report tabs for the failing case read like this (the console lines are what <code class=\"language-text\">exceptionFormat = 'full'</code> prints; the last two rows are the HTML report's Data and Attachments tabs):</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">OrderContractTest &gt; honoursTheOrderContract(StoredOrder, TestReporter) &gt; GET /orders/{id} [missing-currency] FAILED\n    org.opentest4j.MultipleFailuresError: GET /orders/{id} [missing-currency] (1 failure)\n        org.opentest4j.AssertionFailedError: $.total.currency: expected required string, actual absent\n\n  Data         endpoint=GET /orders/{id}  case=missing-currency  orderId=ord_legacy_no_ccy\n               status=200  requestId=5c1e0b7a\n  Attachments  order-missing-currency.json</code></pre>\n\n<p>Nothing in the name changes between runs. Everything that does is in the data, attached to the invocation that produced it.</p>\n\n<h2 id=\"redact-before-anything-is-written\">Redact before anything is written</h2>\n\n<p>Gradle's JUnit XML includes standard output and standard error by default (<code class=\"language-text\">includeSystemOutLog</code> and <code class=\"language-text\">includeSystemErrLog</code> both default to <code class=\"language-text\">true</code>), so a <code class=\"language-text\">println</code> of a response in a test that authenticates will land in the result files, and from there in every CI artefact store and every tool that ingests them. <code class=\"language-text\">showStandardStreams</code> goes one step further and puts it in the job log. That is why the configuration above leaves it off.</p>\n\n<p>Richer formats carry richer leaks. Open Test Reporting can include Git information, which its README says to include \"only if the requesting user has opted in explicitly\" because it \"may include file names that contain personally identifiable or otherwise sensitive information\". JUnit's <code class=\"language-text\">junit.platform.reporting.open.xml.git.enabled</code> defaults to <code class=\"language-text\">false</code>; leave it there.</p>\n\n<p>Put redaction in one place, applied to every attachment before it is written, and never attach request headers at all, since that is where the <code class=\"language-text\">Authorization</code> value lives:</p>\n\n<pre class=\"language-java\"><code class=\"language-java\">final class Redactor {\n\n    private static final ObjectMapper MAPPER = new ObjectMapper();\n    private static final Pattern SENSITIVE =\n            Pattern.compile(\"(?i).*(token|secret|password|authorization|email|card).*\");\n\n    static String redact(String json) throws IOException {\n        JsonNode tree = MAPPER.readTree(json);\n        scrub(tree);\n        return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(tree);\n    }\n\n    private static void scrub(JsonNode node) {\n        if (node instanceof ObjectNode object) {\n            List&lt;String&gt; keys = new ArrayList&lt;&gt;();\n            object.fieldNames().forEachRemaining(keys::add);\n            for (String key : keys) {\n                if (SENSITIVE.matcher(key).matches()) {\n                    object.put(key, \"[redacted]\");\n                } else {\n                    scrub(object.get(key));\n                }\n            }\n        } else if (node instanceof ArrayNode array) {\n            array.forEach(Redactor::scrub);\n        }\n    }\n}</code></pre>\n\n<p>Redacting by key keeps the structure, which is the evidence here: the attachment still shows <code class=\"language-text\">total</code> with an <code class=\"language-text\">amount</code> and no <code class=\"language-text\">currency</code>. The weakness is that a denylist knows only the names it was given, so a new <code class=\"language-text\">apiKey</code> field passes through. If payloads often carry credentials, invert it into an allowlist of keys whose values may appear.</p>\n\n<h2 id=\"a-green-build-that-contains-a-failure\">A green build that contains a failure</h2>\n\n<p>Retries are where reports most often stop telling the truth, through two defaults that are each reasonable alone. The Gradle Test Retry plugin reruns failed tests after the rest of the task, and its <code class=\"language-text\">failOnPassedAfterRetry</code> setting \"defaults to <code class=\"language-text\">false</code>, which results in the task not failing if all tests pass on retry\". Without <code class=\"language-text\">mergeReruns</code>, the plugin's README states that \"each execution of a test is discretely reported in Gradle-generated XML and HTML reports\", so a CI server reading the XML may still count the failed one.</p>\n\n<p>Turn on <code class=\"language-text\">mergeReruns</code> and the failures are recorded \"as <code class=\"language-text\">&lt;flakyFailure&gt;</code> instead of <code class=\"language-text\">&lt;failure&gt;</code>, within one <code class=\"language-text\">&lt;testcase&gt;</code>\". Gradle then states the consequence: if your CI server does not understand that element, \"it will indicate that the test succeeded\". Maven Surefire writes the same shape when <code class=\"language-text\">rerunFailingTestsCount</code> is set, and records that \"the running time of a flaky test will be the running time of the last successful run\".</p>\n\n<p>So a test that failed on its first attempt yields a green build, a passing testcase and a duration from its good run. The failure is in the file, in an element nobody downstream reads.</p>\n\n<p>The README lists Jupiter 5.9.2 as the version it was tested against, noting that other versions \"are likely to work\", so push one deliberately failing test through it on 6.x before relying on it. For a Jupiter <code class=\"language-text\">@ParameterizedTest</code> \"the plugin will retry the entire method with all parameters including those that initially passed\". Retrying <code class=\"language-text\">[missing-currency]</code> reruns every case, and any name that differs between the first execution and the retry cannot be merged at all.</p>\n\n<p>Pick one of two honest configurations. The first keeps retries as a diagnostic and makes flakiness fail the build:</p>\n\n<pre class=\"language-groovy\"><code class=\"language-groovy\">plugins {\n    id 'org.gradle.test-retry' version '1.6.6'\n}\n\ntasks.withType(Test).configureEach {\n    retry {\n        maxRetries = 2\n        maxFailures = 10     // stop retrying when the database is down, not flaky\n        failOnPassedAfterRetry = true\n    }\n}</code></pre>\n\n<p>The build is red either way, but the report now separates \"fails every time\" from \"failed, then passed\": look at the code, or look for a race. <code class=\"language-text\">maxFailures</code> covers the README's case for not retrying at all, \"if a disk fills up or a required database is not available\". On Maven, <code class=\"language-text\">failOnFlakeCount</code>, available since Surefire 3.0.0-M6, fails the build once more than a given number of tests are flaky.</p>\n\n<p>The second accepts green-on-retry but refuses to let the flake vanish, by counting it straight from the XML after the test task:</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\">#!/usr/bin/env bash\n# Run after ./gradlew test, whatever its exit code.\nflaky=$(grep -l '&lt;flakyFailure' build/test-results/test/*.xml 2&gt;/dev/null | wc -l | tr -d ' ')\nif [ \"$flaky\" -gt 0 ]; then\n  echo \"::warning::$flaky result file(s) contain tests that passed only on retry\"\n  grep -h -o 'testcase name=\"[^\"]*\"' $(grep -l '&lt;flakyFailure' build/test-results/test/*.xml)\nfi</code></pre>\n\n<p>Crude, and enough: an element nobody reads becomes a job-summary line naming the tests. It works only because the names are stable. A flaky test whose name carries a timestamp is a different test every week, and nobody acts on a list of names that never repeat. To see those names across several builds rather than one, load each build's XML into the <a href=\"https://automationtester.in/tools/junit-xml-viewer\">JUnit XML Viewer</a>, which reads <code class=\"language-text\">&lt;flakyFailure&gt;</code> and prints every test's pass and fail sequence by build.</p>\n\n<h2 id=\"rebuild-one-red-build-from-the-report-alone\">Rebuild one red build from the report alone</h2>\n\n<p>Take your most recent failed CI run and, without opening the source or rerunning anything, answer four questions from what the job left behind: which case, which field, expected against actual, and whether it failed on the first attempt. The ones you could not answer are your backlog, in order.</p>\n\n<p>If the message is missing from the log, add <code class=\"language-text\">exceptionFormat = 'full'</code> today. Then grep a month of result files for names containing a timestamp or anything shaped like a UUID. Each hit is a test with no history; the fix is a case slug.</p>\n\n<p>Last, check whether retries are enabled anywhere, and if so, find out what reads <code class=\"language-text\">&lt;flakyFailure&gt;</code>. If nothing does, set <code class=\"language-text\">failOnPassedAfterRetry = true</code> or add the counting step in the same change. Keep one before-and-after pair of console lines for a real failure in the pull request, so whoever wants to revert a setting can see what it bought.</p>\n\n<h2 id=\"questions-about-junit-results-in-ci\">Questions about JUnit results in CI</h2>\n\n<h3 id=\"does-the-console-launcher-write-the-same-xml-as-gradle\">Does the Console Launcher write the same XML as Gradle?</h3>\n\n<p>It writes the legacy format: JUnit's <code class=\"language-text\">LegacyXmlReportGeneratingListener</code>, which the guide describes as \"compatible with the de facto standard for JUnit 4 based test reports that was made popular by the Ant build system\", producing a separate file per root in the test plan when you pass <code class=\"language-text\">--reports-dir</code>. Gradle writes its own XML in the same Ant-derived format, with options such as <code class=\"language-text\">mergeReruns</code> that the launcher does not have.</p>\n\n<h3 id=\"does-junit-capture-output-from-the-http-client-threads\">Why is some output missing from the report when capture is on?</h3>\n\n<p>Because JUnit's own capture, enabled with <code class=\"language-text\">junit.platform.output.capture.stdout</code>, records only \"output emitted by the thread that was used to execute a container or test\". Anything an HTTP client or an executor prints from its own threads is omitted, since it cannot be attributed to a test. Publish evidence through <code class=\"language-text\">TestReporter</code> from the test thread instead.</p>\n\n<h3 id=\"can-an-ai-model-triage-a-large-failure-list\">Can a language model triage a run with hundreds of failures?</h3>\n\n<p>It can cluster them usefully, provided the input is the structured result files after redaction rather than raw logs, and provided every cluster it reports links back to the exact test names it grouped. A summary that cannot be traced to test identities cannot be checked, and one built from unredacted logs has sent your tokens somewhere new.</p>\n\n<h3 id=\"is-merge-reruns-still-worth-enabling-given-all-that\">Is <code class=\"language-text\">mergeReruns</code> still worth enabling?</h3>\n\n<p>Yes, if something reads <code class=\"language-text\">&lt;flakyFailure&gt;</code>: a CI server that understands it, the counting step above, or Develocity, which Gradle notes detects flaky tests \"regardless of this setting\". Without one of those, merging makes a flake look like an ordinary pass, and leaving it off at least keeps the failed execution visible as its own testcase.</p>\n\n<h2 id=\"references-for-junit-ci-reporting\">Primary references</h2>\n\n<ul>\n<li><a href=\"https://docs.gradle.org/current/userguide/java_testing.html\">Gradle 9.8.0 user manual: Testing in Java &amp; JVM projects</a>. The per-level logging defaults (at <code class=\"language-text\">LIFECYCLE</code>, \"Test failures\" with \"Exception format is SHORT\"); the JUnit XML location <code class=\"language-text\">test-results/$testTaskName</code> with a file per class; <code class=\"language-text\">includeSystemOutLog</code> and <code class=\"language-text\">includeSystemErrLog</code> defaulting to true; <code class=\"language-text\">outputPerTestCase</code>, off by default, which \"associates any output logging generated during a test case to that test case\"; and <code class=\"language-text\">mergeReruns</code>, which records failures \"as <code class=\"language-text\">&lt;flakyFailure&gt;</code> instead of <code class=\"language-text\">&lt;failure&gt;</code>\", with the statements that a CI server that does not understand this \"will indicate that the test succeeded\" and that \"tests are grouped and merged based on their reported name\".</li>\n<li><a href=\"https://docs.gradle.org/current/javadoc/org/gradle/api/tasks/testing/logging/TestExceptionFormat.html\">Gradle API: TestExceptionFormat</a>. That <code class=\"language-text\">SHORT</code> \"shows the exception types and locations as well as the hierarchy of causes, but not exception messages or full stack traces\".</li>\n<li><a href=\"https://docs.gradle.org/current/dsl/org.gradle.api.tasks.testing.logging.TestLoggingContainer.html\">Gradle DSL: TestLoggingContainer</a>. That <code class=\"language-text\">exceptionFormat</code> defaults to <code class=\"language-text\">FULL</code> for <code class=\"language-text\">INFO</code> and <code class=\"language-text\">DEBUG</code> and <code class=\"language-text\">SHORT</code> for <code class=\"language-text\">LIFECYCLE</code>; <code class=\"language-text\">showStandardStreams</code> as the switch for logging standard out and error; <code class=\"language-text\">stackTraceFilters</code>; and the <code class=\"language-text\">events</code> property.</li>\n<li><a href=\"https://docs.gradle.org/current/userguide/java_plugin.html\">Gradle 9.8.0 user manual: The Java Plugin</a>. That the test report directory defaults to <code class=\"language-text\">reporting.baseDirectory.dir(\"tests\")</code>, e.g. <code class=\"language-text\">build/reports/tests</code>, and test result XML to <code class=\"language-text\">build/test-results</code>.</li>\n<li><a href=\"https://docs.gradle.org/9.4.0/release-notes.html\">Gradle 9.4.0 release notes</a>. That Gradle \"now captures this metadata\", meaning <code class=\"language-text\">TestReporter</code> entries and files, \"into both the HTML test report and the XML test results\"; the Data and Attachments tabs; direct rendering of some media types; entries \"mapped to <code class=\"language-text\">&lt;properties/&gt;</code>\" and files written as <code class=\"language-text\">[[ATTACHMENT|/path/to/file]]</code>. Released 2026-03-04.</li>\n<li><a href=\"https://docs.gradle.org/current/release-notes.html\">Gradle 9.8.0 release notes</a>. That 9.8.0 is the current release, dated 2026-09-29.</li>\n<li><a href=\"https://github.com/gradle/test-retry-gradle-plugin\">Gradle Test Retry plugin README</a>. The <code class=\"language-text\">retry</code> extension; that <code class=\"language-text\">failOnPassedAfterRetry</code> defaults to false so a pass on retry does not fail the task; <code class=\"language-text\">maxFailures</code> and its disk-full and missing-database rationale; that for a Jupiter <code class=\"language-text\">@ParameterizedTest</code> it \"will retry the entire method with all parameters including those that initially passed\"; and that \"each execution of a test is discretely reported in Gradle-generated XML and HTML reports\". Version 1.6.6 per the <a href=\"https://plugins.gradle.org/plugin/org.gradle.test-retry\">Gradle Plugin Portal listing</a>.</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>. <code class=\"language-text\">rerunFailingTestsCount</code>; the <code class=\"language-text\">flakyFailure</code>, <code class=\"language-text\">flakyError</code> and <code class=\"language-text\">rerunFailure</code> elements; that a flaky test's recorded time is \"the running time of the last successful run\"; and <code class=\"language-text\">failOnFlakeCount</code> since 3.0.0-M6.</li>\n<li><a href=\"https://docs.junit.org/6.1.3/writing-tests/dependency-injection-for-constructors-and-methods.html\">JUnit 6.1.3 user guide: Dependency Injection, TestReporter</a>. <code class=\"language-text\">publishEntry</code>, <code class=\"language-text\">publishFile</code> and <code class=\"language-text\">publishDirectory</code> with examples, and the advice to \"use TestReporter where you used to print information to stdout or stderr\".</li>\n<li><a href=\"https://docs.junit.org/6.1.3/api/org.junit.jupiter.api/org/junit/jupiter/api/TestReporter.html\">JUnit 6.1.3 API: TestReporter</a>. The interface since 5.0; file and directory publishing since 5.12; the <code class=\"language-text\">org.junit.jupiter.api.MediaType</code> overloads since 5.14 with the <code class=\"language-text\">extension.MediaType</code> variants deprecated for removal; and that a published name must not contain path separators.</li>\n<li><a href=\"https://docs.junit.org/6.1.3/writing-tests/parameterized-classes-and-tests.html\">JUnit 6.1.3 user guide: Parameterized Classes and Tests</a>. That <code class=\"language-text\">name</code> \"is a MessageFormat pattern\"; the placeholders including <code class=\"language-text\">{argumentSetName}</code>; <code class=\"language-text\">junit.jupiter.params.displayname.default</code> and the precedence of the annotation attribute over it; and the default name built from the index and each argument's string form.</li>\n<li><a href=\"https://docs.oracle.com/en/java/javase/26/docs/api/java.base/java/text/MessageFormat.html\">Java SE 26 API: MessageFormat</a>. The quoting rule that \"pattern string <code class=\"language-text\">\"'{0}'\"</code> represents string <code class=\"language-text\">\"{0}\"</code>, not a FormatElement\", and that an argument index is written with digits.</li>\n<li><a href=\"https://docs.junit.org/6.1.3/writing-tests/assertions.html\">JUnit 6.1.3 user guide: Assertions</a>. That a <code class=\"language-text\">Supplier&lt;String&gt;</code> message is \"only evaluated when the assertion fails\", and that in <code class=\"language-text\">assertAll</code> \"all assertions are executed, and all failures will be reported together\".</li>\n<li><a href=\"https://docs.junit.org/6.1.3/advanced-topics/junit-platform-reporting.html\">JUnit 6.1.3 user guide: JUnit Platform Reporting</a>. The Open Test Reporting listener, disabled by default, its <code class=\"language-text\">junit.platform.reporting.open.xml.enabled</code> and <code class=\"language-text\">git.enabled</code> parameters, the Gradle wiring, and the legacy listener's Ant-compatible format and its use by the Console Launcher.</li>\n<li><a href=\"https://docs.junit.org/6.1.3/running-tests/capturing-standard-output-error.html\">JUnit 6.1.3 user guide: Capturing Standard Output/Error</a>. That capture is opt-in and records only output \"emitted by the thread that was used to execute a container or test\".</li>\n<li><a href=\"https://docs.junit.org/6.1.3/running-tests/console-launcher.html\">JUnit 6.1.3 user guide: Console Launcher</a>. The <code class=\"language-text\">--reports-dir</code> option that enables XML report output.</li>\n<li><a href=\"https://github.com/ota4j-team/open-test-reporting\">Open Test Reporting README</a>. The event-based and hierarchical XML formats, and the recommendation to include Git metadata \"only if the requesting user has opted in explicitly\" because it may expose sensitive file names.</li>\n<li><a href=\"https://docs.oracle.com/en/java/javase/26/docs/api/java.base/java/lang/Record.html\">Java SE 26 API: Record</a>. That a record's implicit <code class=\"language-text\">toString()</code> is \"derived from all of the component fields\", which is how a generated id and a timestamp end up in a default parameterized display name.</li>\n<li><a href=\"https://docs.junit.org/6.1.3/overview.html\">JUnit 6.1.3 user guide: Overview</a>. That 6.1.3 is Platform plus Jupiter plus Vintage and that JUnit requires Java 17 or higher at runtime.</li>\n</ul>\n"}