I am not making a secret out of it, I am a fan of the Java Module system and I think it can provide benefit for library developers the same way it brings for the maintainers and developers of the JDK themselves.
If you are interested in a great overview, have a look at this comprehensive post about Java modules by Jakob Jenkov. No fuss, just straight to the matter. Also, read what Christian publishes: sormuras.github.io. It is no coincidence that my post in 2021 has the same name as his in 2018.
At the beginning of this months, I wrote about a small tool I wrote for myself, scrobbles4j. I want the client to be able to run on the module path and the module path alone. Why am I doing this? Because I am convinced that modularization of libraries will play a bigger role in Javas future and I am responsible for Spring Data Neo4j (not yet modularized), the Cypher-DSL (published as a Multi-Release-Jar, with module support on JDK11+ and the module path) and I advise a couple of things on the Neo4j Java driver and I just want to know upfront what I have to deal with.
The Java module system starts to be a bit painful when you have to deal with open- and closed-box testing.
Goal: Create a tool that runs on the module path, is unit-testable without hassle in any IDE (i.e. does not need additional plugins, config or conventions) and can be integration tested. The tool in my case (the Scrobbles4j application linked above) is a runnable command line tool depending on various service implementations defined by modules. A Java module does not need to export or open a package to be executable, which will be important to notice later on!
Christian starts his post above with the “suggestion” to add the (unit) test classes just next to the classes under test… Like it was ages ago. Christians blog post ist from 2018, but honestly, that reassembles my feeling all to well when I kicked this off: It’s seems to be the easiest solution and I wonder if this is how the JDK team works.
I prefer not todo this as I am happy with the convention of src/main
and src/test
.
As I write this, most things work out pretty well with Maven (3.8 and Surefire 3.0.0.M5) and the need for extra config vanished.
Have a look at this repository: michael-simons/modulartesting. The project’s pom.xml
has everything needed to successfully compile and test Java 17 code (read: The minimum required plugin versions necessary to teach Maven about JDK 17). The project has the following structure:
.
├── app
│ ├── pom.xml
│ └── src
│ ├── main
│ │ └── java
│ │ ├── app
│ │ │ └── Main.java
│ │ └── module-info.java
│ └── test
│ └── java
│ └── app
│ └── MainTest.java
├── greeter
│ ├── pom.xml
│ └── src
│ ├── main
│ │ └── java
│ │ ├── greeter
│ │ │ └── Greeter.java
│ │ └── module-info.java
│ └── test
│ └── java
├── greeter-it
│ ├── pom.xml
│ └── src
│ └── test
│ └── java
│ ├── greeter
│ │ └── it
│ │ └── GreeterIT.java
│ └── module-info.java
└── pom.xml |
.
├── app
│ ├── pom.xml
│ └── src
│ ├── main
│ │ └── java
│ │ ├── app
│ │ │ └── Main.java
│ │ └── module-info.java
│ └── test
│ └── java
│ └── app
│ └── MainTest.java
├── greeter
│ ├── pom.xml
│ └── src
│ ├── main
│ │ └── java
│ │ ├── greeter
│ │ │ └── Greeter.java
│ │ └── module-info.java
│ └── test
│ └── java
├── greeter-it
│ ├── pom.xml
│ └── src
│ └── test
│ └── java
│ ├── greeter
│ │ └── it
│ │ └── GreeterIT.java
│ └── module-info.java
└── pom.xml
That example here consists of a greeter
module that creates a greeting and an app
module using that greeter. The greeter requires a non-null and not blank argument. The app module has some tooling to assert its arguments. I already have prepared a closed test for the greeter module.
The whole setup is compilable and runnable like this (without using any tooling apart JDK provided means). First, compile the greeter
and app
modules. The --module-source-path
can be specified multiple times, the --module
argument takes a list of modules:
javac -d out --module-source-path greeter=greeter/src/main/java --module-source-path app=app/src/main/java --module greeter,app |
javac -d out --module-source-path greeter=greeter/src/main/java --module-source-path app=app/src/main/java --module greeter,app
It’s runnable on the module path like this
java --module-path out --module app/app.Main world
> Hello world. |
java --module-path out --module app/app.Main world
> Hello world.
As said before, the app
module doesn’t export or opens anything. cat app/src/main/java/module-info.java
gives you:
module app {
requires greeter;
} |
module app {
requires greeter;
}
Open testing
Why is this important? Because we want to unit-test or open-test this module (or use in-module testing vs extra-module testing).
In-module testing will allow us to test package private API as before, extra-module testing will use the modular API as-is and as other modules will do, hence: It will map to integration tests).
The main class is dead stupid:
package app;
import greeter.Greeter;
public class Main {
public static void main(String... var0) {
if (!hasArgument(var0)) {
throw new IllegalArgumentException("Missing name argument.");
}
System.out.println((new Greeter()).hello(var0[0]));
}
static boolean hasArgument(String... args) {
return args.length > 0 && !isNullOrBlank(args[0]);
}
static boolean isNullOrBlank(String value) {
return value == null || value.isBlank();
}
} |
package app;
import greeter.Greeter;
public class Main {
public static void main(String... var0) {
if (!hasArgument(var0)) {
throw new IllegalArgumentException("Missing name argument.");
}
System.out.println((new Greeter()).hello(var0[0]));
}
static boolean hasArgument(String... args) {
return args.length > 0 && !isNullOrBlank(args[0]);
}
static boolean isNullOrBlank(String value) {
return value == null || value.isBlank();
}
}
It has some utility methods I want to make sure they work as intended and subject them to a unit test. I test package-private methods here, so this is an open test and a test best on JUnit 5 might look like this:
package app;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class MainTest {
@Test
void isNullOrBlankShouldDetectNullString() {
assertTrue(Main.isNullOrBlank(null));
}
@ParameterizedTest
@ValueSource(strings = { "", " ", " \t " })
void isNullOrBlankShouldDetectBlankStrings(String value) {
assertTrue(Main.isNullOrBlank(value));
}
@ParameterizedTest
@ValueSource(strings = { "bar", " foo \t " })
void isNullOrBlankShouldWorkWithNonBlankStrings(String value) {
assertFalse(Main.isNullOrBlank(value));
}
} |
package app;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
class MainTest {
@Test
void isNullOrBlankShouldDetectNullString() {
assertTrue(Main.isNullOrBlank(null));
}
@ParameterizedTest
@ValueSource(strings = { "", " ", " \t " })
void isNullOrBlankShouldDetectBlankStrings(String value) {
assertTrue(Main.isNullOrBlank(value));
}
@ParameterizedTest
@ValueSource(strings = { "bar", " foo \t " })
void isNullOrBlankShouldWorkWithNonBlankStrings(String value) {
assertFalse(Main.isNullOrBlank(value));
}
}
It lives in the same package (app
) and in the same module but under a different source path (app/src/test
). When I hit the run button in my IDE (here IDEA), it just works:
But what happens if I just run ./mvnw clean verify
? Things fail:
[INFO] --- maven-surefire-plugin:3.0.0-M5:test (default-test) @ app ---
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running app.MainTest
[ERROR] Tests run: 6, Failures: 0, Errors: 6, Skipped: 0, Time elapsed: 0.05 s <<< FAILURE! - in app.MainTest
[ERROR] app.MainTest.isNullOrBlankShouldDetectNullString Time elapsed: 0.003 s <<< ERROR!
java.lang.reflect.InaccessibleObjectException: Unable to make app.MainTest() accessible: module app does not "opens app" to unnamed module @7880cdf3
[ERROR] app.MainTest.isNullOrBlankShouldWorkWithNonBlankStrings(String)[1] Time elapsed: 0 s <<< ERROR!
java.lang.reflect.InaccessibleObjectException: Unable to make app.MainTest() accessible: module app does not "opens app" to unnamed module @7880cdf3
[ERROR] app.MainTest.isNullOrBlankShouldWorkWithNonBlankStrings(String)[2] Time elapsed: 0 s <<< ERROR!
java.lang.reflect.InaccessibleObjectException: Unable to make app.MainTest() accessible: module app does not "opens app" to unnamed module @7880cdf3
[ERROR] app.MainTest.isNullOrBlankShouldDetectBlankStrings(String)[1] Time elapsed: 0.001 s <<< ERROR!
java.lang.reflect.InaccessibleObjectException: Unable to make app.MainTest() accessible: module app does not "opens app" to unnamed module @7880cdf3
[ERROR] app.MainTest.isNullOrBlankShouldDetectBlankStrings(String)[2] Time elapsed: 0.001 s <<< ERROR!
java.lang.reflect.InaccessibleObjectException: Unable to make app.MainTest() accessible: module app does not "opens app" to unnamed module @7880cdf3
[ERROR] app.MainTest.isNullOrBlankShouldDetectBlankStrings(String)[3] Time elapsed: 0 s <<< ERROR!
java.lang.reflect.InaccessibleObjectException: Unable to make app.MainTest() accessible: module app does not "opens app" to unnamed module @7880cdf3
[INFO]
[INFO] Results:
[INFO]
[ERROR] Errors:
[ERROR] MainTest.isNullOrBlankShouldDetectBlankStrings(String)[1] » InaccessibleObject
[ERROR] MainTest.isNullOrBlankShouldDetectBlankStrings(String)[2] » InaccessibleObject
[ERROR] MainTest.isNullOrBlankShouldDetectBlankStrings(String)[3] » InaccessibleObject
[ERROR] MainTest.isNullOrBlankShouldDetectNullString » InaccessibleObject Unable to ma... |
[INFO] --- maven-surefire-plugin:3.0.0-M5:test (default-test) @ app ---
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running app.MainTest
[ERROR] Tests run: 6, Failures: 0, Errors: 6, Skipped: 0, Time elapsed: 0.05 s <<< FAILURE! - in app.MainTest
[ERROR] app.MainTest.isNullOrBlankShouldDetectNullString Time elapsed: 0.003 s <<< ERROR!
java.lang.reflect.InaccessibleObjectException: Unable to make app.MainTest() accessible: module app does not "opens app" to unnamed module @7880cdf3
[ERROR] app.MainTest.isNullOrBlankShouldWorkWithNonBlankStrings(String)[1] Time elapsed: 0 s <<< ERROR!
java.lang.reflect.InaccessibleObjectException: Unable to make app.MainTest() accessible: module app does not "opens app" to unnamed module @7880cdf3
[ERROR] app.MainTest.isNullOrBlankShouldWorkWithNonBlankStrings(String)[2] Time elapsed: 0 s <<< ERROR!
java.lang.reflect.InaccessibleObjectException: Unable to make app.MainTest() accessible: module app does not "opens app" to unnamed module @7880cdf3
[ERROR] app.MainTest.isNullOrBlankShouldDetectBlankStrings(String)[1] Time elapsed: 0.001 s <<< ERROR!
java.lang.reflect.InaccessibleObjectException: Unable to make app.MainTest() accessible: module app does not "opens app" to unnamed module @7880cdf3
[ERROR] app.MainTest.isNullOrBlankShouldDetectBlankStrings(String)[2] Time elapsed: 0.001 s <<< ERROR!
java.lang.reflect.InaccessibleObjectException: Unable to make app.MainTest() accessible: module app does not "opens app" to unnamed module @7880cdf3
[ERROR] app.MainTest.isNullOrBlankShouldDetectBlankStrings(String)[3] Time elapsed: 0 s <<< ERROR!
java.lang.reflect.InaccessibleObjectException: Unable to make app.MainTest() accessible: module app does not "opens app" to unnamed module @7880cdf3
[INFO]
[INFO] Results:
[INFO]
[ERROR] Errors:
[ERROR] MainTest.isNullOrBlankShouldDetectBlankStrings(String)[1] » InaccessibleObject
[ERROR] MainTest.isNullOrBlankShouldDetectBlankStrings(String)[2] » InaccessibleObject
[ERROR] MainTest.isNullOrBlankShouldDetectBlankStrings(String)[3] » InaccessibleObject
[ERROR] MainTest.isNullOrBlankShouldDetectNullString » InaccessibleObject Unable to ma...
To understand what’s happening here we have to look what command is run by the IDE. I have appreviated the command a bit and kept the important bits:
/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home/bin/java -ea \
--patch-module app=/Users/msimons/Projects/modulartesting/app/target/test-classes \
--add-reads app=ALL-UNNAMED \
--add-opens app/app=ALL-UNNAMED \
--add-modules app \
// Something something JUnit app.MainTest |
/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home/bin/java -ea \
--patch-module app=/Users/msimons/Projects/modulartesting/app/target/test-classes \
--add-reads app=ALL-UNNAMED \
--add-opens app/app=ALL-UNNAMED \
--add-modules app \
// Something something JUnit app.MainTest
Note: Why does it say app/app
? The first app is the name of the module, the second the name of the exported package (which are in this case here the same).
First: --patch-module
: “Patching modules” teaches us that one can patch sources and resources into a module. This is what’s happening here: The IDE adds my test classes into the app
module, so they are subject to the one and only allowed module descriptor in that module.
Then --add-reads
: This patches the module descriptor itself and basically makes it require
another module (here: simplified everything).
The most important bit to successfully test things: --add-opens
: It opens the app
module to the whole world (but especially, to JUnit). It is not that JUnit needs direct access to the classes under test, but to the test classes which are – due to --patch-module
part of the module.
Let’s compare what Maven/Surefire does with ./mvnw -X clean verify
:
[DEBUG] Path to args file: /Users/msimons/Projects/modulartesting/app/target/surefire/surefireargs17684515751207543064
[DEBUG] args file content:
--module-path
"/Users/msimons/Projects/modulartesting/app/target/classes:/Users/msimons/Projects/modulartesting/greeter/target/greeter-1.0-SNAPSHOT.jar"
--class-path
"damn long class path"
--patch-module
app="/Users/msimons/Projects/modulartesting/app/target/test-classes"
--add-exports
app/app=ALL-UNNAMED
--add-modules
app
--add-reads
app=ALL-UNNAMED
org.apache.maven.surefire.booter.ForkedBooter |
[DEBUG] Path to args file: /Users/msimons/Projects/modulartesting/app/target/surefire/surefireargs17684515751207543064
[DEBUG] args file content:
--module-path
"/Users/msimons/Projects/modulartesting/app/target/classes:/Users/msimons/Projects/modulartesting/greeter/target/greeter-1.0-SNAPSHOT.jar"
--class-path
"damn long class path"
--patch-module
app="/Users/msimons/Projects/modulartesting/app/target/test-classes"
--add-exports
app/app=ALL-UNNAMED
--add-modules
app
--add-reads
app=ALL-UNNAMED
org.apache.maven.surefire.booter.ForkedBooter
It doesn’t have the --add-opens
part! Remember when I wrote that the app-module has no opens or export declaration? If it would, the --add-opens
option would not have been necessary and my plain Maven execution would work. But adding it to my module is completely against what I want to achieve.
And as much as I appreciate Christian and his knowledge, I didn’t get any solution from his blog to above to work for me. What does work is just adding the necessary opens to surefire like this:
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration combine.self="append">
<argLine>--add-opens app/app=ALL-UNNAMED</argLine>
</configuration>
</plugin>
</plugins>
</build> |
<build>
<plugins>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<configuration combine.self="append">
<argLine>--add-opens app/app=ALL-UNNAMED</argLine>
</configuration>
</plugin>
</plugins>
</build>
There is actually an open ticket in the Maven tracker about this exact topic: SUREFIRE-1909 – Support JUnit 5 reflection access by changing add-exports to add-opens (Thanks Oliver for finding it!). I would love to see this fixed in Surefire. I mean, the likelihood that someone using surefire wants also to use JUnit 5 accessing their module is pretty high.
You might rightfully ask why open the app-module to “all-unnamed” and not to org.junit.platform.commons
because the latter is what should access the test classes? The tests doesn’t run on the module path alone but on the classpath which does access modules which is perfectly valid as explained by Nicolai. We are having a dependency from the classpath on the module path here and we must make sure that the dependent is allowed to read the dependency.
Now to
Closed or integration testing
From my point of view closed testing in the modular world should be done with actually, separate modules. Oliver and I agreed that this thinking probably comes from a TCK based working approach, such as applied in the JDK itself or in the Jakarta EE world, but it’s not a bad approach, quite the contrary. What you get here is an ideal separation of really different types of tests, without fiddling with test names and everything.
An integration test for the greeter could look like this:
package greeter.it;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import greeter.Greeter;
import org.junit.jupiter.api.Test;
class GreeterIT {
@Test
void greeterShouldWork() {
assertEquals("Hello, modules.", new Greeter().hello("modules"));
}
@Test
void greeterShouldNotGreetNothing() {
assertThrows(NullPointerException.class, () -> new Greeter().hello(null));
}
} |
package greeter.it;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import greeter.Greeter;
import org.junit.jupiter.api.Test;
class GreeterIT {
@Test
void greeterShouldWork() {
assertEquals("Hello, modules.", new Greeter().hello("modules"));
}
@Test
void greeterShouldNotGreetNothing() {
assertThrows(NullPointerException.class, () -> new Greeter().hello(null));
}
}
As it lives in a different module, the package is different (greeter.it
) and so is the module name. Therefor, we can happily specify a module descriptor for the test itself, living in src/test
:
module greeter.it {
requires greeter;
requires org.junit.jupiter;
opens greeter.it to org.junit.platform.commons;
} |
module greeter.it {
requires greeter;
requires org.junit.jupiter;
opens greeter.it to org.junit.platform.commons;
}
The module descriptor makes it obvious: This test will run on the module path alone! I can clearly define what is required and what I need to open up for usage of reflection. Notice: I open up the integration test module (greeter.it
), not the greeter module itself!
Verdict
Testing in the modular world requires some rethinking. You need to learn about the various options of javac
and java
in regards of the module system.
I found The javac Command particular helpful. Pain points are usually to be found where class-path and module-path met. Sadly, this is often the case in simple unit or open tests. In a pure classpath world, they are easier to handle.
However, Maven and its plugins are getting there. I haven’t checked Gradle, but I guess that ecosystem is moving along as well. For integration tests, the world looks actually neat, at least in terms of the test setup itself. Everything needed can be expressed through module descriptors. Testing effort for something like the Spring framework itself to provide real module descriptors is of course something else and I am curious with what solution the smart people over there will come along in time of Spring 6.
If you find this post interesting, feel free to tweet it and don’t forget to checkout the accompanying project: https://github.com/michael-simons/modulartesting.
Filed in English posts, Java
|