Skip to content

[GR-60258] Refactor JUnit feature #693

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 28 commits into from
May 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5853e8f
Re-implement JUnit suppport
dnestoro Feb 6, 2025
d041294
Make the feature work with previous class initialization strategy
dnestoro Feb 19, 2025
6ec21ea
Add maven and gradle functional tests for JUnit
dnestoro Feb 20, 2025
f9755ae
Add default test-ids locations
dnestoro Feb 20, 2025
66acdca
Fix MethodSourceTests for JUnit 5.11.0
dnestoro Feb 20, 2025
bd36fc6
Use system property to calculate test-ids location
dnestoro Feb 20, 2025
faff29f
Restore code that provides support for Timeout annotation
dnestoro Feb 21, 2025
1e66e70
Add more checks in JUnitFunctionalTests
dnestoro Feb 21, 2025
76ddff1
Rename JUnit functional tests
dnestoro Feb 28, 2025
ec2a319
Make fallback test-ids search more roubust
dnestoro Feb 28, 2025
50a7ac9
Throw an error when unique file prefix is not avaialable at runtime
dnestoro Mar 4, 2025
9b4c249
Add buildtime checks for test-ids directory and prefix
dnestoro Mar 4, 2025
f94a05e
Register org.junit.runner.Description fields for reflection when usin…
dnestoro Mar 17, 2025
4ce03e7
Refactor usages of flatmap
dnestoro Mar 17, 2025
6519022
Support RunWith annotation for JUnit4
dnestoro Mar 20, 2025
f3be7db
Improve error messages
dnestoro Mar 27, 2025
dd4f497
Use several specific packages instead of one general when initializin…
dnestoro Apr 2, 2025
82d3fbb
Store all classes that should be initialized at buildtime for earlier…
dnestoro Apr 7, 2025
3364e05
Extract annottaion element fetching into a separate method
dnestoro Apr 10, 2025
c890433
Improve error message and rename function for initializing classes fo…
dnestoro Apr 10, 2025
cae0ced
Adjust initialize-at-buildtime list with JUnit list
dnestoro Apr 17, 2025
c3979f4
Add exclude config for JUnit versions that provide initialize-at-buil…
dnestoro Apr 22, 2025
7f51603
Add exclude config in both Gradle and Maven
dnestoro Apr 22, 2025
04198fc
Use JUnit 5.11.0 version
dnestoro Apr 23, 2025
6f9748e
Use try with resources to properly close BufferedReader
dnestoro Apr 24, 2025
847ef48
Register all class members of inner classes for reflection
dnestoro Apr 28, 2025
2c2ec24
Remove unused java.beans.Introspector entry form initialize-at-buildt…
dnestoro Apr 28, 2025
5b8d5fe
Avoid infinite loops when registering test classes for reflection
dnestoro Apr 30, 2025
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
1 change: 1 addition & 0 deletions common/graalvm-reachability-metadata/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ dependencies {
implementation(libs.openjson)
testImplementation(platform(libs.test.junit.bom))
testImplementation(libs.test.junit.jupiter.core)
testRuntimeOnly(libs.test.junit.platform.launcher)
}

tasks.withType<FetchRepositoryMetadata>().configureEach {
Expand Down
1 change: 1 addition & 0 deletions common/junit-platform-native/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ dependencies {
implementation libs.test.junit.platform.launcher
implementation libs.test.junit.jupiter.core
testImplementation libs.test.junit.vintage
testRuntimeOnly libs.test.junit.platform.launcher
}

apply from: "gradle/native-image-testing.gradle"
Expand Down
17 changes: 17 additions & 0 deletions common/junit-platform-native/gradle/native-image-testing.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/

def agentOutput = layout.buildDirectory.dir("agent")

ext {
Expand Down Expand Up @@ -90,6 +91,17 @@ abstract class NativeTestArgumentProvider implements CommandLineArgumentProvider
@Input
abstract Property<Boolean> getDiscovery()

@InputDirectory
abstract DirectoryProperty getExcludeConfigDir()

private List<String> addExcludeConfig() {
String[] jarPatternPair = getExcludeConfigDir().get().file("exclude-config").getAsFile().readLines().get(0).split(",")
var jar = jarPatternPair[0]
var pattern = jarPatternPair[1]

return ["--exclude-config", jar, pattern]
}

@Override
Iterable<String> asArguments() {
def args = [
Expand All @@ -99,6 +111,9 @@ abstract class NativeTestArgumentProvider implements CommandLineArgumentProvider
"-o", "native-image-tests",
"-Djunit.platform.listeners.uid.tracking.output.dir=${testIdsDir.get().asFile.absolutePath}"
]

args.addAll(addExcludeConfig())

if (agentOutputDir.isPresent()) {
def outputDir = agentOutputDir.get().asFile
if (!outputDir.exists()) {
Expand Down Expand Up @@ -134,6 +149,7 @@ tasks.register("nativeTestCompile", Exec) {
def argsProvider = objects.newInstance(NativeTestArgumentProvider)
argsProvider.classpath.from(test.classpath)
argsProvider.testIdsDir.set(testIdsDir)
argsProvider.excludeConfigDir.set(layout.buildDirectory.dir("resources").get().dir("main").dir("extra-build-args"))
argsProvider.agentOutputDir.set(agentOutput)
argsProvider.discovery.set(providers.systemProperty("testDiscovery").map(v -> Boolean.valueOf(v)).orElse(false))
argumentProviders.add(argsProvider)
Expand All @@ -143,4 +159,5 @@ tasks.register("nativeTest", Exec) {
dependsOn nativeTestCompile
workingDir = "${buildDir}"
executable = "${buildDir}/native-image-tests"
args = ["-Djunit.platform.listeners.uid.tracking.output.dir=${testIdsDir.get().asFile.absolutePath}"]
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@
import org.graalvm.nativeimage.ImageSingletons;
import org.graalvm.nativeimage.hosted.Feature;
import org.graalvm.nativeimage.hosted.RuntimeClassInitialization;
import org.graalvm.nativeimage.hosted.RuntimeReflection;

import org.junit.platform.engine.DiscoverySelector;
import org.junit.platform.engine.discovery.DiscoverySelectors;
import org.junit.platform.engine.discovery.UniqueIdSelector;
Expand All @@ -57,15 +59,19 @@
import org.junit.platform.launcher.core.LauncherFactory;
import org.junit.platform.launcher.listeners.UniqueIdTrackingListener;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.ServiceLoader;
import java.util.Set;
import java.util.function.BiConsumer;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
Expand All @@ -74,66 +80,85 @@
public final class JUnitPlatformFeature implements Feature {

public final boolean debug = System.getProperty("debug") != null;

private static final NativeImageConfigurationImpl nativeImageConfigImpl = new NativeImageConfigurationImpl();
private final ServiceLoader<PluginConfigProvider> extensionConfigProviders = ServiceLoader.load(PluginConfigProvider.class);

public static void debug(String format, Object... args) {
if (debug()) {
System.out.printf("[Debug] " + format + "%n", args);
}
}

@Override
public void afterRegistration(AfterRegistrationAccess access) {
extensionConfigProviders.forEach(p -> p.initialize(access.getApplicationClassLoader(), nativeImageConfigImpl));
}

private static boolean debug() {
return ImageSingletons.lookup(JUnitPlatformFeature.class).debug;
}

@Override
public void duringSetup(DuringSetupAccess access) {
forEachProvider(p -> p.onLoad(nativeImageConfigImpl));
}

@Override
public void beforeAnalysis(BeforeAnalysisAccess access) {
RuntimeClassInitialization.initializeAtBuildTime(NativeImageJUnitLauncher.class);
/* Before GraalVM version 22 we couldn't have classes initialized at run-time
* that are also used at build-time but not added to the image heap */
if (Runtime.version().feature() <= 21) {
initializeClassesForJDK21OrEarlier();
}

List<Path> classpathRoots = access.getApplicationClassPath();
List<? extends DiscoverySelector> selectors = getSelectors(classpathRoots);
List<? extends DiscoverySelector> selectors = getSelectors();
registerTestClassesForReflection(selectors);

Launcher launcher = LauncherFactory.create();
TestPlan testplan = discoverTestsAndRegisterTestClassesForReflection(launcher, selectors);
ImageSingletons.add(NativeImageJUnitLauncher.class, new NativeImageJUnitLauncher(launcher, testplan));
/* support for JUnit Vintage */
registerClassesForHamcrestSupport(access);
}

private List<? extends DiscoverySelector> getSelectors(List<Path> classpathRoots) {
private List<? extends DiscoverySelector> getSelectors() {
try {
Path outputDir = Paths.get(System.getProperty(UniqueIdTrackingListener.OUTPUT_DIR_PROPERTY_NAME));
String prefix = System.getProperty(UniqueIdTrackingListener.OUTPUT_FILE_PREFIX_PROPERTY_NAME,
String uniqueIdDirectoryProperty = System.getProperty(UniqueIdTrackingListener.OUTPUT_DIR_PROPERTY_NAME);
if (uniqueIdDirectoryProperty == null) {
throw new IllegalStateException("Cannot determine test-ids directory because junit.platform.listeners.uid.tracking.output.dir property is null");
}

String uniqueIdFilePrefix = System.getProperty(UniqueIdTrackingListener.OUTPUT_FILE_PREFIX_PROPERTY_NAME,
UniqueIdTrackingListener.DEFAULT_OUTPUT_FILE_PREFIX);
List<UniqueIdSelector> selectors = readAllFiles(outputDir, prefix)
if (uniqueIdFilePrefix == null) {
throw new IllegalStateException("Cannot determine unique test-ids prefix because junit.platform.listeners.uid.tracking.output.file.prefix property is null");
}

Path uniqueIdDirectory = Path.of(uniqueIdDirectoryProperty);
List<UniqueIdSelector> selectors = readAllFiles(uniqueIdDirectory, uniqueIdFilePrefix)
.map(DiscoverySelectors::selectUniqueId)
.collect(Collectors.toList());
if (!selectors.isEmpty()) {
System.out.printf(
"[junit-platform-native] Running in 'test listener' mode using files matching pattern [%s*] "
+ "found in folder [%s] and its subfolders.%n",
prefix, outputDir.toAbsolutePath());
uniqueIdFilePrefix, uniqueIdDirectory.toAbsolutePath());
return selectors;
}
} catch (Exception ex) {
debug("Failed to read UIDs from UniqueIdTrackingListener output files: " + ex.getMessage());
}

System.out.println("[junit-platform-native] Running in 'test discovery' mode. Note that this is a fallback mode.");
if (debug) {
classpathRoots.forEach(entry -> debug("Selecting classpath root: " + entry));
}
return DiscoverySelectors.selectClasspathRoots(new HashSet<>(classpathRoots));
throw new RuntimeException("Cannot compute test selectors from test ids.");
}

/**
* Use the JUnit Platform Launcher to discover tests and register classes
* for reflection.
* Use the JUnit Platform Launcher to register classes for reflection.
*/
private TestPlan discoverTestsAndRegisterTestClassesForReflection(Launcher launcher,
List<? extends DiscoverySelector> selectors) {

private void registerTestClassesForReflection(List<? extends DiscoverySelector> selectors) {
LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
.selectors(selectors)
.build();

Launcher launcher = LauncherFactory.create();

Choose a reason for hiding this comment

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

Suggested change
Launcher launcher = LauncherFactory.create();
Set<Class<?>> registeredClasses = new HashSet<>();
Launcher launcher = LauncherFactory.create();

TestPlan testPlan = launcher.discover(request);

testPlan.getRoots().stream()
.flatMap(rootIdentifier -> testPlan.getDescendants(rootIdentifier).stream())
.map(TestIdentifier::getSource)
Expand All @@ -143,14 +168,44 @@ private TestPlan discoverTestsAndRegisterTestClassesForReflection(Launcher launc
.map(ClassSource.class::cast)
.map(ClassSource::getJavaClass)
.forEach(this::registerTestClassForReflection);

Choose a reason for hiding this comment

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

Suggested change
.forEach(this::registerTestClassForReflection);
.forEach(clazz -> registerTestClassForReflection(clazz, registeredClasses));

}

private final Set<Class<?>> registeredClasses = new HashSet<>();
Comment on lines +172 to +173

Choose a reason for hiding this comment

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

I don't think this should be a field. Instead, it should be possible to pass it as a paramter to registerTestClassForReflection and shouldRegisterClass.

Suggested change
private final Set<Class<?>> registeredClasses = new HashSet<>();


private boolean shouldRegisterClass(Class<?> clazz) {

Choose a reason for hiding this comment

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

Suggested change
private boolean shouldRegisterClass(Class<?> clazz) {
private boolean shouldRegisterClass(Class<?> clazz, Set<Class<?>> registeredClasses) {

/* avoid registering java internal classes */
if (ModuleLayer.boot().modules().contains(clazz.getModule())) {
return false;
}

/* avoid loops (possible case: class B is inner class of A, and B extends A) */
if (registeredClasses.contains(clazz)) {
return false;
}
registeredClasses.add(clazz);

return testPlan;
return true;
Comment on lines +182 to +187

Choose a reason for hiding this comment

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

This can be simplified to the following, but your version may be more readable.

        return registeredClasses.add(clazz);

}

private void registerTestClassForReflection(Class<?> clazz) {
if (!shouldRegisterClass(clazz)) {
Comment on lines 190 to +191

Choose a reason for hiding this comment

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

Suggested change
private void registerTestClassForReflection(Class<?> clazz) {
if (!shouldRegisterClass(clazz)) {
private void registerTestClassForReflection(Class<?> clazz, Set<Class<?>> registeredClasses) {
if (!shouldRegisterClass(clazz, registeredClasses)) {

return;
}

debug("Registering test class for reflection: %s", clazz.getName());
nativeImageConfigImpl.registerAllClassMembersForReflection(clazz);
forEachProvider(p -> p.onTestClassRegistered(clazz, nativeImageConfigImpl));

Class<?>[] declaredClasses = clazz.getDeclaredClasses();
for (Class<?> declaredClass : declaredClasses) {
registerTestClassForReflection(declaredClass);

Choose a reason for hiding this comment

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

Suggested change
registerTestClassForReflection(declaredClass);
registerTestClassForReflection(declaredClass, registeredClasses);

}

Class<?>[] interfaces = clazz.getInterfaces();
for (Class<?> inter : interfaces) {
registerTestClassForReflection(inter);

Choose a reason for hiding this comment

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

Suggested change
registerTestClassForReflection(inter);
registerTestClassForReflection(inter, registeredClasses);

}

Class<?> superClass = clazz.getSuperclass();
if (superClass != null && superClass != Object.class) {
registerTestClassForReflection(superClass);

Choose a reason for hiding this comment

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

Suggested change
registerTestClassForReflection(superClass);
registerTestClassForReflection(superClass, registeredClasses);

Expand All @@ -163,16 +218,6 @@ private void forEachProvider(Consumer<PluginConfigProvider> consumer) {
}
}

public static void debug(String format, Object... args) {
if (debug()) {
System.out.printf("[Debug] " + format + "%n", args);
}
}

public static boolean debug() {
return ImageSingletons.lookup(JUnitPlatformFeature.class).debug;
}

private Stream<String> readAllFiles(Path dir, String prefix) throws IOException {
return findFiles(dir, prefix).map(outputFile -> {
try {
Expand All @@ -192,4 +237,38 @@ private static Stream<Path> findFiles(Path dir, String prefix) throws IOExceptio
&& path.getFileName().toString().startsWith(prefix)));
}

private static void registerClassesForHamcrestSupport(BeforeAnalysisAccess access) {
Copy link
Collaborator Author

@dnestoro dnestoro Mar 5, 2025

Choose a reason for hiding this comment

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

Implementation originally taken from: #576

Thanks @tzezula

ClassLoader applicationLoader = access.getApplicationClassLoader();
Class<?> typeSafeMatcher = findClassOrNull(applicationLoader, "org.hamcrest.TypeSafeMatcher");
Class<?> typeSafeDiagnosingMatcher = findClassOrNull(applicationLoader, "org.hamcrest.TypeSafeDiagnosingMatcher");
if (typeSafeMatcher != null || typeSafeDiagnosingMatcher != null) {
BiConsumer<DuringAnalysisAccess, Class<?>> registerMatcherForReflection = (a, c) -> RuntimeReflection.register(c.getDeclaredMethods());
if (typeSafeMatcher != null) {
access.registerSubtypeReachabilityHandler(registerMatcherForReflection, typeSafeMatcher);
}
if (typeSafeDiagnosingMatcher != null) {
access.registerSubtypeReachabilityHandler(registerMatcherForReflection, typeSafeDiagnosingMatcher);
}
}
}

private static Class<?> findClassOrNull(ClassLoader loader, String className) {
try {
return loader.loadClass(className);
} catch (ClassNotFoundException e) {
return null;
}
}

private static void initializeClassesForJDK21OrEarlier() {
try (InputStream is = JUnitPlatformFeature.class.getResourceAsStream("/initialize-at-buildtime")) {
if (is != null) {
try (BufferedReader br = new BufferedReader(new InputStreamReader(is))) {
br.lines().forEach(RuntimeClassInitialization::initializeAtBuildTime);
}
}
} catch (IOException e) {
throw new RuntimeException("Failed to process build time initializations for JDK 21 or earlier");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
package org.graalvm.junit.platform;

import org.graalvm.junit.platform.config.core.NativeImageConfiguration;
import org.graalvm.nativeimage.hosted.RuntimeClassInitialization;
import org.graalvm.nativeimage.hosted.RuntimeReflection;

import java.lang.reflect.Executable;
Expand All @@ -64,10 +63,4 @@ public void registerForReflection(Executable... methods) {
public void registerForReflection(Field... fields) {
RuntimeReflection.register(fields);
}


@Override
public void initializeAtBuildTime(Class<?>... classes) {
RuntimeClassInitialization.initializeAtBuildTime(classes);
}
}
Loading
Loading