Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) //
Comment on lines +56 to +57
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Java 9+

.map(m -> (Package) ReflectionSupport.invokeMethod(m, classLoader, name)) //
.orElseGet(() -> getPackage(name));
Comment on lines +56 to +59
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only works for packages for which classes have already been loaded by the class loader and thus is likely to contain the conflicting JARs.

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
Loading