junit5
junit5 copied to clipboard
Tests not found via classpath scanning within nested JAR in Spring Boot JAR
We are providing a test suite for our applications as Spring Boot application. The test suite is basically a set of regular JUnit 5 tests that are launched in the Spring Boot application.
The application is packaged as Spring Boot JAR (created with the spring-boot-maven-plugin
).
We do now have the problem, that the tests are not found when we are using DiscoverySelectors.selectPackage()
and run the application as Spring Boot JAR. Everything works fine, when we select the tests by classname. Executing the application within an IDE works fine as well.
The problem seems to be in ClasspathScanner#findClassesForPath())
. Files.walkFileTree()
does not work with nested JAR files. A similar problem was already reported in #399.
Steps to reproduce
I created a project on GitHub to reproduce this issue. When the Application
is run within the IDE, everything works fine. When it is executed as Spring Boot JAR, the test is not found.
Context
- Used versions (Jupiter/Vintage/Platform): junit-bom 5.3.2
- Build Tool/IDE: Maven, OpenJDK11, IntelliJ
- Spring Boot: 2.1.1.RELEASE
Thanks for raising the issue.
I've tentatively slated this for 5.4 M2 for the purpose of team discussion.
Today I stumbled over the same problems. Thanks for creating a demo project @ferstl!
I'll a) push an integration test showing the issue is even applicable when test classes are jarred (not nested) and b) tackle the underlying missing feature/enhancement after 5.5 GA is released.
Team Decision: Move to General Backlog to see whether this use case is relevant for other users as well.
Yes, this problem is relevant for other users. Please fix it.
If this issue is ever addressed, I wonder if it would be worth including ClassGraph, shadowing it, and using it instead of ClasspathScanner
. 🤔
Interesting idea, @jbduncan!
I didn't realize that ClassGraph had explicit support for Spring Boot's proprietary JAR structure until now.
Jarfiles within jarfiles (to unlimited nesting depth), e.g.
project.jar!/BOOT-INF/lib/dependency.jar
, as required by Spring-Boot, JBoss, and Felix classloaders, and probably others.
I've spiked implementing ClasspathScanner
using ClassGraph and it passes all the tests:
https://github.com/junit-team/junit5/commit/9000a80327d92820e095e5b579c58d97efbd8de0
The build is currently broken due to some error in our module compilation setup: https://scans.gradle.com/s/z2kzsnwxcrt7y/console-log?task=:junit-jupiter:compileModule
@sormuras Let's take a look at that when you're back.
Why so late? :nerd_face:
Looks like the ClassGraph JAR is not available when compiling the modules. Neither on the module-path, nor on the class-path. Will look into it ... soon.
Hello I have faced with this issue triying to run my tests from spring boot jar. I use gradle bootJar task and place my tests to BOOT-INF/classes as spring using this
from(sourceSets.test.output) {
into 'BOOT-INF/classes'
}
I can see that the problem is in CloseablePath.create(baseUri) method where you split the path by "!" symbol. It returns "BOOT-INF/classes" instead of "BOOT-INF/classes/com.target.package.name"
The reason is that full path in spring boot jar contains two "!" signs like this "jar:file:/C:/Users/IdeaProjects/build/libs/application.jar!/BOOT-INF/classes!/com/target/package/name"
I decided to use -c and pass full class name and it works. But won't be handy if I have a lot of tests
@epam-valerii is correct, the baseDir is incorrect so the subPackage variable in determineSubpackageName
is returning the full package instead of '' like it supposed to. @marcphilipp any update on this issue? It still has not been fixed in 5.5.2.
The team decision from above to wait for additional interest still stands.
I've found a workaround.
Gradle Shadow plugin could be used instead of bootJar
task for not to produce nested Jars.
https://github.com/spring-projects/spring-boot/issues/1828#issuecomment-231104288
With configuration below Spring Boot features work alongside with JUnit Console Launcher --select-package
option
import com.github.jengelman.gradle.plugins.shadow.transformers.*
shadowJar {
zip64 true
manifest {
attributes 'Implementation-Title': 'Testing Jar File',
'Main-Class': 'org.junit.platform.console.ConsoleLauncher'
}
from sourceSets.test.output
configurations = [project.configurations.testRuntimeClasspath]
exclude '**/Log4j2Plugins.dat'
// Required for Spring
mergeServiceFiles()
append 'META-INF/spring.handlers'
append 'META-INF/spring.schemas'
append 'META-INF/spring.tooling'
transform(PropertiesFileTransformer) {
paths = ['META-INF/spring.factories' ]
mergeStrategy = "append"
}
}
Same issue here, currently declaring every class via LauncherDiscoveryRequestBuilder
Hit this issue for integration tests run using JUnit 5 that are packaged up for a CI/CD pipeline as a Spring Boot application.
Just chiming in here. We ran into this issue as well. Our motivation for this is to run E2E-tests within a cluster by deploying a Spring Boot Application executing the tests using the JUnit5 platform launcher.
Same issue here. Any update on this issue?
No, but someone could pick up where I left off above: https://github.com/junit-team/junit5/issues/1724#issuecomment-518027950 🙂
hey there, also stumbled upon it. same issue: trying to package dependencies via the spring boot fat jar packager to run integration tests in a kubernetes cluster.
I know, the problem does not exist when using other build plugins (e.g. maven shade plugin or assembly plugin). However, these plugins add quite a bit to the build time. The spring plugin is literally over 20x faster when assembling a fat jar. (that's because it does not extract and add all files to the target archive but just adds the archives itself)
Looks like the following could be a workaround:
- Add a marker-annotation with runtime retention to all test-classes
- Use ClassPathScanningCandidateComponentProvider with an AnnotationTypeFilter
- Use DiscoverySelectors.selectClass(Class.forName(candidate.getBeanClassName()))
That way at least in our environment it seems to pick up all annotated test-classes.
E.g.:
private static List<ClassSelector> selectorsFor(Class<? extends Annotation> annotationType) {
ClassPathScanningCandidateComponentProvider scanner = new ClassPathScanningCandidateComponentProvider(false);
scanner.addIncludeFilter(new AnnotationTypeFilter(annotationType));
return scanner.findCandidateComponents("ch/corporateapi/corapi/testing/systemtest").stream()
.map(c -> c.getBeanClassName())
.filter(Objects::nonNull)
.map(className -> DiscoverySelectors.selectClass(classFor(className)))
.toList();
}
@NotNull
private static Class<?> classFor(String className) {
try {
return Class.forName(className);
} catch (ClassNotFoundException e) {
throw new RuntimeException(e);
}
}
after that, we can use LauncherDiscoveryRequestBuilder.request().selectors(selectorsFor(MyTest.class));
Yes. Please fix. I spent 2 days finding a solution to this problem of shadow jar not working with Spring Boot. BootJar not bringing in test classes, not getting any shadowjar to run the platform-console.
I can confirm @valeratimofeev solution works. That will create a fat jar (make sure to run the compileTestJava first) that when executing -jar junit-platform-console-standalone-1.9.2.jar -cp yourshadow.jar -c your.test.Class it will execute.
What a pain.
Please note the Spring Boot 3.2.0 release notes:
Nested Jar Support The underlying code that supports Spring Boot’s "Uber Jar" loading has been rewritten now that we no longer need to support Java 8. The updated code makes use of a new URL format which is more compliant with JDK expectations. The previous URL format of jar:file:/dir/myjar.jar:BOOT-INF/lib/nested.jar!/com/example/MyClass.class has been replaced with jar:nested:/dir/myjar.jar/!BOOT-INF/lib/nested.jar!/com/example/MyClass.class. The updated code also makes use of java.lang.ref.Cleaner (which was part of JDK 9) for resource management.
Ignoring the typos in the example uri, this means that java's FileSystem
API can now be used to read Spring Boots jar:nested
URIs. There does appear to be a minor problem in that JUnit 5 does not parse URI's correctly.
2023-12-17T09:29:36.063+01:00 WARN 8361 --- [ main] o.j.p.commons.util.ClasspathScanner : Error scanning files for URI jar:nested:/home/mpkorstanje/Projects/mpkorstanje/junit-5-spring-boot-3.2.0/build/libs/junit-5-spring-boot-3.2.0-0.0.1-SNAPSHOT.jar/!BOOT-INF/classes/!/com/example
java.lang.IllegalArgumentException: 'path' must contain '/!'
at org.springframework.boot.loader.net.protocol.nested.NestedLocation.parse(NestedLocation.java:98) ~[junit-5-spring-boot-3.2.0-0.0.1-SNAPSHOT.jar:0.0.1-SNAPSHOT]
at org.springframework.boot.loader.net.protocol.nested.NestedLocation.fromUri(NestedLocation.java:89) ~[junit-5-spring-boot-3.2.0-0.0.1-SNAPSHOT.jar:0.0.1-SNAPSHOT]
at org.springframework.boot.loader.nio.file.NestedFileSystemProvider.getPath(NestedFileSystemProvider.java:88) ~[junit-5-spring-boot-3.2.0-0.0.1-SNAPSHOT.jar:0.0.1-SNAPSHOT]
at java.base/java.nio.file.Path.of(Path.java:208) ~[na:na]
at java.base/java.nio.file.Paths.get(Paths.java:98) ~[na:na]
at jdk.zipfs/jdk.nio.zipfs.ZipFileSystemProvider.uriToPath(ZipFileSystemProvider.java:76) ~[jdk.zipfs:na]
at jdk.zipfs/jdk.nio.zipfs.ZipFileSystemProvider.newFileSystem(ZipFileSystemProvider.java:98) ~[jdk.zipfs:na]
at java.base/java.nio.file.FileSystems.newFileSystem(FileSystems.java:339) ~[na:na]
at java.base/java.nio.file.FileSystems.newFileSystem(FileSystems.java:288) ~[na:na]
at org.junit.platform.commons.util.CloseablePath$ManagedFileSystem.<init>(CloseablePath.java:92) ~[junit-platform-commons-1.10.1.jar!/:1.10.1]
at org.junit.platform.commons.util.CloseablePath.lambda$createForJarFileSystem$3(CloseablePath.java:63) ~[junit-platform-commons-1.10.1.jar!/:1.10.1]
Which happens because JUnit 5 assumes the jar uri is flat:
https://github.com/junit-team/junit5/blob/8f5fbd0d1693be19de86189cc6fb48394269d2d9/junit-platform-commons/src/main/java/org/junit/platform/commons/util/CloseablePath.java#L54-L61
While it in fact it is recursive jar:<url>!/[<entry>]
and so the last, not the first !/
should be used.
private static final String JAR_URI_SEPARATOR = "!/";
String uriString = uri.toString();
int lastJarUriSeparator = uriString.lastIndexOf(JAR_URI_SEPARATOR);
String jarUri = uriString.substring(0, lastJarUriSeparator);
String jarEntry = uriString.substring(lastJarUriSeparator + 1);
Worth noting that this will only work when scanning inside BOOT-INF/classes
as https://github.com/spring-projects/spring-boot/issues/38595 will prevent scanning BOOT-INF/lib
for now.
With a relatively small change scanning the BOOT-INF/classes
would also be possible.
if (JAR_URI_SCHEME.equals(uri.getScheme())) {
String uriString = uri.toString();
if (uriString.startsWith("jar:file:") && uriString.contains("!/BOOT-INF/classes")) {
// Parsing jar:file:<file>!/BOOT-INF/classes!/[<entry>]
String[] parts = uri.toString().split("!");
String jarUri = parts[0];
String jarEntry = parts[1];
String subEntry = parts[2];
return createForJarFileSystem(new URI(jarUri), fileSystem -> fileSystem.getPath(jarEntry + subEntry), fileSystemProvider);
}
// Parsing: jar:<url>!/[<entry>]
int lastJarUriSeparator = uriString.lastIndexOf(JAR_URI_SEPARATOR);
String jarUri = uriString.substring(0, lastJarUriSeparator);
String jarEntry = uriString.substring(lastJarUriSeparator + 1);
return createForJarFileSystem(new URI(jarUri), fileSystem -> fileSystem.getPath(jarEntry), fileSystemProvider);
}
```