Skip to content

Commit

Permalink
Check for classpath alignment on LinkageErrors (#4244)
Browse files Browse the repository at this point in the history
The Launcher now checks for classpath alignment in case a LinkageError
such as a ClassNotFoundError is thrown from one of the methods of the
Launcher or TestEngine interfaces. If it finds unaligned versions, it
wraps the LinkageError in a JUnitException with a message listing the
detected versions and a link to the User Guide.

Resolves #3935.
  • Loading branch information
marcphilipp authored Jan 15, 2025
1 parent 86a64bb commit 1a03531
Show file tree
Hide file tree
Showing 13 changed files with 375 additions and 18 deletions.
20 changes: 20 additions & 0 deletions documentation/src/docs/asciidoc/user-guide/appendix.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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:
<<dependency-metadata-junit-platform, Platform>>,
<<dependency-metadata-junit-jupiter, Jupiter>>, and
<<dependency-metadata-junit-vintage, Vintage>>.
The <<dependency-metadata-junit-bom, Bill of Materials (BOM)>> 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 <<running-tests-build-spring-boot, Spring Boot>> for dependency management,
please see the corresponding section.
Otherwise, instead of managing individual versions of the JUnit artifacts, it is
recommended to apply the <<dependency-metadata-junit-bom, BOM>> to your project.
Please refer to the corresponding sections for <<running-tests-build-maven-bom, Maven>> or
<<running-tests-build-gradle-bom, Gradle>>.
====

[[dependency-metadata-junit-platform]]
==== JUnit Platform

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> 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<JUnitException> check(LinkageError error) {
ClassLoader classLoader = ClassLoaderUtils.getClassLoader(ClasspathAlignmentChecker.class);
Function<String, Package> 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<JUnitException> check(LinkageError error, Function<String, Package> packageLookup) {
Map<String, List<Package>> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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> T intercept(Invocation<T> invocation) {
try {
return invocation.proceed();
}
catch (LinkageError e) {
Optional<JUnitException> exception = ClasspathAlignmentChecker.check(e);
if (exception.isPresent()) {
throw exception.get();
}
throw e;
}
}

@Override
public void close() {
// do nothing
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -145,12 +144,12 @@ private static DefaultLauncher createDefaultLauncher(LauncherConfig config,

private static List<LauncherInterceptor> collectLauncherInterceptors(
LauncherConfigurationParameters configurationParameters) {
List<LauncherInterceptor> interceptors = new ArrayList<>();
if (configurationParameters.getBoolean(ENABLE_LAUNCHER_INTERCEPTORS).orElse(false)) {
List<LauncherInterceptor> interceptors = new ArrayList<>();
ServiceLoaderRegistry.load(LauncherInterceptor.class).forEach(interceptors::add);
return interceptors;
}
return emptyList();
interceptors.add(ClasspathAlignmentCheckingLauncherInterceptor.INSTANCE);
return interceptors;
}

private static Set<TestEngine> collectTestEngines(LauncherConfig config) {
Expand Down
7 changes: 6 additions & 1 deletion platform-tests/platform-tests.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -63,7 +64,11 @@ dependencies {
}

// --- Test run-time dependencies ---------------------------------------------
testRuntimeOnly(projects.junitVintageEngine)
val mavenizedProjects: List<Project> 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")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Package> 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)));
}
}
}
7 changes: 7 additions & 0 deletions platform-tooling-support-tests/projects/maven-starter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,16 @@
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>${maven.compiler.source}</maven.compiler.target>
<junit.platform.commons.version>${junit.platform.version}</junit.platform.commons.version>
</properties>

<dependencies>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-commons</artifactId>
<version>${junit.platform.commons.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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"));
}
Expand Down
Loading

0 comments on commit 1a03531

Please sign in to comment.