# Make JUnit Failures Useful in CI

> 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.

- Author: [Shashank Rawlani](https://shashank.rawlani.com)

- Published: 2026-10-02T13:30:00.000Z

- Updated: 2026-10-14T11:09:52.936Z

- Category: Automation Tutorials

- Canonical source: https://automationtester.in/blog/automation-tutorials/junit-failure-reports-useful-ci

- Tags: junit, gradle, java, ci-cd, test-reporting, flaky-tests, contract-testing

**Quick answer:** 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 `exceptionFormat = 'full'` (the default console drops your assertion message), build names from fixed case slugs, attach sanitized evidence with `TestReporter` (captured into reports since Gradle 9.4), and make sure something reads the flaky outcome of a retry.

Here is a failed contract test as it appears in a CI console, in full:

```
OrderContractTest > contract(Order) > [2] order = Order[id=ord_7f3a9c21, createdAt=2026-09-30T08:14:22.391Z, lines=3] FAILED
    org.opentest4j.AssertionFailedError at OrderContractTest.java:47
```

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.

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 `GET /orders/{id}`, where one case, an order stored before currencies were recorded, comes back without `$.total.currency`.

**Versions checked on 2026-09-30.** 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 `org.gradle.test-retry` 1.6.6; Maven Surefire 3.6.0. Two behaviours below are recent enough to depend on your versions: file and directory attachments on `TestReporter` arrived in Jupiter 5.12, and Gradle only started carrying `TestReporter` data into its HTML and XML reports in 9.4.0.

## The default console drops your assertion message

Gradle logs test events per log level, and CI runs at `LIFECYCLE`. At that level the documented default is "Test failures", with "Exception format is SHORT". The Javadoc for `TestExceptionFormat.SHORT` 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."

So the assertion message never reaches the console by default. The XML and the HTML report under `build/reports/tests` have it, but the first thing anyone reads when a pull request goes red is the job log, which says `AssertionFailedError at OrderContractTest.java:47`. `FULL` is only the default at `INFO` and `DEBUG`, and nobody runs CI at those levels.

Set it once, for every test task:

```
tasks.withType(Test).configureEach {
    testLogging {
        events 'failed', 'skipped'
        exceptionFormat = 'full'
        // Deliberately not showStandardStreams: see the section on stdout.
    }
    reports.junitXml {
        outputPerTestCase = true
        mergeReruns = true
    }
}
```

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.

`outputPerTestCase` "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. `mergeReruns` is less harmless than it looks; it gets its own section below.

## Identity that is the same on every run

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 `mergeReruns` 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.

The opening example breaks that rule without anyone deciding to. Its argument source builds orders with a random id and `Instant.now()`, and the default display name prints the index and each argument's string form. `Order` is a record, and a record's implicitly declared `toString()` 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.

```
// Wrong: the display name is derived from generated data.
static Stream<Order> orders() {
    return Stream.of(
            Order.random(),
            Order.legacy(Instant.now()),   // prints createdAt into the test name
            Order.withLines(3));
}

@ParameterizedTest
@MethodSource("orders")
void contract(Order order) {
    var response = client.get("/orders/" + order.id());
    assertTrue(response.body().contains("\"currency\""));
}
```

Part two of this series covered [naming rows with `argumentSet`](/blog/junit-parameterized-tests-without-matrix-explosion) 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.

```
static Stream<Arguments> storedOrders() {
    return Stream.of(
            argumentSet("complete-order",   StoredOrder.complete("ord_complete")),
            argumentSet("multi-line-order", StoredOrder.withLines("ord_three_lines", 3)),
            argumentSet("missing-currency", StoredOrder.legacyWithoutCurrency("ord_legacy_no_ccy")));
}

@ParameterizedTest(name = "GET /orders/'{id}' [{argumentSetName}]")
@MethodSource("storedOrders")
void honoursTheOrderContract(StoredOrder stored, TestReporter reporter) throws Exception {
    // body in the next two sections
}
```

One detail in that `name` attribute will bite anyone who copies an endpoint template into it. The JUnit guide states that the attribute "is a MessageFormat pattern", and in `MessageFormat` a brace pair is a format element whose index must be digits. To print a literal `{id}`, quote it: the `MessageFormat` Javadoc gives the rule as "pattern string `"'{0}'"` represents string `"{0}"`, not a FormatElement". The rendered name is then `GET /orders/{id} [missing-currency]`, which is exactly what someone will paste into a search box.

## A message that answers path, expected and actual

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:

```
test:       GET /orders/{id} [missing-currency]
path:       $.total.currency
expected:   required string
actual:     absent
attachment: order-missing-currency.json (sanitized response)
```

Build that once as a helper, rather than trusting each test author to format it:

```
import com.fasterxml.jackson.databind.JsonNode;   // Jackson 2.x

final class Contract {

    /** Fails with "path: expected required string, actual …". */
    static void requiredString(JsonNode body, String jsonPath, String pointer) {
        JsonNode value = body.at(pointer);
        assertTrue(value.isTextual(), () -> jsonPath
                + ": expected required string, actual "
                + describe(value));
    }

    private static String describe(JsonNode value) {
        if (value.isMissingNode()) return "absent";
        if (value.isNull()) return "null";
        return value.getNodeType().name().toLowerCase() + " " + value;
    }
}
```

The message is a `Supplier<String>` because Jupiter documents that a supplier "is only evaluated when the assertion fails", so the formatting work is paid for only on failures.

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 `assertAll`, "all assertions are executed, and all failures will be reported together" under a heading you supply. Use the test identity as the heading.

```
JsonNode body = json.readTree(response.body());
assertAll("GET /orders/{id} [" + stored.slug() + "]",
        () -> assertEquals(200, response.statusCode(), "HTTP status"),
        () -> Contract.requiredString(body, "$.total.currency", "/total/currency"),
        () -> Contract.requiredString(body, "$.total.amount", "/total/amount"),
        () -> Contract.requiredString(body, "$.status", "/status"));
```

The path is written twice, as a reader expects it and as the JSON Pointer Jackson's `at()` takes. If the expected shape lives in a JSON Schema, the [JSON Schema generator](/tools/json-schema) is a quick check that the fields your helper calls required match the schema's.

One caution: `describe()` 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.

## Attach the response to the test that saw it

The message says what broke; the response body says why. The wrong place for that body is standard output. The right place is `TestReporter`, 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".

`publishEntry` for key-value data has been there since Jupiter 5.0. `publishFile` and `publishDirectory`, which attach files to the current test, arrived in 5.12. In 5.14 the file overloads moved to `org.junit.jupiter.api.MediaType`; the ones taking `org.junit.jupiter.api.extension.MediaType` are deprecated for removal, so check that import after an upgrade.

```
@ParameterizedTest(name = "GET /orders/'{id}' [{argumentSetName}]")
@MethodSource("storedOrders")
void honoursTheOrderContract(StoredOrder stored, TestReporter reporter) throws Exception {
    orders.seed(stored);
    HttpResponse<String> response = client.get("/orders/" + stored.id());

    reporter.publishEntry(Map.of(
            "endpoint", "GET /orders/{id}",
            "case", stored.slug(),
            "orderId", stored.id(),
            "status", String.valueOf(response.statusCode()),
            "requestId", response.headers().firstValue("x-request-id").orElse("none")));

    try {
        JsonNode body = json.readTree(response.body());
        assertAll("GET /orders/{id} [" + stored.slug() + "]",
                () -> assertEquals(200, response.statusCode(), "HTTP status"),
                () -> Contract.requiredString(body, "$.total.currency", "/total/currency"),
                () -> Contract.requiredString(body, "$.total.amount", "/total/amount"));
    } catch (AssertionError failure) {
        // Evidence only for failures, so passing runs do not fill the artefact store.
        reporter.publishFile("order-" + stored.slug() + ".json", MediaType.APPLICATION_JSON,
                file -> Files.writeString(file, Redactor.redact(response.body())));
        throw failure;
    }
}
```

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.

### What Gradle does with it, and since when

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 *Data* tab for key-value entries and an *Attachments* 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 `<properties/>`" and each file is written in the form `[[ATTACHMENT|/path/to/file]]`, a convention those notes attribute to Jenkins, Azure Pipelines and GitLab.

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 `junit-platform-reporting`, off until `junit.platform.reporting.open.xml.enabled=true`, 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.

Put together, the console line and the report tabs for the failing case read like this (the console lines are what `exceptionFormat = 'full'` prints; the last two rows are the HTML report's Data and Attachments tabs):

```
OrderContractTest > honoursTheOrderContract(StoredOrder, TestReporter) > GET /orders/{id} [missing-currency] FAILED
    org.opentest4j.MultipleFailuresError: GET /orders/{id} [missing-currency] (1 failure)
        org.opentest4j.AssertionFailedError: $.total.currency: expected required string, actual absent

  Data         endpoint=GET /orders/{id}  case=missing-currency  orderId=ord_legacy_no_ccy
               status=200  requestId=5c1e0b7a
  Attachments  order-missing-currency.json
```

Nothing in the name changes between runs. Everything that does is in the data, attached to the invocation that produced it.

## Redact before anything is written

Gradle's JUnit XML includes standard output and standard error by default (`includeSystemOutLog` and `includeSystemErrLog` both default to `true`), so a `println` 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. `showStandardStreams` goes one step further and puts it in the job log. That is why the configuration above leaves it off.

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 `junit.platform.reporting.open.xml.git.enabled` defaults to `false`; leave it there.

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 `Authorization` value lives:

```
final class Redactor {

    private static final ObjectMapper MAPPER = new ObjectMapper();
    private static final Pattern SENSITIVE =
            Pattern.compile("(?i).*(token|secret|password|authorization|email|card).*");

    static String redact(String json) throws IOException {
        JsonNode tree = MAPPER.readTree(json);
        scrub(tree);
        return MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(tree);
    }

    private static void scrub(JsonNode node) {
        if (node instanceof ObjectNode object) {
            List<String> keys = new ArrayList<>();
            object.fieldNames().forEachRemaining(keys::add);
            for (String key : keys) {
                if (SENSITIVE.matcher(key).matches()) {
                    object.put(key, "[redacted]");
                } else {
                    scrub(object.get(key));
                }
            }
        } else if (node instanceof ArrayNode array) {
            array.forEach(Redactor::scrub);
        }
    }
}
```

Redacting by key keeps the structure, which is the evidence here: the attachment still shows `total` with an `amount` and no `currency`. The weakness is that a denylist knows only the names it was given, so a new `apiKey` field passes through. If payloads often carry credentials, invert it into an allowlist of keys whose values may appear.

## A green build that contains a failure

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 `failOnPassedAfterRetry` setting "defaults to `false`, which results in the task not failing if all tests pass on retry". Without `mergeReruns`, 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.

Turn on `mergeReruns` and the failures are recorded "as `<flakyFailure>` instead of `<failure>`, within one `<testcase>`". 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 `rerunFailingTestsCount` is set, and records that "the running time of a flaky test will be the running time of the last successful run".

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.

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 `@ParameterizedTest` "the plugin will retry the entire method with all parameters including those that initially passed". Retrying `[missing-currency]` reruns every case, and any name that differs between the first execution and the retry cannot be merged at all.

Pick one of two honest configurations. The first keeps retries as a diagnostic and makes flakiness fail the build:

```
plugins {
    id 'org.gradle.test-retry' version '1.6.6'
}

tasks.withType(Test).configureEach {
    retry {
        maxRetries = 2
        maxFailures = 10     // stop retrying when the database is down, not flaky
        failOnPassedAfterRetry = true
    }
}
```

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. `maxFailures` covers the README's case for not retrying at all, "if a disk fills up or a required database is not available". On Maven, `failOnFlakeCount`, available since Surefire 3.0.0-M6, fails the build once more than a given number of tests are flaky.

The second accepts green-on-retry but refuses to let the flake vanish, by counting it straight from the XML after the test task:

```
#!/usr/bin/env bash
# Run after ./gradlew test, whatever its exit code.
flaky=$(grep -l '<flakyFailure' build/test-results/test/*.xml 2>/dev/null | wc -l | tr -d ' ')
if [ "$flaky" -gt 0 ]; then
  echo "::warning::$flaky result file(s) contain tests that passed only on retry"
  grep -h -o 'testcase name="[^"]*"' $(grep -l '<flakyFailure' build/test-results/test/*.xml)
fi
```

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 [JUnit XML Viewer](https://automationtester.in/tools/junit-xml-viewer), which reads `<flakyFailure>` and prints every test's pass and fail sequence by build.

## Rebuild one red build from the report alone

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.

If the message is missing from the log, add `exceptionFormat = 'full'` 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.

Last, check whether retries are enabled anywhere, and if so, find out what reads `<flakyFailure>`. If nothing does, set `failOnPassedAfterRetry = true` 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.

## Questions about JUnit results in CI

### Does the Console Launcher write the same XML as Gradle?

It writes the legacy format: JUnit's `LegacyXmlReportGeneratingListener`, 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 `--reports-dir`. Gradle writes its own XML in the same Ant-derived format, with options such as `mergeReruns` that the launcher does not have.

### Why is some output missing from the report when capture is on?

Because JUnit's own capture, enabled with `junit.platform.output.capture.stdout`, 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 `TestReporter` from the test thread instead.

### Can a language model triage a run with hundreds of failures?

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.

### Is `mergeReruns` still worth enabling?

Yes, if something reads `<flakyFailure>`: 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.

## Primary references

- [Gradle 9.8.0 user manual: Testing in Java & JVM projects](https://docs.gradle.org/current/userguide/java_testing.html). The per-level logging defaults (at `LIFECYCLE`, "Test failures" with "Exception format is SHORT"); the JUnit XML location `test-results/$testTaskName` with a file per class; `includeSystemOutLog` and `includeSystemErrLog` defaulting to true; `outputPerTestCase`, off by default, which "associates any output logging generated during a test case to that test case"; and `mergeReruns`, which records failures "as `<flakyFailure>` instead of `<failure>`", 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".

- [Gradle API: TestExceptionFormat](https://docs.gradle.org/current/javadoc/org/gradle/api/tasks/testing/logging/TestExceptionFormat.html). That `SHORT` "shows the exception types and locations as well as the hierarchy of causes, but not exception messages or full stack traces".

- [Gradle DSL: TestLoggingContainer](https://docs.gradle.org/current/dsl/org.gradle.api.tasks.testing.logging.TestLoggingContainer.html). That `exceptionFormat` defaults to `FULL` for `INFO` and `DEBUG` and `SHORT` for `LIFECYCLE`; `showStandardStreams` as the switch for logging standard out and error; `stackTraceFilters`; and the `events` property.

- [Gradle 9.8.0 user manual: The Java Plugin](https://docs.gradle.org/current/userguide/java_plugin.html). That the test report directory defaults to `reporting.baseDirectory.dir("tests")`, e.g. `build/reports/tests`, and test result XML to `build/test-results`.

- [Gradle 9.4.0 release notes](https://docs.gradle.org/9.4.0/release-notes.html). That Gradle "now captures this metadata", meaning `TestReporter` 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 `<properties/>`" and files written as `[[ATTACHMENT|/path/to/file]]`. Released 2026-03-04.

- [Gradle 9.8.0 release notes](https://docs.gradle.org/current/release-notes.html). That 9.8.0 is the current release, dated 2026-09-29.

- [Gradle Test Retry plugin README](https://github.com/gradle/test-retry-gradle-plugin). The `retry` extension; that `failOnPassedAfterRetry` defaults to false so a pass on retry does not fail the task; `maxFailures` and its disk-full and missing-database rationale; that for a Jupiter `@ParameterizedTest` 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 [Gradle Plugin Portal listing](https://plugins.gradle.org/plugin/org.gradle.test-retry).

- [Maven Surefire 3.6.0: Rerun Failing Tests](https://maven.apache.org/surefire/maven-surefire-plugin/examples/rerun-failing-tests.html). `rerunFailingTestsCount`; the `flakyFailure`, `flakyError` and `rerunFailure` elements; that a flaky test's recorded time is "the running time of the last successful run"; and `failOnFlakeCount` since 3.0.0-M6.

- [JUnit 6.1.3 user guide: Dependency Injection, TestReporter](https://docs.junit.org/6.1.3/writing-tests/dependency-injection-for-constructors-and-methods.html). `publishEntry`, `publishFile` and `publishDirectory` with examples, and the advice to "use TestReporter where you used to print information to stdout or stderr".

- [JUnit 6.1.3 API: TestReporter](https://docs.junit.org/6.1.3/api/org.junit.jupiter.api/org/junit/jupiter/api/TestReporter.html). The interface since 5.0; file and directory publishing since 5.12; the `org.junit.jupiter.api.MediaType` overloads since 5.14 with the `extension.MediaType` variants deprecated for removal; and that a published name must not contain path separators.

- [JUnit 6.1.3 user guide: Parameterized Classes and Tests](https://docs.junit.org/6.1.3/writing-tests/parameterized-classes-and-tests.html). That `name` "is a MessageFormat pattern"; the placeholders including `{argumentSetName}`; `junit.jupiter.params.displayname.default` and the precedence of the annotation attribute over it; and the default name built from the index and each argument's string form.

- [Java SE 26 API: MessageFormat](https://docs.oracle.com/en/java/javase/26/docs/api/java.base/java/text/MessageFormat.html). The quoting rule that "pattern string `"'{0}'"` represents string `"{0}"`, not a FormatElement", and that an argument index is written with digits.

- [JUnit 6.1.3 user guide: Assertions](https://docs.junit.org/6.1.3/writing-tests/assertions.html). That a `Supplier<String>` message is "only evaluated when the assertion fails", and that in `assertAll` "all assertions are executed, and all failures will be reported together".

- [JUnit 6.1.3 user guide: JUnit Platform Reporting](https://docs.junit.org/6.1.3/advanced-topics/junit-platform-reporting.html). The Open Test Reporting listener, disabled by default, its `junit.platform.reporting.open.xml.enabled` and `git.enabled` parameters, the Gradle wiring, and the legacy listener's Ant-compatible format and its use by the Console Launcher.

- [JUnit 6.1.3 user guide: Capturing Standard Output/Error](https://docs.junit.org/6.1.3/running-tests/capturing-standard-output-error.html). That capture is opt-in and records only output "emitted by the thread that was used to execute a container or test".

- [JUnit 6.1.3 user guide: Console Launcher](https://docs.junit.org/6.1.3/running-tests/console-launcher.html). The `--reports-dir` option that enables XML report output.

- [Open Test Reporting README](https://github.com/ota4j-team/open-test-reporting). 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.

- [Java SE 26 API: Record](https://docs.oracle.com/en/java/javase/26/docs/api/java.base/java/lang/Record.html). That a record's implicit `toString()` 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.

- [JUnit 6.1.3 user guide: Overview](https://docs.junit.org/6.1.3/overview.html). That 6.1.3 is Platform plus Jupiter plus Vintage and that JUnit requires Java 17 or higher at runtime.

## Continue reading on AutomationTester.in

- [Mock or Integrate? Playwright Network Testing Boundaries](https://automationtester.in/blog/automation-tutorials/playwright-network-mocking-integration-boundaries)
- [Build Deterministic Python API Test Data Factories](https://automationtester.in/blog/automation-tutorials/python-api-test-data-factories-deterministic)
- [Design Playwright Fixtures for Parallel Test Isolation](https://automationtester.in/blog/automation-tutorials/playwright-fixtures-parallel-test-isolation)
- [Test Python Retry Logic Without Sleeping](https://automationtester.in/blog/automation-tutorials/test-python-retry-backoff-without-sleeping)
- [Debug JUnit Parallel-Execution Flakes](https://automationtester.in/blog/automation-tutorials/debug-junit-parallel-execution-flakes)

Source: [Make JUnit Failures Useful in CI](https://automationtester.in/blog/automation-tutorials/junit-failure-reports-useful-ci) by Shashank Rawlani.
