Skip to content

Commit

Permalink
Check for classpath alignment on LinkageErrors
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 committed Jan 12, 2025
1 parent 097b7bf commit 11e0200
Show file tree
Hide file tree
Showing 9 changed files with 342 additions and 9 deletions.
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 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 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
Loading

0 comments on commit 11e0200

Please sign in to comment.