From d84ee5887d6a01e21f06d532cc9b6aa6373e0006 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Sat, 17 Jan 2026 13:43:49 +0100 Subject: [PATCH 1/3] Create AssertionFailedError with reason --- documentation/documentation.gradle.kts | 5 ++++ gradle/libs.versions.toml | 2 +- .../jupiter/api/AssertionFailureBuilder.java | 27 ++++++++++++++----- settings.gradle.kts | 2 ++ 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/documentation/documentation.gradle.kts b/documentation/documentation.gradle.kts index 311a5a8418b4..bc67f97f718d 100644 --- a/documentation/documentation.gradle.kts +++ b/documentation/documentation.gradle.kts @@ -54,6 +54,11 @@ val tools by sourceSets.creating val toolsImplementation by configurations.getting dependencies { + repositories { + // TODO: Remove + mavenLocal() + } + implementation(projects.junitJupiterApi) { because("Jupiter API is used in src/main/java") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7e2192981d24..889e4a88c927 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,7 +12,7 @@ junit4 = "4.13.2" junit4Min = "4.12" ktlint = "1.8.0" log4j = "2.25.3" -opentest4j = "1.3.0" +opentest4j = "1.4.0-SNAPSHOT" openTestReporting = "0.2.5" snapshotTests = "1.11.0" surefire = "3.5.5" diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertionFailureBuilder.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertionFailureBuilder.java index 8b1d2e15f7a5..a899a40574d6 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertionFailureBuilder.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertionFailureBuilder.java @@ -192,6 +192,25 @@ public void buildAndThrow() throws AssertionFailedError { */ public AssertionFailedError build() { String reason = nullSafeGet(this.reason); + var assertionFailedError = new AssertionFailedError( // + formatExceptionMessage(reason), // + formatReason(reason), // + expected, // + actual, // + cause // + ); + maybeTrimStackTrace(assertionFailedError); + return assertionFailedError; + } + + private @Nullable String formatReason(@Nullable String reason) { + if (mismatch) { + return (reason == null ? "" : reason + ", ") + "expected did not match actual"; + } + return reason; + } + + private @Nullable String formatExceptionMessage(@Nullable String reason) { if (mismatch && includeValuesInMessage) { reason = (reason == null ? "" : reason + ", ") + formatValues(expected, actual); } @@ -199,13 +218,7 @@ public AssertionFailedError build() { if (reason != null) { message = buildPrefix(message) + reason; } - - var assertionFailedError = mismatch // - ? new AssertionFailedError(message, expected, actual, cause) // - : new AssertionFailedError(message, cause); - - maybeTrimStackTrace(assertionFailedError); - return assertionFailedError; + return message; } private void maybeTrimStackTrace(Throwable throwable) { diff --git a/settings.gradle.kts b/settings.gradle.kts index 2cc5e2d14846..b888dfd44630 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -15,6 +15,8 @@ plugins { dependencyResolutionManagement { repositories { mavenCentral() + // TODO: Remove + mavenLocal() } } From 198f8dcd486a79ffc3efcdf69f516655bfc7eb90 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 19 Jan 2026 13:08:39 +0100 Subject: [PATCH 2/3] Render rich diffs for expected and actual values --- documentation/documentation.gradle.kts | 3 +- gradle/libs.versions.toml | 1 + .../jupiter/api/AssertionFailureBuilder.java | 12 ++--- .../junit-platform-console.gradle.kts | 2 + .../src/main/java/module-info.java | 1 + .../console/output/RichDiffFormatter.java | 45 +++++++++++++++++ platform-tests/platform-tests.gradle.kts | 5 ++ .../output/RichDiffFormatterTests.java | 48 +++++++++++++++++++ 8 files changed, 110 insertions(+), 7 deletions(-) create mode 100644 junit-platform-console/src/main/java/org/junit/platform/console/output/RichDiffFormatter.java create mode 100644 platform-tests/src/test/java/org/junit/platform/console/output/RichDiffFormatterTests.java diff --git a/documentation/documentation.gradle.kts b/documentation/documentation.gradle.kts index bc67f97f718d..d1e03115f2b2 100644 --- a/documentation/documentation.gradle.kts +++ b/documentation/documentation.gradle.kts @@ -57,8 +57,9 @@ dependencies { repositories { // TODO: Remove mavenLocal() + mavenCentral() } - + implementation(projects.junitJupiterApi) { because("Jupiter API is used in src/main/java") } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 889e4a88c927..c9cf168e9bc7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,6 +67,7 @@ openTestReporting-events = { module = "org.opentest4j.reporting:open-test-report openTestReporting-tooling-core = { module = "org.opentest4j.reporting:open-test-reporting-tooling-core", version.ref = "openTestReporting" } openTestReporting-tooling-spi = { module = "org.opentest4j.reporting:open-test-reporting-tooling-spi", version.ref = "openTestReporting" } picocli = { module = "info.picocli:picocli", version = "4.7.7" } +javadiffutils = { module = "io.github.java-diff-utils:java-diff-utils", version = "4.15" } roseau-cli = { module = "io.github.alien-tools:roseau-cli", version = "0.5.0" } slf4j-julBinding = { module = "org.slf4j:slf4j-jdk14", version = "2.0.17" } snapshotTests-junit5 = { module = "de.skuzzle.test:snapshot-tests-junit5", version.ref = "snapshotTests" } diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertionFailureBuilder.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertionFailureBuilder.java index a899a40574d6..f5e57f92aa8b 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertionFailureBuilder.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertionFailureBuilder.java @@ -193,11 +193,11 @@ public void buildAndThrow() throws AssertionFailedError { public AssertionFailedError build() { String reason = nullSafeGet(this.reason); var assertionFailedError = new AssertionFailedError( // - formatExceptionMessage(reason), // - formatReason(reason), // - expected, // - actual, // - cause // + formatExceptionMessage(reason), // + formatReason(reason), // + expected, // + actual, // + cause // ); maybeTrimStackTrace(assertionFailedError); return assertionFailedError; @@ -205,7 +205,7 @@ public AssertionFailedError build() { private @Nullable String formatReason(@Nullable String reason) { if (mismatch) { - return (reason == null ? "" : reason + ", ") + "expected did not match actual"; + return (reason == null ? "" : reason + ", ") + "expected did not match actual"; } return reason; } diff --git a/junit-platform-console/junit-platform-console.gradle.kts b/junit-platform-console/junit-platform-console.gradle.kts index dd9568faf82f..c451086d7ae2 100644 --- a/junit-platform-console/junit-platform-console.gradle.kts +++ b/junit-platform-console/junit-platform-console.gradle.kts @@ -18,6 +18,8 @@ dependencies { compileOnlyApi(libs.jspecify) shadowed(libs.picocli) + // TODO: Sort out + shadowed(libs.javadiffutils) osgiVerification(projects.junitJupiterEngine) osgiVerification(projects.junitPlatformLauncher) diff --git a/junit-platform-console/src/main/java/module-info.java b/junit-platform-console/src/main/java/module-info.java index 4ae45d47e8bf..3474905a0d12 100644 --- a/junit-platform-console/src/main/java/module-info.java +++ b/junit-platform-console/src/main/java/module-info.java @@ -23,6 +23,7 @@ requires org.junit.platform.engine; requires org.junit.platform.launcher; requires org.junit.platform.reporting; + requires io.github.javadiffutils; exports org.junit.platform.console.output to org.junit.start; diff --git a/junit-platform-console/src/main/java/org/junit/platform/console/output/RichDiffFormatter.java b/junit-platform-console/src/main/java/org/junit/platform/console/output/RichDiffFormatter.java new file mode 100644 index 000000000000..22f2a3820176 --- /dev/null +++ b/junit-platform-console/src/main/java/org/junit/platform/console/output/RichDiffFormatter.java @@ -0,0 +1,45 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.console.output; + +import com.github.difflib.text.DiffRowGenerator; + +import org.junit.platform.commons.util.ExceptionUtils; +import org.opentest4j.AssertionFailedError; + +final class RichDiffFormatter { + public String format(AssertionFailedError assertionFailed) { + if (!(assertionFailed.isActualDefined() && assertionFailed.isExpectedDefined())) { + return ExceptionUtils.readStackTrace(assertionFailed); + } + + StringBuilder builder = new StringBuilder(); + + builder.append(assertionFailed.getClass().getSimpleName()); + if (assertionFailed.isReasonDefined()) { + builder.append(": "); + builder.append(assertionFailed.getReason()); + } + builder.append(System.lineSeparator()); + + builder.append("+ actual - expected"); + builder.append(System.lineSeparator()); + + var generator = DiffRowGenerator.create().mergeOriginalRevised(true).build(); + + // TODO: But how to render the stacktrace? + + builder.append(generator.generateDiffRows(assertionFailed.getExpected().toString().lines().toList(), + assertionFailed.getActual().toString().lines().toList())); + + return builder.toString(); + } +} diff --git a/platform-tests/platform-tests.gradle.kts b/platform-tests/platform-tests.gradle.kts index 9ea2c04a8531..c04493009fce 100644 --- a/platform-tests/platform-tests.gradle.kts +++ b/platform-tests/platform-tests.gradle.kts @@ -30,6 +30,11 @@ val woodstoxRuntimeClasspath = configurations.resolvable("woodstoxRuntimeClasspa } dependencies { + repositories { + mavenLocal() + mavenCentral() + } + // --- Things we are testing -------------------------------------------------- testImplementation(projects.junitPlatformCommons) testImplementation(projects.junitPlatformConsole) diff --git a/platform-tests/src/test/java/org/junit/platform/console/output/RichDiffFormatterTests.java b/platform-tests/src/test/java/org/junit/platform/console/output/RichDiffFormatterTests.java new file mode 100644 index 000000000000..74c934552d78 --- /dev/null +++ b/platform-tests/src/test/java/org/junit/platform/console/output/RichDiffFormatterTests.java @@ -0,0 +1,48 @@ +/* + * Copyright 2015-2026 the original author or authors. + * + * All rights reserved. This program and the accompanying materials are + * made available under the terms of the Eclipse Public License v2.0 which + * accompanies this distribution and is available at + * + * https://www.eclipse.org/legal/epl-v20.html + */ + +package org.junit.platform.console.output; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; +import org.opentest4j.AssertionFailedError; + +class RichDiffFormatterTests { + + @Test + void test() { + var expected = """ + { + "speaker": "world" + "message": "hello" + } + """; + var actuall = """ + { + "speaker": "you" + "message": "hello" + } + """; + + var assertionFailed = assertThrows(AssertionFailedError.class, () -> assertEquals(expected, actuall)); + + var formatter = new RichDiffFormatter(); + + String message = formatter.format(assertionFailed); + + assertEquals(""" + + """, message); + + } + +} From 99bf1ef5dc8f9a60b640154e24e0dbdac1c25007 Mon Sep 17 00:00:00 2001 From: "M.P. Korstanje" Date: Mon, 19 Jan 2026 16:44:56 +0100 Subject: [PATCH 3/3] Include rich diff in tree output --- .../jupiter/api/AssertionFailureBuilder.java | 18 +++-- .../junit-platform-console.gradle.kts | 5 +- .../console/output/RichDiffFormatter.java | 47 +++++++++--- .../platform/console/output/TreePrinter.java | 11 ++- .../output/RichDiffFormatterTests.java | 72 +++++++++++++++---- 5 files changed, 119 insertions(+), 34 deletions(-) diff --git a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertionFailureBuilder.java b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertionFailureBuilder.java index f5e57f92aa8b..8f3702657057 100644 --- a/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertionFailureBuilder.java +++ b/junit-jupiter-api/src/main/java/org/junit/jupiter/api/AssertionFailureBuilder.java @@ -192,9 +192,10 @@ public void buildAndThrow() throws AssertionFailedError { */ public AssertionFailedError build() { String reason = nullSafeGet(this.reason); + String message = nullSafeGet(this.message); var assertionFailedError = new AssertionFailedError( // - formatExceptionMessage(reason), // - formatReason(reason), // + formatExceptionMessage(reason, message), // + formatReason(reason, message), // expected, // actual, // cause // @@ -203,18 +204,21 @@ public AssertionFailedError build() { return assertionFailedError; } - private @Nullable String formatReason(@Nullable String reason) { + private @Nullable String formatReason(@Nullable String reason, @Nullable String message) { if (mismatch) { - return (reason == null ? "" : reason + ", ") + "expected did not match actual"; + // TODO: The suffix is implicit due to mismatch, but how to make explicit? + reason = (reason == null ? "" : reason + ", ") + "expected did not match actual"; + } + if (reason != null) { + message = buildPrefix(message) + reason; } - return reason; + return message; } - private @Nullable String formatExceptionMessage(@Nullable String reason) { + private @Nullable String formatExceptionMessage(@Nullable String reason, @Nullable String message) { if (mismatch && includeValuesInMessage) { reason = (reason == null ? "" : reason + ", ") + formatValues(expected, actual); } - String message = nullSafeGet(this.message); if (reason != null) { message = buildPrefix(message) + reason; } diff --git a/junit-platform-console/junit-platform-console.gradle.kts b/junit-platform-console/junit-platform-console.gradle.kts index c451086d7ae2..061210879313 100644 --- a/junit-platform-console/junit-platform-console.gradle.kts +++ b/junit-platform-console/junit-platform-console.gradle.kts @@ -18,7 +18,7 @@ dependencies { compileOnlyApi(libs.jspecify) shadowed(libs.picocli) - // TODO: Sort out + // TODO: Sort out relocation, licence, ect shadowed(libs.javadiffutils) osgiVerification(projects.junitJupiterEngine) @@ -31,7 +31,8 @@ tasks { options.compilerArgs.addAll(listOf( "-Xlint:-module", // due to qualified exports "--add-modules", "info.picocli", - "--add-reads", "${javaModuleName}=info.picocli" + "--add-reads", "${javaModuleName}=info.picocli", + "-Xlint:-requires-automatic", // Java diff utils )) options.errorprone.nullaway { excludedFieldAnnotations.addAll( diff --git a/junit-platform-console/src/main/java/org/junit/platform/console/output/RichDiffFormatter.java b/junit-platform-console/src/main/java/org/junit/platform/console/output/RichDiffFormatter.java index 22f2a3820176..90ab1fb0eec3 100644 --- a/junit-platform-console/src/main/java/org/junit/platform/console/output/RichDiffFormatter.java +++ b/junit-platform-console/src/main/java/org/junit/platform/console/output/RichDiffFormatter.java @@ -10,20 +10,16 @@ package org.junit.platform.console.output; +import java.util.function.Function; + import com.github.difflib.text.DiffRowGenerator; -import org.junit.platform.commons.util.ExceptionUtils; import org.opentest4j.AssertionFailedError; final class RichDiffFormatter { public String format(AssertionFailedError assertionFailed) { - if (!(assertionFailed.isActualDefined() && assertionFailed.isExpectedDefined())) { - return ExceptionUtils.readStackTrace(assertionFailed); - } - StringBuilder builder = new StringBuilder(); - builder.append(assertionFailed.getClass().getSimpleName()); if (assertionFailed.isReasonDefined()) { builder.append(": "); builder.append(assertionFailed.getReason()); @@ -33,12 +29,43 @@ public String format(AssertionFailedError assertionFailed) { builder.append("+ actual - expected"); builder.append(System.lineSeparator()); - var generator = DiffRowGenerator.create().mergeOriginalRevised(true).build(); + var generator = DiffRowGenerator.create() // + .lineNormalizer(Function.identity()) // Don't normalize lines + .showInlineDiffs(false) // + .build(); - // TODO: But how to render the stacktrace? + var diffRows = generator.generateDiffRows( // + assertionFailed.getExpected().getStringRepresentation().lines().toList(), // + assertionFailed.getActual().getStringRepresentation().lines().toList() // + ); - builder.append(generator.generateDiffRows(assertionFailed.getExpected().toString().lines().toList(), - assertionFailed.getActual().toString().lines().toList())); + diffRows.forEach(diffRow -> { + switch (diffRow.getTag()) { + case INSERT -> { + builder.append("+ "); + builder.append(diffRow.getNewLine()); + builder.append(System.lineSeparator()); + } + case DELETE -> { + builder.append("- "); + builder.append(diffRow.getOldLine()); + builder.append(System.lineSeparator()); + } + case CHANGE -> { + builder.append("+ "); + builder.append(diffRow.getOldLine()); + builder.append(System.lineSeparator()); + builder.append("- "); + builder.append(diffRow.getNewLine()); + builder.append(System.lineSeparator()); + } + case EQUAL -> { + builder.append(" "); + builder.append(diffRow.getNewLine()); + builder.append(System.lineSeparator()); + } + } + }); return builder.toString(); } diff --git a/junit-platform-console/src/main/java/org/junit/platform/console/output/TreePrinter.java b/junit-platform-console/src/main/java/org/junit/platform/console/output/TreePrinter.java index 065ac69e108e..1be3e0b8ab61 100644 --- a/junit-platform-console/src/main/java/org/junit/platform/console/output/TreePrinter.java +++ b/junit-platform-console/src/main/java/org/junit/platform/console/output/TreePrinter.java @@ -22,6 +22,7 @@ import org.junit.platform.engine.TestExecutionResult.Status; import org.junit.platform.engine.reporting.FileEntry; import org.junit.platform.engine.reporting.ReportEntry; +import org.opentest4j.AssertionFailedError; /** * @since 1.0 @@ -123,11 +124,19 @@ private void printThrowable(String indent, TestExecutionResult result) { return; } Throwable throwable = result.getThrowable().get(); + String message = formatThrowable(throwable); + printMessage(Style.FAILED, indent, message); + } + + private static String formatThrowable(Throwable throwable) { + if (throwable instanceof AssertionFailedError assertionFailedError) { + return new RichDiffFormatter().format(assertionFailedError); + } String message = throwable.getMessage(); if (StringUtils.isBlank(message)) { message = throwable.toString(); } - printMessage(Style.FAILED, indent, message); + return message; } private void printReportEntry(String indent, ReportEntry reportEntry) { diff --git a/platform-tests/src/test/java/org/junit/platform/console/output/RichDiffFormatterTests.java b/platform-tests/src/test/java/org/junit/platform/console/output/RichDiffFormatterTests.java index 74c934552d78..433f6bb106ed 100644 --- a/platform-tests/src/test/java/org/junit/platform/console/output/RichDiffFormatterTests.java +++ b/platform-tests/src/test/java/org/junit/platform/console/output/RichDiffFormatterTests.java @@ -19,30 +19,74 @@ class RichDiffFormatterTests { @Test - void test() { - var expected = """ + void lineAdded() { + assertRichDiffEquals(""" + { + "speaker": "world" + } + """, """ { "speaker": "world" "message": "hello" } - """; - var actuall = """ + """, """ + expected did not match actual + + actual - expected + { + "speaker": "world" + + "message": "hello" + } + """); + } + + @Test + void lineRemoved() { + assertRichDiffEquals(""" { - "speaker": "you" + "speaker": "world" "message": "hello" } - """; + """, """ + { + "speaker": "world" + } + """, """ + expected did not match actual + + actual - expected + { + "speaker": "world" + - "message": "hello" + } + """); + } - var assertionFailed = assertThrows(AssertionFailedError.class, () -> assertEquals(expected, actuall)); + @Test + void lineChanged() { + assertRichDiffEquals(""" + { + "speaker": "world" + "message": "hello" + } + """, """ + { + "speaker": "you" + "message": "hello" + } + """, """ + expected did not match actual + + actual - expected + { + + "speaker": "world" + - "speaker": "you" + "message": "hello" + } + """); + } + private static void assertRichDiffEquals(String expected, String actual, String expectedDiff) { var formatter = new RichDiffFormatter(); - - String message = formatter.format(assertionFailed); - - assertEquals(""" - - """, message); - + var assertionFailed = assertThrows(AssertionFailedError.class, () -> assertEquals(expected, actual)); + assertEquals(expectedDiff, formatter.format(assertionFailed)); } }