diff --git a/documentation/src/docs/asciidoc/user-guide/appendix.adoc b/documentation/src/docs/asciidoc/user-guide/appendix.adoc index 2f3ae2bf5e93..ab63a5a90b3b 100644 --- a/documentation/src/docs/asciidoc/user-guide/appendix.adoc +++ b/documentation/src/docs/asciidoc/user-guide/appendix.adoc @@ -21,6 +21,26 @@ Artifacts for final releases and milestones are deployed to {Maven_Central}, and artifacts are deployed to Sonatype's {snapshot-repo}[snapshots repository] under {snapshot-repo}/org/junit/[/org/junit]. +The sections below list all artifacts with their versions for the three groups: +<>, +<>, and +<>. +The <> contains a list of all +of the above artifacts and their versions. + +[TIP] +.Aligning dependency versions +==== +To ensure that all JUnit artifacts are compatible with each other, their versions should +be aligned. +If you rely on <> for dependency management, +please see the corresponding section. +Otherwise, instead of managing individual versions of the JUnit artifacts, it is +recommended to apply the <> to your project. +Please refer to the corresponding sections for <> or +<>. +==== + [[dependency-metadata-junit-platform]] ==== JUnit Platform diff --git a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/ClasspathAlignmentChecker.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/ClasspathAlignmentChecker.java new file mode 100644 index 000000000000..3b95f7153e6a --- /dev/null +++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/ClasspathAlignmentChecker.java @@ -0,0 +1,110 @@ +/* + * Copyright 2015-2025 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.launcher.core; + +import static java.util.Collections.unmodifiableList; +import static java.util.Comparator.comparing; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +import org.junit.platform.commons.JUnitException; +import org.junit.platform.commons.support.ReflectionSupport; +import org.junit.platform.commons.util.ClassLoaderUtils; + +/** + * @since 1.12 + */ +class ClasspathAlignmentChecker { + + // VisibleForTesting + static final List WELL_KNOWN_PACKAGES = unmodifiableList(Arrays.asList( // + "org.junit.jupiter.api", // + "org.junit.jupiter.engine", // + "org.junit.jupiter.migrationsupport", // + "org.junit.jupiter.params", // + "org.junit.platform.commons", // + "org.junit.platform.console", // + "org.junit.platform.engine", // + "org.junit.platform.jfr", // + "org.junit.platform.launcher", // + "org.junit.platform.reporting", // + "org.junit.platform.runner", // + "org.junit.platform.suite.api", // + "org.junit.platform.suite.commons", // + "org.junit.platform.suite.engine", // + "org.junit.platform.testkit", // + "org.junit.vintage.engine" // + )); + + static Optional check(LinkageError error) { + ClassLoader classLoader = ClassLoaderUtils.getClassLoader(ClasspathAlignmentChecker.class); + Function packageLookup = name -> ReflectionSupport.findMethod(ClassLoader.class, + "getDefinedPackage", String.class) // + .map(m -> (Package) ReflectionSupport.invokeMethod(m, classLoader, name)) // + .orElseGet(() -> getPackage(name)); + return check(error, packageLookup); + } + + // VisibleForTesting + static Optional check(LinkageError error, Function packageLookup) { + Map> packagesByVersions = new HashMap<>(); + WELL_KNOWN_PACKAGES.stream() // + .map(packageLookup) // + .filter(Objects::nonNull) // + .forEach(pkg -> { + String version = pkg.getImplementationVersion(); + if (version != null) { + if (pkg.getName().startsWith("org.junit.platform") && version.contains(".")) { + version = platformToJupiterVersion(version); + } + packagesByVersions.computeIfAbsent(version, __ -> new ArrayList<>()).add(pkg); + } + }); + if (packagesByVersions.size() > 1) { + StringBuilder message = new StringBuilder(); + String lineBreak = System.lineSeparator(); + message.append("The wrapped ").append(error.getClass().getSimpleName()) // + .append(" is likely caused by the versions of JUnit jars on the classpath/module path ") // + .append("not being properly aligned. ") // + .append(lineBreak) // + .append("Please ensure consistent versions are used (see https://junit.org/junit5/docs/") // + .append(platformToJupiterVersion( + ClasspathAlignmentChecker.class.getPackage().getImplementationVersion())) // + .append("/user-guide/#dependency-metadata).") // + .append(lineBreak) // + .append("The following conflicting versions were detected:").append(lineBreak); + packagesByVersions.values().stream() // + .flatMap(List::stream) // + .sorted(comparing(Package::getName)) // + .map(pkg -> String.format("- %s: %s%n", pkg.getName(), pkg.getImplementationVersion())) // + .forEach(message::append); + return Optional.of(new JUnitException(message.toString(), error)); + } + return Optional.empty(); + } + + private static String platformToJupiterVersion(String version) { + int majorVersion = Integer.parseInt(version.substring(0, version.indexOf("."))) + 4; + return majorVersion + version.substring(version.indexOf(".")); + } + + @SuppressWarnings({ "deprecation", "RedundantSuppression" }) // only called when running on JDK 8 + private static Package getPackage(String name) { + return Package.getPackage(name); + } +} diff --git a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/ClasspathAlignmentCheckingLauncherInterceptor.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/ClasspathAlignmentCheckingLauncherInterceptor.java new file mode 100644 index 000000000000..4bef73ba35d7 --- /dev/null +++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/ClasspathAlignmentCheckingLauncherInterceptor.java @@ -0,0 +1,40 @@ +/* + * Copyright 2015-2025 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.launcher.core; + +import java.util.Optional; + +import org.junit.platform.commons.JUnitException; +import org.junit.platform.launcher.LauncherInterceptor; + +class ClasspathAlignmentCheckingLauncherInterceptor implements LauncherInterceptor { + + static final LauncherInterceptor INSTANCE = new ClasspathAlignmentCheckingLauncherInterceptor(); + + @Override + public T intercept(Invocation invocation) { + try { + return invocation.proceed(); + } + catch (LinkageError e) { + Optional exception = ClasspathAlignmentChecker.check(e); + if (exception.isPresent()) { + throw exception.get(); + } + throw e; + } + } + + @Override + public void close() { + // do nothing + } +} diff --git a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/EngineDiscoveryOrchestrator.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/EngineDiscoveryOrchestrator.java index 862d7bb28ddf..09c895a69684 100644 --- a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/EngineDiscoveryOrchestrator.java +++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/EngineDiscoveryOrchestrator.java @@ -156,8 +156,14 @@ private TestDescriptor discoverEngineRoot(TestEngine testEngine, LauncherDiscove } catch (Throwable throwable) { UnrecoverableExceptions.rethrowIfUnrecoverable(throwable); - String message = String.format("TestEngine with ID '%s' failed to discover tests", testEngine.getId()); - JUnitException cause = new JUnitException(message, throwable); + JUnitException cause = null; + if (throwable instanceof LinkageError) { + cause = ClasspathAlignmentChecker.check((LinkageError) throwable).orElse(null); + } + if (cause == null) { + String message = String.format("TestEngine with ID '%s' failed to discover tests", testEngine.getId()); + cause = new JUnitException(message, throwable); + } listener.engineDiscoveryFinished(uniqueEngineId, EngineDiscoveryResult.failed(cause)); return new EngineDiscoveryErrorDescriptor(uniqueEngineId, testEngine, cause); } diff --git a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/EngineExecutionOrchestrator.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/EngineExecutionOrchestrator.java index 90fa40d97dc8..c726d87eea01 100644 --- a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/EngineExecutionOrchestrator.java +++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/EngineExecutionOrchestrator.java @@ -200,8 +200,15 @@ private void execute(TestDescriptor engineDescriptor, EngineExecutionListener li } catch (Throwable throwable) { UnrecoverableExceptions.rethrowIfUnrecoverable(throwable); - delayingListener.reportEngineFailure(new JUnitException( - String.format("TestEngine with ID '%s' failed to execute tests", testEngine.getId()), throwable)); + JUnitException cause = null; + if (throwable instanceof LinkageError) { + cause = ClasspathAlignmentChecker.check((LinkageError) throwable).orElse(null); + } + if (cause == null) { + String message = String.format("TestEngine with ID '%s' failed to execute tests", testEngine.getId()); + cause = new JUnitException(message, throwable); + } + delayingListener.reportEngineFailure(cause); } } } diff --git a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/LauncherFactory.java b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/LauncherFactory.java index d0dfae7c8a7d..c756f27351f0 100644 --- a/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/LauncherFactory.java +++ b/junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/LauncherFactory.java @@ -10,7 +10,6 @@ package org.junit.platform.launcher.core; -import static java.util.Collections.emptyList; import static org.apiguardian.api.API.Status.STABLE; import static org.junit.platform.launcher.LauncherConstants.DEACTIVATE_LISTENERS_PATTERN_PROPERTY_NAME; import static org.junit.platform.launcher.LauncherConstants.ENABLE_LAUNCHER_INTERCEPTORS; @@ -145,12 +144,12 @@ private static DefaultLauncher createDefaultLauncher(LauncherConfig config, private static List collectLauncherInterceptors( LauncherConfigurationParameters configurationParameters) { + List interceptors = new ArrayList<>(); if (configurationParameters.getBoolean(ENABLE_LAUNCHER_INTERCEPTORS).orElse(false)) { - List interceptors = new ArrayList<>(); ServiceLoaderRegistry.load(LauncherInterceptor.class).forEach(interceptors::add); - return interceptors; } - return emptyList(); + interceptors.add(ClasspathAlignmentCheckingLauncherInterceptor.INSTANCE); + return interceptors; } private static Set collectTestEngines(LauncherConfig config) { diff --git a/platform-tests/platform-tests.gradle.kts b/platform-tests/platform-tests.gradle.kts index 2da1a0dbb08a..dd8508f495c4 100644 --- a/platform-tests/platform-tests.gradle.kts +++ b/platform-tests/platform-tests.gradle.kts @@ -47,6 +47,7 @@ dependencies { testImplementation(projects.junitJupiterEngine) testImplementation(testFixtures(projects.junitJupiterEngine)) testImplementation(libs.apiguardian) + testImplementation(libs.classgraph) testImplementation(libs.jfrunit) { exclude(group = "org.junit.vintage") } @@ -63,7 +64,11 @@ dependencies { } // --- Test run-time dependencies --------------------------------------------- - testRuntimeOnly(projects.junitVintageEngine) + val mavenizedProjects: List by rootProject + mavenizedProjects.filter { it.path != projects.junitPlatformConsoleStandalone.path }.forEach { + // Add all projects to the classpath for tests using classpath scanning + testRuntimeOnly(it) + } testRuntimeOnly(libs.groovy4) { because("`ReflectionUtilsTests.findNestedClassesWithInvalidNestedClassFile` needs it") } diff --git a/platform-tests/src/test/java/org/junit/platform/launcher/core/ClasspathAlignmentCheckerTests.java b/platform-tests/src/test/java/org/junit/platform/launcher/core/ClasspathAlignmentCheckerTests.java new file mode 100644 index 000000000000..c15ad7aa543f --- /dev/null +++ b/platform-tests/src/test/java/org/junit/platform/launcher/core/ClasspathAlignmentCheckerTests.java @@ -0,0 +1,85 @@ +/* + * Copyright 2015-2025 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.launcher.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.platform.launcher.core.ClasspathAlignmentChecker.WELL_KNOWN_PACKAGES; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.regex.Pattern; + +import io.github.classgraph.ClassGraph; +import io.github.classgraph.PackageInfo; + +import org.junit.jupiter.api.Test; + +class ClasspathAlignmentCheckerTests { + + @Test + void classpathIsAligned() { + assertThat(ClasspathAlignmentChecker.check(new LinkageError())).isEmpty(); + } + + @Test + void wrapsLinkageErrorForUnalignedClasspath() { + var cause = new LinkageError(); + AtomicInteger counter = new AtomicInteger(); + Function packageLookup = name -> { + var pkg = mock(Package.class); + when(pkg.getName()).thenReturn(name); + when(pkg.getImplementationVersion()).thenReturn(counter.incrementAndGet() + ".0.0"); + return pkg; + }; + + var result = ClasspathAlignmentChecker.check(cause, packageLookup); + + assertThat(result).isPresent(); + assertThat(result.get()) // + .hasMessageStartingWith("The wrapped LinkageError is likely caused by the versions of " + + "JUnit jars on the classpath/module path not being properly aligned.") // + .hasMessageContaining("Please ensure consistent versions are used") // + .hasMessageFindingMatch("https://junit\\.org/junit5/docs/.*/user-guide/#dependency-metadata") // + .hasMessageContaining("The following conflicting versions were detected:") // + .hasMessageContaining("- org.junit.jupiter.api: 1.0.0") // + .hasMessageContaining("- org.junit.jupiter.engine: 2.0.0") // + .cause().isSameAs(cause); + } + + @Test + void allRootPackagesAreChecked() { + var allowedFileNames = Pattern.compile("junit-(?:platform|jupiter|vintage)-.+[\\d.]+(?:-SNAPSHOT)?\\.jar"); + var classGraph = new ClassGraph() // + .acceptPackages("org.junit.platform", "org.junit.jupiter", "org.junit.vintage") // + .rejectPackages("org.junit.platform.reporting.shadow", "org.junit.jupiter.params.shadow") // + .filterClasspathElements(e -> { + var path = Path.of(e); + var fileName = path.getFileName().toString(); + return allowedFileNames.matcher(fileName).matches(); + }); + + try (var scanResult = classGraph.scan()) { + var foundPackages = scanResult.getPackageInfo().stream() // + .filter(it -> !it.getClassInfo().isEmpty()) // + .map(PackageInfo::getName) // + .sorted() // + .toList(); + + assertThat(foundPackages) // + .allMatch(name -> WELL_KNOWN_PACKAGES.stream().anyMatch(name::startsWith)); + assertThat(WELL_KNOWN_PACKAGES) // + .allMatch(name -> foundPackages.stream().anyMatch(it -> it.startsWith(name))); + } + } +} diff --git a/platform-tooling-support-tests/projects/maven-starter/pom.xml b/platform-tooling-support-tests/projects/maven-starter/pom.xml index 4f1552a66cab..c03e69a3277c 100644 --- a/platform-tooling-support-tests/projects/maven-starter/pom.xml +++ b/platform-tooling-support-tests/projects/maven-starter/pom.xml @@ -11,9 +11,16 @@ UTF-8 1.8 ${maven.compiler.source} + ${junit.platform.version} + + org.junit.platform + junit-platform-commons + ${junit.platform.commons.version} + test + org.junit.jupiter junit-jupiter diff --git a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/JavaVersionsTests.java b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/JavaVersionsTests.java index 8cbe4e3ea29b..4a7cce72c99e 100644 --- a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/JavaVersionsTests.java +++ b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/JavaVersionsTests.java @@ -21,6 +21,7 @@ import java.util.Map; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.JRE; import org.junit.jupiter.api.io.TempDir; import org.junit.platform.tests.process.OutputFiles; @@ -50,7 +51,7 @@ void java_8(@FilePrefix("maven") OutputFiles outputFiles) throws Exception { @Test void java_default(@FilePrefix("maven") OutputFiles outputFiles) throws Exception { - var actualLines = execute(currentJdkHome(), outputFiles, MavenEnvVars.FOR_JDK24_AND_LATER); + var actualLines = execute(currentJdkHome(), outputFiles, MavenEnvVars.forJre(JRE.currentVersion())); assertTrue(actualLines.contains("[WARNING] Tests run: 2, Failures: 0, Errors: 0, Skipped: 1")); } diff --git a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/MavenEnvVars.java b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/MavenEnvVars.java index f502c6120acd..115ea298e815 100644 --- a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/MavenEnvVars.java +++ b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/MavenEnvVars.java @@ -16,14 +16,15 @@ final class MavenEnvVars { - static final Map FOR_JDK24_AND_LATER = JRE.currentVersion().compareTo(JRE.JAVA_24) >= 0 // - ? Map.of("MAVEN_OPTS", String.join(" ", // - "--enable-native-access=ALL-UNNAMED", // https://issues.apache.org/jira/browse/MNG-8248 - "--sun-misc-unsafe-memory-access=allow" // https://issues.apache.org/jira/browse/MNG-8399 - )) // - : Map.of(); + private static final Map FOR_JDK24_AND_LATER = Map.of("MAVEN_OPTS", String.join(" ", // + "--enable-native-access=ALL-UNNAMED", // https://issues.apache.org/jira/browse/MNG-8248 + "--sun-misc-unsafe-memory-access=allow" // https://issues.apache.org/jira/browse/MNG-8399 + )); - private MavenEnvVars() { + static Map forJre(JRE jre) { + return jre.compareTo(JRE.JAVA_24) >= 0 ? FOR_JDK24_AND_LATER : Map.of(); } + private MavenEnvVars() { + } } diff --git a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/MultiReleaseJarTests.java b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/MultiReleaseJarTests.java index 27f3c2a762b9..c07954ac5c5e 100644 --- a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/MultiReleaseJarTests.java +++ b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/MultiReleaseJarTests.java @@ -21,6 +21,7 @@ import java.util.List; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.JRE; import org.junit.jupiter.api.io.TempDir; import org.junit.platform.tests.process.OutputFiles; @@ -76,7 +77,7 @@ void checkDefault(@TempDir Path workspace, @FilePrefix("maven") OutputFiles outp .addArguments("-Dsnapshot.repo.url=" + mavenRepoProxy.getBaseUri()) // .addArguments("--update-snapshots", "--show-version", "--errors", "--batch-mode") // .addArguments("test") // - .putEnvironment(MavenEnvVars.FOR_JDK24_AND_LATER) // + .putEnvironment(MavenEnvVars.forJre(JRE.currentVersion())) // .redirectOutput(outputFiles) // .startAndWait(); diff --git a/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/UnalignedClasspathTests.java b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/UnalignedClasspathTests.java new file mode 100644 index 000000000000..a6467c511564 --- /dev/null +++ b/platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/UnalignedClasspathTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2015-2025 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 platform.tooling.support.tests; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.parallel.ExecutionMode.SAME_THREAD; +import static platform.tooling.support.ProcessStarters.currentJdkHome; +import static platform.tooling.support.tests.Projects.copyToWorkspace; + +import java.nio.file.Path; +import java.util.stream.Stream; + +import org.junit.jupiter.api.condition.JRE; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.platform.tests.process.OutputFiles; + +import platform.tooling.support.Helper; +import platform.tooling.support.MavenRepo; +import platform.tooling.support.ProcessStarters; + +/** + * @since 1.3 + */ +class UnalignedClasspathTests { + + @GlobalResource + LocalMavenRepo localMavenRepo; + + @GlobalResource + MavenRepoProxy mavenRepoProxy; + + @ParameterizedTest + @MethodSource("javaVersions") + @Execution(SAME_THREAD) + void verifyErrorMessageForUnalignedClasspath(JRE jre, Path javaHome, @TempDir Path workspace, + @FilePrefix("maven") OutputFiles outputFiles) throws Exception { + var starter = ProcessStarters.maven(javaHome) // + .workingDir(copyToWorkspace(Projects.MAVEN_STARTER, workspace)) // + .addArguments(localMavenRepo.toCliArgument(), "-Dmaven.repo=" + MavenRepo.dir()) // + .addArguments("-Dsnapshot.repo.url=" + mavenRepoProxy.getBaseUri()) // + .addArguments("-Djunit.platform.commons.version=1.11.4").addArguments("--update-snapshots", + "--batch-mode", "verify") // + .putEnvironment(MavenEnvVars.forJre(jre)) // + .redirectOutput(outputFiles); + var result = starter.startAndWait(); + + assertEquals(1, result.exitCode()); + assertEquals("", result.stdErr()); + assertTrue(result.stdOutLines().contains("[INFO] BUILD FAILURE")); + assertThat(result.stdOut()) // + .contains("The wrapped NoClassDefFoundError is likely caused by the versions of JUnit jars " + + "on the classpath/module path not being properly aligned"); + } + + static Stream javaVersions() { + return Stream.concat( // + Helper.getJavaHome("8").map(path -> Arguments.of(JRE.JAVA_8, path)).stream(), // + Stream.of(Arguments.of(JRE.currentVersion(), currentJdkHome())) // + ); + } +}