Testing in a modular world

The joys of the Java module system, Surefire and IDEs.
October 19, 2021 by Michael

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

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

It’s runnable on the module path like this

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

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

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

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

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

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

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>

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

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

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.

5 comments

  1. David Grieve wrote:

    Make your test methods public and the ‘does not open to unnamed module’ issue goes away.

    Posted on October 29, 2021 at 1:32 PM | Permalink
  2. Michael wrote:

    Thanks, David, for your comment!

    Yes, you are indeed right.
    Using package private test(s) and test classes has become such a habit, that I most often think not about it (and, some IDEs start to recommend it, too).

    Posted on October 30, 2021 at 10:57 AM | Permalink
  3. Michael Piefel wrote:

    Thank you for sharing this. Opening up the test methods with public is not the JUnit recommended path, and some for some mocking this is not enough. So the –add-opens is the one solution that will really fix things.

    Posted on February 24, 2022 at 4:18 PM | Permalink
  4. Michael wrote:

    Thanks, Michael. Really appreciate your feedback!

    Posted on February 24, 2022 at 4:23 PM | Permalink
  5. Rob wrote:

    Also noting that maven-surefire-plugin has the useModulePath false configuration option.

    Posted on March 17, 2022 at 4:44 AM | Permalink
One Trackback/Pingback
  1. Java Weekly, Issue 408 | Baeldung on October 25, 2021 at 2:30 PM

    […] >> Testing in a Modular World [info.michael-simons.eu] […]

Post a Comment

Your email is never published. We need your name and email address only for verifying a legitimate comment. For more information, a copy of your saved data or a request to delete any data under this address, please send a short notice to michael@simons.ac from the address you used to comment on this entry.
By entering and submitting a comment, wether with or without name or email address, you'll agree that all data you have entered including your IP address will be checked and stored for a limited time by Automattic Inc., 60 29th Street #343, San Francisco, CA 94110-4929, USA. only for the purpose of avoiding spam. You can deny further storage of your data by sending an email to support@wordpress.com, with subject “Deletion of Data stored by Akismet”.
Required fields are marked *