Skip to content

Commit 1a03531

Browse files
authored
Check for classpath alignment on LinkageErrors (#4244)
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.
1 parent 86a64bb commit 1a03531

File tree

13 files changed

+375
-18
lines changed

13 files changed

+375
-18
lines changed

documentation/src/docs/asciidoc/user-guide/appendix.adoc

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,26 @@ Artifacts for final releases and milestones are deployed to {Maven_Central}, and
2121
artifacts are deployed to Sonatype's {snapshot-repo}[snapshots repository] under
2222
{snapshot-repo}/org/junit/[/org/junit].
2323

24+
The sections below list all artifacts with their versions for the three groups:
25+
<<dependency-metadata-junit-platform, Platform>>,
26+
<<dependency-metadata-junit-jupiter, Jupiter>>, and
27+
<<dependency-metadata-junit-vintage, Vintage>>.
28+
The <<dependency-metadata-junit-bom, Bill of Materials (BOM)>> contains a list of all
29+
of the above artifacts and their versions.
30+
31+
[TIP]
32+
.Aligning dependency versions
33+
====
34+
To ensure that all JUnit artifacts are compatible with each other, their versions should
35+
be aligned.
36+
If you rely on <<running-tests-build-spring-boot, Spring Boot>> for dependency management,
37+
please see the corresponding section.
38+
Otherwise, instead of managing individual versions of the JUnit artifacts, it is
39+
recommended to apply the <<dependency-metadata-junit-bom, BOM>> to your project.
40+
Please refer to the corresponding sections for <<running-tests-build-maven-bom, Maven>> or
41+
<<running-tests-build-gradle-bom, Gradle>>.
42+
====
43+
2444
[[dependency-metadata-junit-platform]]
2545
==== JUnit Platform
2646

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Copyright 2015-2025 the original author or authors.
3+
*
4+
* All rights reserved. This program and the accompanying materials are
5+
* made available under the terms of the Eclipse Public License v2.0 which
6+
* accompanies this distribution and is available at
7+
*
8+
* https://www.eclipse.org/legal/epl-v20.html
9+
*/
10+
11+
package org.junit.platform.launcher.core;
12+
13+
import static java.util.Collections.unmodifiableList;
14+
import static java.util.Comparator.comparing;
15+
16+
import java.util.ArrayList;
17+
import java.util.Arrays;
18+
import java.util.HashMap;
19+
import java.util.List;
20+
import java.util.Map;
21+
import java.util.Objects;
22+
import java.util.Optional;
23+
import java.util.function.Function;
24+
25+
import org.junit.platform.commons.JUnitException;
26+
import org.junit.platform.commons.support.ReflectionSupport;
27+
import org.junit.platform.commons.util.ClassLoaderUtils;
28+
29+
/**
30+
* @since 1.12
31+
*/
32+
class ClasspathAlignmentChecker {
33+
34+
// VisibleForTesting
35+
static final List<String> WELL_KNOWN_PACKAGES = unmodifiableList(Arrays.asList( //
36+
"org.junit.jupiter.api", //
37+
"org.junit.jupiter.engine", //
38+
"org.junit.jupiter.migrationsupport", //
39+
"org.junit.jupiter.params", //
40+
"org.junit.platform.commons", //
41+
"org.junit.platform.console", //
42+
"org.junit.platform.engine", //
43+
"org.junit.platform.jfr", //
44+
"org.junit.platform.launcher", //
45+
"org.junit.platform.reporting", //
46+
"org.junit.platform.runner", //
47+
"org.junit.platform.suite.api", //
48+
"org.junit.platform.suite.commons", //
49+
"org.junit.platform.suite.engine", //
50+
"org.junit.platform.testkit", //
51+
"org.junit.vintage.engine" //
52+
));
53+
54+
static Optional<JUnitException> check(LinkageError error) {
55+
ClassLoader classLoader = ClassLoaderUtils.getClassLoader(ClasspathAlignmentChecker.class);
56+
Function<String, Package> packageLookup = name -> ReflectionSupport.findMethod(ClassLoader.class,
57+
"getDefinedPackage", String.class) //
58+
.map(m -> (Package) ReflectionSupport.invokeMethod(m, classLoader, name)) //
59+
.orElseGet(() -> getPackage(name));
60+
return check(error, packageLookup);
61+
}
62+
63+
// VisibleForTesting
64+
static Optional<JUnitException> check(LinkageError error, Function<String, Package> packageLookup) {
65+
Map<String, List<Package>> packagesByVersions = new HashMap<>();
66+
WELL_KNOWN_PACKAGES.stream() //
67+
.map(packageLookup) //
68+
.filter(Objects::nonNull) //
69+
.forEach(pkg -> {
70+
String version = pkg.getImplementationVersion();
71+
if (version != null) {
72+
if (pkg.getName().startsWith("org.junit.platform") && version.contains(".")) {
73+
version = platformToJupiterVersion(version);
74+
}
75+
packagesByVersions.computeIfAbsent(version, __ -> new ArrayList<>()).add(pkg);
76+
}
77+
});
78+
if (packagesByVersions.size() > 1) {
79+
StringBuilder message = new StringBuilder();
80+
String lineBreak = System.lineSeparator();
81+
message.append("The wrapped ").append(error.getClass().getSimpleName()) //
82+
.append(" is likely caused by the versions of JUnit jars on the classpath/module path ") //
83+
.append("not being properly aligned. ") //
84+
.append(lineBreak) //
85+
.append("Please ensure consistent versions are used (see https://junit.org/junit5/docs/") //
86+
.append(platformToJupiterVersion(
87+
ClasspathAlignmentChecker.class.getPackage().getImplementationVersion())) //
88+
.append("/user-guide/#dependency-metadata).") //
89+
.append(lineBreak) //
90+
.append("The following conflicting versions were detected:").append(lineBreak);
91+
packagesByVersions.values().stream() //
92+
.flatMap(List::stream) //
93+
.sorted(comparing(Package::getName)) //
94+
.map(pkg -> String.format("- %s: %s%n", pkg.getName(), pkg.getImplementationVersion())) //
95+
.forEach(message::append);
96+
return Optional.of(new JUnitException(message.toString(), error));
97+
}
98+
return Optional.empty();
99+
}
100+
101+
private static String platformToJupiterVersion(String version) {
102+
int majorVersion = Integer.parseInt(version.substring(0, version.indexOf("."))) + 4;
103+
return majorVersion + version.substring(version.indexOf("."));
104+
}
105+
106+
@SuppressWarnings({ "deprecation", "RedundantSuppression" }) // only called when running on JDK 8
107+
private static Package getPackage(String name) {
108+
return Package.getPackage(name);
109+
}
110+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/*
2+
* Copyright 2015-2025 the original author or authors.
3+
*
4+
* All rights reserved. This program and the accompanying materials are
5+
* made available under the terms of the Eclipse Public License v2.0 which
6+
* accompanies this distribution and is available at
7+
*
8+
* https://www.eclipse.org/legal/epl-v20.html
9+
*/
10+
11+
package org.junit.platform.launcher.core;
12+
13+
import java.util.Optional;
14+
15+
import org.junit.platform.commons.JUnitException;
16+
import org.junit.platform.launcher.LauncherInterceptor;
17+
18+
class ClasspathAlignmentCheckingLauncherInterceptor implements LauncherInterceptor {
19+
20+
static final LauncherInterceptor INSTANCE = new ClasspathAlignmentCheckingLauncherInterceptor();
21+
22+
@Override
23+
public <T> T intercept(Invocation<T> invocation) {
24+
try {
25+
return invocation.proceed();
26+
}
27+
catch (LinkageError e) {
28+
Optional<JUnitException> exception = ClasspathAlignmentChecker.check(e);
29+
if (exception.isPresent()) {
30+
throw exception.get();
31+
}
32+
throw e;
33+
}
34+
}
35+
36+
@Override
37+
public void close() {
38+
// do nothing
39+
}
40+
}

junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/EngineDiscoveryOrchestrator.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,14 @@ private TestDescriptor discoverEngineRoot(TestEngine testEngine, LauncherDiscove
156156
}
157157
catch (Throwable throwable) {
158158
UnrecoverableExceptions.rethrowIfUnrecoverable(throwable);
159-
String message = String.format("TestEngine with ID '%s' failed to discover tests", testEngine.getId());
160-
JUnitException cause = new JUnitException(message, throwable);
159+
JUnitException cause = null;
160+
if (throwable instanceof LinkageError) {
161+
cause = ClasspathAlignmentChecker.check((LinkageError) throwable).orElse(null);
162+
}
163+
if (cause == null) {
164+
String message = String.format("TestEngine with ID '%s' failed to discover tests", testEngine.getId());
165+
cause = new JUnitException(message, throwable);
166+
}
161167
listener.engineDiscoveryFinished(uniqueEngineId, EngineDiscoveryResult.failed(cause));
162168
return new EngineDiscoveryErrorDescriptor(uniqueEngineId, testEngine, cause);
163169
}

junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/EngineExecutionOrchestrator.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,15 @@ private void execute(TestDescriptor engineDescriptor, EngineExecutionListener li
200200
}
201201
catch (Throwable throwable) {
202202
UnrecoverableExceptions.rethrowIfUnrecoverable(throwable);
203-
delayingListener.reportEngineFailure(new JUnitException(
204-
String.format("TestEngine with ID '%s' failed to execute tests", testEngine.getId()), throwable));
203+
JUnitException cause = null;
204+
if (throwable instanceof LinkageError) {
205+
cause = ClasspathAlignmentChecker.check((LinkageError) throwable).orElse(null);
206+
}
207+
if (cause == null) {
208+
String message = String.format("TestEngine with ID '%s' failed to execute tests", testEngine.getId());
209+
cause = new JUnitException(message, throwable);
210+
}
211+
delayingListener.reportEngineFailure(cause);
205212
}
206213
}
207214
}

junit-platform-launcher/src/main/java/org/junit/platform/launcher/core/LauncherFactory.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010

1111
package org.junit.platform.launcher.core;
1212

13-
import static java.util.Collections.emptyList;
1413
import static org.apiguardian.api.API.Status.STABLE;
1514
import static org.junit.platform.launcher.LauncherConstants.DEACTIVATE_LISTENERS_PATTERN_PROPERTY_NAME;
1615
import static org.junit.platform.launcher.LauncherConstants.ENABLE_LAUNCHER_INTERCEPTORS;
@@ -145,12 +144,12 @@ private static DefaultLauncher createDefaultLauncher(LauncherConfig config,
145144

146145
private static List<LauncherInterceptor> collectLauncherInterceptors(
147146
LauncherConfigurationParameters configurationParameters) {
147+
List<LauncherInterceptor> interceptors = new ArrayList<>();
148148
if (configurationParameters.getBoolean(ENABLE_LAUNCHER_INTERCEPTORS).orElse(false)) {
149-
List<LauncherInterceptor> interceptors = new ArrayList<>();
150149
ServiceLoaderRegistry.load(LauncherInterceptor.class).forEach(interceptors::add);
151-
return interceptors;
152150
}
153-
return emptyList();
151+
interceptors.add(ClasspathAlignmentCheckingLauncherInterceptor.INSTANCE);
152+
return interceptors;
154153
}
155154

156155
private static Set<TestEngine> collectTestEngines(LauncherConfig config) {

platform-tests/platform-tests.gradle.kts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ dependencies {
4747
testImplementation(projects.junitJupiterEngine)
4848
testImplementation(testFixtures(projects.junitJupiterEngine))
4949
testImplementation(libs.apiguardian)
50+
testImplementation(libs.classgraph)
5051
testImplementation(libs.jfrunit) {
5152
exclude(group = "org.junit.vintage")
5253
}
@@ -63,7 +64,11 @@ dependencies {
6364
}
6465

6566
// --- Test run-time dependencies ---------------------------------------------
66-
testRuntimeOnly(projects.junitVintageEngine)
67+
val mavenizedProjects: List<Project> by rootProject
68+
mavenizedProjects.filter { it.path != projects.junitPlatformConsoleStandalone.path }.forEach {
69+
// Add all projects to the classpath for tests using classpath scanning
70+
testRuntimeOnly(it)
71+
}
6772
testRuntimeOnly(libs.groovy4) {
6873
because("`ReflectionUtilsTests.findNestedClassesWithInvalidNestedClassFile` needs it")
6974
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
/*
2+
* Copyright 2015-2025 the original author or authors.
3+
*
4+
* All rights reserved. This program and the accompanying materials are
5+
* made available under the terms of the Eclipse Public License v2.0 which
6+
* accompanies this distribution and is available at
7+
*
8+
* https://www.eclipse.org/legal/epl-v20.html
9+
*/
10+
11+
package org.junit.platform.launcher.core;
12+
13+
import static org.assertj.core.api.Assertions.assertThat;
14+
import static org.junit.platform.launcher.core.ClasspathAlignmentChecker.WELL_KNOWN_PACKAGES;
15+
import static org.mockito.Mockito.mock;
16+
import static org.mockito.Mockito.when;
17+
18+
import java.nio.file.Path;
19+
import java.util.concurrent.atomic.AtomicInteger;
20+
import java.util.function.Function;
21+
import java.util.regex.Pattern;
22+
23+
import io.github.classgraph.ClassGraph;
24+
import io.github.classgraph.PackageInfo;
25+
26+
import org.junit.jupiter.api.Test;
27+
28+
class ClasspathAlignmentCheckerTests {
29+
30+
@Test
31+
void classpathIsAligned() {
32+
assertThat(ClasspathAlignmentChecker.check(new LinkageError())).isEmpty();
33+
}
34+
35+
@Test
36+
void wrapsLinkageErrorForUnalignedClasspath() {
37+
var cause = new LinkageError();
38+
AtomicInteger counter = new AtomicInteger();
39+
Function<String, Package> packageLookup = name -> {
40+
var pkg = mock(Package.class);
41+
when(pkg.getName()).thenReturn(name);
42+
when(pkg.getImplementationVersion()).thenReturn(counter.incrementAndGet() + ".0.0");
43+
return pkg;
44+
};
45+
46+
var result = ClasspathAlignmentChecker.check(cause, packageLookup);
47+
48+
assertThat(result).isPresent();
49+
assertThat(result.get()) //
50+
.hasMessageStartingWith("The wrapped LinkageError is likely caused by the versions of "
51+
+ "JUnit jars on the classpath/module path not being properly aligned.") //
52+
.hasMessageContaining("Please ensure consistent versions are used") //
53+
.hasMessageFindingMatch("https://junit\\.org/junit5/docs/.*/user-guide/#dependency-metadata") //
54+
.hasMessageContaining("The following conflicting versions were detected:") //
55+
.hasMessageContaining("- org.junit.jupiter.api: 1.0.0") //
56+
.hasMessageContaining("- org.junit.jupiter.engine: 2.0.0") //
57+
.cause().isSameAs(cause);
58+
}
59+
60+
@Test
61+
void allRootPackagesAreChecked() {
62+
var allowedFileNames = Pattern.compile("junit-(?:platform|jupiter|vintage)-.+[\\d.]+(?:-SNAPSHOT)?\\.jar");
63+
var classGraph = new ClassGraph() //
64+
.acceptPackages("org.junit.platform", "org.junit.jupiter", "org.junit.vintage") //
65+
.rejectPackages("org.junit.platform.reporting.shadow", "org.junit.jupiter.params.shadow") //
66+
.filterClasspathElements(e -> {
67+
var path = Path.of(e);
68+
var fileName = path.getFileName().toString();
69+
return allowedFileNames.matcher(fileName).matches();
70+
});
71+
72+
try (var scanResult = classGraph.scan()) {
73+
var foundPackages = scanResult.getPackageInfo().stream() //
74+
.filter(it -> !it.getClassInfo().isEmpty()) //
75+
.map(PackageInfo::getName) //
76+
.sorted() //
77+
.toList();
78+
79+
assertThat(foundPackages) //
80+
.allMatch(name -> WELL_KNOWN_PACKAGES.stream().anyMatch(name::startsWith));
81+
assertThat(WELL_KNOWN_PACKAGES) //
82+
.allMatch(name -> foundPackages.stream().anyMatch(it -> it.startsWith(name)));
83+
}
84+
}
85+
}

platform-tooling-support-tests/projects/maven-starter/pom.xml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,16 @@
1111
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
1212
<maven.compiler.source>1.8</maven.compiler.source>
1313
<maven.compiler.target>${maven.compiler.source}</maven.compiler.target>
14+
<junit.platform.commons.version>${junit.platform.version}</junit.platform.commons.version>
1415
</properties>
1516

1617
<dependencies>
18+
<dependency>
19+
<groupId>org.junit.platform</groupId>
20+
<artifactId>junit-platform-commons</artifactId>
21+
<version>${junit.platform.commons.version}</version>
22+
<scope>test</scope>
23+
</dependency>
1724
<dependency>
1825
<groupId>org.junit.jupiter</groupId>
1926
<artifactId>junit-jupiter</artifactId>

platform-tooling-support-tests/src/test/java/platform/tooling/support/tests/JavaVersionsTests.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import java.util.Map;
2222

2323
import org.junit.jupiter.api.Test;
24+
import org.junit.jupiter.api.condition.JRE;
2425
import org.junit.jupiter.api.io.TempDir;
2526
import org.junit.platform.tests.process.OutputFiles;
2627

@@ -50,7 +51,7 @@ void java_8(@FilePrefix("maven") OutputFiles outputFiles) throws Exception {
5051

5152
@Test
5253
void java_default(@FilePrefix("maven") OutputFiles outputFiles) throws Exception {
53-
var actualLines = execute(currentJdkHome(), outputFiles, MavenEnvVars.FOR_JDK24_AND_LATER);
54+
var actualLines = execute(currentJdkHome(), outputFiles, MavenEnvVars.forJre(JRE.currentVersion()));
5455

5556
assertTrue(actualLines.contains("[WARNING] Tests run: 2, Failures: 0, Errors: 0, Skipped: 1"));
5657
}

0 commit comments

Comments
 (0)