junit5 icon indicating copy to clipboard operation
junit5 copied to clipboard

Tests not found via classpath scanning within nested JAR in Spring Boot JAR

Open ferstl opened this issue 5 years ago • 22 comments

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

ferstl avatar Jan 10 '19 14:01 ferstl

Thanks for raising the issue.

I've tentatively slated this for 5.4 M2 for the purpose of team discussion.

sbrannen avatar Jan 10 '19 14:01 sbrannen

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.

sormuras avatar Jun 30 '19 16:06 sormuras

Team Decision: Move to General Backlog to see whether this use case is relevant for other users as well.

marcphilipp avatar Jul 12 '19 10:07 marcphilipp

Yes, this problem is relevant for other users. Please fix it.

Oleg3n avatar Jul 24 '19 20:07 Oleg3n

If this issue is ever addressed, I wonder if it would be worth including ClassGraph, shadowing it, and using it instead of ClasspathScanner. 🤔

jbduncan avatar Jul 25 '19 07:07 jbduncan

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.

sbrannen avatar Jul 25 '19 09:07 sbrannen

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.

marcphilipp avatar Aug 04 '19 19:08 marcphilipp

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.

sormuras avatar Aug 04 '19 19:08 sormuras

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

ValTimof avatar Oct 03 '19 13:10 ValTimof

@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.

Jmyeluri avatar Mar 12 '20 22:03 Jmyeluri

The team decision from above to wait for additional interest still stands.

marcphilipp avatar Mar 27 '20 10:03 marcphilipp

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"
    }
}

ValTimof avatar May 10 '20 07:05 ValTimof

Same issue here, currently declaring every class via LauncherDiscoveryRequestBuilder

rossdanderson avatar Jul 16 '20 09:07 rossdanderson

Hit this issue for integration tests run using JUnit 5 that are packaged up for a CI/CD pipeline as a Spring Boot application.

adrian-skybaker avatar Aug 23 '20 08:08 adrian-skybaker

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.

jimonthebarn avatar Nov 11 '20 14:11 jimonthebarn

Same issue here. Any update on this issue?

dkroehan avatar Nov 13 '20 07:11 dkroehan

No, but someone could pick up where I left off above: https://github.com/junit-team/junit5/issues/1724#issuecomment-518027950 🙂

marcphilipp avatar Nov 13 '20 12:11 marcphilipp

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)

newcron avatar Jun 15 '22 20:06 newcron

Looks like the following could be a workaround:

  1. Add a marker-annotation with runtime retention to all test-classes
  2. Use ClassPathScanningCandidateComponentProvider with an AnnotationTypeFilter
  3. 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));

elekktrisch avatar Jan 24 '23 14:01 elekktrisch

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.

gte811i avatar Feb 02 '23 00:02 gte811i

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.

mpkorstanje avatar Dec 17 '23 08:12 mpkorstanje

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);
}
```		

mpkorstanje avatar Dec 17 '23 21:12 mpkorstanje