junit5 icon indicating copy to clipboard operation
junit5 copied to clipboard

Make the standalone jar be usable with "import module"

Open kitlei-robert opened this issue 4 months ago • 11 comments

JUnit 5 is properly fitted with modules but junit-platform-console-standalone-1.13.4.jar does not include a module-info.class. This is probably due to Java's limitation of not allowing more than one such file in a .jar file, however, it would be reasonable and feasible to have just one: that of the API.

All of the following code assumes Java 25 (or at least Java 23+).

Ideal

The ideal, reasonable-looking minimal tester file for me would look like this.

import module org.junit.jupiter.api;

@Test
void test() {
    assertEquals(4, 2+2);
}

It would be compiled as javac -p junit-platform-console-standalone-VSN.jar MinimalTest.java and then run as java -jar junit5_modules.jar execute -cp . -c MinimalTest.

What works in a mock implementation

Due to limitations, the file needs some boilerplate which makes it more annoying for beginners to use.

import module org.junit.jupiter.api;
// bloat: modules provide imports, just not static ones
import static org.junit.jupiter.api.Assertions.*;

@Test
void test() {
    assertEquals(4, 2+2);
}

// bloat: compact source files must have this
//        or it could be a regular source file with a different sort of bloat
void main() {}

Compilation also requires an extra option: javac --add-modules org.junit.jupiter.api -p junit-platform-console-standalone-VSN.jar MinimalTest.java

The module-info.class file also has to be inserted into junit-platform-console-standalone-VSN.jar (considered as a zip).

Deliverables

  • The junit-platform-console-standalone-***.jar becomes usable as a org.junit.jupiter.api module.

kitlei-robert avatar Aug 03 '25 15:08 kitlei-robert

Slight improvement on the idea: it's even better if the org.junit.jupiter module is used as it also requires org.junit.jupiter.params.

The implementation might require some inspection: as there cannot be other modules inside the jar, the transitively exported contents from the other modules might have to be identified, either in an automated way (which is more error-proof) or manually.

kitlei-robert avatar Aug 03 '25 16:08 kitlei-robert

Related to:

  • #4079

sormuras avatar Aug 04 '25 07:08 sormuras

The standalone variant was never meant to be usable on the module path without friction - as it merges a bunch of JUnit-related API and internals(!) into a single JAR file. Just as JUnit 3 and 4 were shipped and lead to hard-to-manage situations.

Since 5.13.0, and still with the upcoming 6 release, the standalone artifact does provide a stable module name in its manifest, namely org.junit.platform.console.standalone. This helps at least in some situations where the standalone JAR lands on the module path.

Trying to use import module with the unmodified artifact on the module path, using it as an automatic module exporting all its packages, yields:

javac --module-path junit-platform-console-standalone-1.13.0.jar --add-modules ALL-MODULE-PATH Test.java

Test.java:3: error: reference to Test is ambiguous
@Test void test() {}
 ^
  both interface junit.framework.Test in junit.framework and class org.junit.Test in org.junit match
Test.java:3: error: compact source file does not have main method in the form of void main() or void main(String[] args)
@Test void test() {}
^
2 errors

An explicit module descriptor though, as you suggested, might help. 🤔

sormuras avatar Aug 04 '25 07:08 sormuras

I took your compact source file and moved most of the command-line into a Test.java file:

import org.junit.jupiter.api.*;

void main() {
  var junit = ToolProvider.findFirst("junit").orElseThrow();
  var error = junit.run(System.out, System.err, "execute",
    "--select-class", getClass().getName(), // "Test"
    "--select-class", A.class.getName(),    // "Test$A"
    "--select-class", B.class.getName()     // "Test$B"
  );
  System.exit(error);
}

@Test
void t() {}

@RepeatedTest(3)
void r() {}

static class A {
  @Test void a() {}
}

@Nested class B {
  @Test void b() {}
}

Together with a standalone JUnit Platform artifact in the current working directory, one just calls java --class-path * Test.java to get:

Thanks for using JUnit! Support its development at https://junit.org/sponsoring

╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│  ├─ Test ✔
│  │  ├─ B ✔
│  │  │  └─ b() ✔
│  │  ├─ r() ✔
│  │  │  ├─ repetition 1 of 3 ✔
│  │  │  ├─ repetition 2 of 3 ✔
│  │  │  └─ repetition 3 of 3 ✔
│  │  └─ t() ✔
│  └─ Test$A ✔
│     └─ a() ✔
└─ JUnit Vintage ✔

Test run finished after 75 ms
[         7 containers found      ]
[         0 containers skipped    ]
[         7 containers started    ]
[         0 containers aborted    ]
[         7 containers successful ]
[         0 containers failed     ]
[         6 tests found           ]
[         0 tests skipped         ]
[         6 tests started         ]
[         0 tests aborted         ]
[         6 tests successful      ]
[         0 tests failed          ]

Yes, on the good ol' class path. Yes, no import module - but also yes, using the java launcher in source mode. 🤓

sormuras avatar Aug 04 '25 08:08 sormuras

Back to "import module ...": if you replace the standalone JAR with the equivalent set of explicit modules, the import module org.junit.jupiter; statement does work as expected. With Test.java reading:

import module org.junit.jupiter;

@Test
void test() {}

void main() {
  var junit = ToolProvider.findFirst("junit").orElseThrow();
  var error = junit.run(System.out, System.err, "execute", "--select-class", getClass().getName());
  System.exit(error);
}

and all modular JAR files required for Jupiter in a lib directory, running:

java --module-path lib --add-modules ALL-MODULE-PATH Test.java

yields:

╷
└─ JUnit Jupiter ✔
   └─ Test ✔
      └─ test()

How to get all "Jupiter modules" into a lib directoy without a build tool? One option is to use jresolve: https://github.com/bowbahdoe/jresolve-cli

java -jar jresolve.jar \
  --output-directory=lib \
  --purge-output-directory \
  --use-module-names \
  pkg:maven/org.junit.jupiter/[email protected]
  pkg:maven/org.junit.platform/[email protected]

sormuras avatar Aug 04 '25 08:08 sormuras

(About your last but one comment) Looks very nice and straightforward; I might tweak your main, turning it into a helper function which will allow the user to just go void main() { testThisClass(); } or something close enough.

The underlying reason for this issue is that I intend to make use of the new language features to teach beginners about basic testing while balancing keeping everything as low-level as possible with as simple as possible. Thus, no frameworks.

One possible simplification is if I can tell the students to remember simply writing a single import module directive vs telling them, "you'll need to use the following:"

import org.junit.jupiter.api.*;
import org.junit.jupiter.api.condition.*;
import org.junit.jupiter.api.extension.*;
import org.junit.jupiter.params.*;
import org.junit.jupiter.params.provider.*;

Your most recent comment is indeed a way, only it seems too finicky for beginners.

kitlei-robert avatar Aug 04 '25 08:08 kitlei-robert

(About your last but one comment) Looks very nice and straightforward; I might tweak your main, turning it into a helper function which will allow the user to just go void main() { testThisClass(); } or something close enough.

With the multi-file source-code launcher you may extract such a feature into a sibling source file, for example JUnitSupport.java:

class JUnitSupport {
  static void run(Class<?> test) {
    var junit = java.util.spi.ToolProvider.findFirst("junit").orElseThrow();
    var error = junit.run(System.out, System.err, "execute", "--select-class", test.getName());
    System.exit(error);
  }
}

And then just: void main() { JUnitSupport.run(getClass()); }

Another approach would be a convenient execution routine in the Launcher API of the Platform itself. 🤔 @junit-team?

The underlying reason for this issue is that I intend to make use of the new language features to teach beginners about basic testing while balancing keeping everything as low-level as possible with as simple as possible. Thus, no frameworks.

Thus, no JUnit framework. Use asserts, Luke! (-;

Your most recent comment is indeed a way, only it seems too finicky for beginners.

Yeah. We're working on it. The Java On-Ramp is not done yet.

One pain-point is the --add-modules ALL-MODULE-PATH sourcery ... which could be automatically added in source launcher mode:

  • https://bugs.openjdk.org/browse/JDK-8334761
  • https://github.com/openjdk/jdk/pull/19842/files

sormuras avatar Aug 04 '25 09:08 sormuras

Yes, that's my idea as well, but instead of using getClass() which would require an unsuitably deep explanation, I (or your team if you wish to implement it, it's a warmup exercise) can just use the stack trace to fish out the class info without the caller ever knowing. The devil's in the details, as always: my version would be a very restrictive, trivial one-shot, but if you add a thing like this, you're stuck with it for a long time (in a galaxy very, very near)...

JUnit as a standalone, for this purpose, functions as a library on my end, plus a main that requires a slightly fancy invocation. I hide al(most al)l nasty details from the end users. 😀🪄

kitlei-robert avatar Aug 04 '25 10:08 kitlei-robert

You could even adopt a basic, recommended structure for the upcoming JUnit 6 (provided that the user has Java 25+ or less conveniently, Java 23/24 with preview features on) which would look like this:

import module org.junit.jupiter;
import static org.junit.jupiter.api.Assertions.*;

void main() { runAllTests(); }

// tests...

If only that pesky static import could go away somehow...

kitlei-robert avatar Aug 04 '25 10:08 kitlei-robert

Yes, that's my idea as well, but instead of using getClass() which would require an unsuitably deep explanation, I (or your team if you wish to implement it, it's a warmup exercise) can just use the stack trace to fish out the class info without the caller ever knowing. The devil's in the details, as always: my version would be a very restrictive, trivial one-shot, but if you add a thing like this, you're stuck with it for a long time (in a galaxy very, very near)...

Yeah, sounds not too stable. I'd add an overload to JUnitSupport like this:

  static void run(Object that) {
    run(that instanceof Class test ? test : that.getClass());
  }

Which, in case of an instance main method (in a compact source file), enables this neat call:

void main() { JUnitSupport.run(this); }

If only that pesky static import could go away somehow...

In former times, we used to extend a (n abstract) test class to inherit some methods from. We're not going back there, though. See this note attached to Assertions API documentation:

Extensibility Although it is technically possible to extend this class, extension is strongly discouraged. The JUnit Team highly recommends that the methods defined in this class be used via static imports.

sormuras avatar Aug 04 '25 11:08 sormuras

One pain-point is the --add-modules ALL-MODULE-PATH sourcery ... which could be automatically added in source launcher mode:

https://bugs.openjdk.org/browse/JDK-8334761 https://github.com/openjdk/jdk/pull/19842/files

Hey, you quoted me!

We'll explore the full-modular variant in

  • #4982

After that, I'll also take a look at how to make the standalone JAR workable in compact source files, again. Might not work out as smooth as with true modules.

import static org.junit.jupiter.api.Assertions.*; If only that pesky static import could go away somehow...

  • IO.println(...)
  • JUnit.run(...)
  • Assertions.assert...(...)

I see a pattern here. Let's stick to it. Leaves room to introduce another library here:

  • Assert.that(...)...;

sormuras avatar Sep 22 '25 14:09 sormuras