Course – LS – All

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE

1. Overview

In this tutorial, we’ll explore Java Modularity and how it affected the testing in Java applications. We’ll start with a brief introduction to the JPMS and then dive into how the tests work together with modules.

2. Java Platform Modularity System

Java Platform Modularity System, also known as JPMS, was introduced in Java 9 to improve the organization and maintainability of large applications. It provides a mechanism to define and manage dependencies between components more efficiently.

Modules are self-contained units of code that encapsulate their implementation and expose a well-defined API. They explicitly declare dependencies, making understanding the relationships between different system parts easier.

Some of the main benefits of Java Modularity are:

  • Encapsulation: Modules hide their internal implementation details and expose only what is needed through a well-defined API
  • Improved maintainability: By clearly separating concerns, managing and maintaining complex applications becomes easier
  • Enhanced performance: Modules can be loaded and unloaded at runtime, allowing the JVM to optimize the application’s memory footprint and startup time

3. Simple Class Path Tests

Before testing with modules, let’s consider tests in a non-modular Java application. Suppose we have a simple application with two classes, Book and Library:

library-core
└── src
    ├── main
    │   └── java
    │       └── com
    │           └── baeldung
    │               └── library
    │                   └── core
    │                       ├── Book.java
    │                       └── Library.java
    └── test
        └── java
            └── com
                └── baeldung
                    └── library
                        └── core
                            └── LibraryUnitTest.java

The Library class has a method called addBook that takes a Book and adds it to an internal list of books. Let’s write a test for the addBook method:

class LibraryUnitTest {

    @Test
    void givenEmptyLibrary_whenAddABook_thenLibraryHasOneBook() {
        Library library = new Library();
        Book theLordOfTheRings = new Book("The Lord of the Rings", "J.R.R. Tolkien");
        library.addBook(theLordOfTheRings);
        int expected = 1;
        int actual = library.getBooks().size();
        assertEquals(expected, actual);
    }
}

This test checks whether adding a book to the library increases the number of books and whether this book is in the library. The test’s imports are pretty straightforward:

package com.baeldung.core;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

We don’t need to import the Library and the Book classes, as JVM treats them as if they’re in the same package. This happens because of the classpath and the process of class discovery. However, this might cause issues which hard to debug and fix. On big projects, it can even lead to a JAR hell.

4. Modularized Tests

Let’s split our library management application into a modular structure. We’ll create a com.baeldung.library.core and a com.baeldung.library.test module. The com.baeldung.library.core module will contain the application code:

library-core
└── src
    └── main
        └── java
            ├── com
            │   └── baeldung
            │       └── library
            │           └── core
            │               ├── Book.java
            │               └── Library.java
            └── module-info.java

The com.baeldung.library.test will include the test code:

library-test
└── src
    └── test
        └── java
            ├── com
            │   └── baeldung
            │       └── library
            │           └── test
            │               └── LibraryUnitTest.java
            └── module-info.java

The structure reflects the structure of a Maven project for simplicity, but in modular applications, we only need to follow the guides of JPMS.

The module-info.java file for the com.baeldung.library.core module will look like this:

module com.baeldung.library.core {
    exports com.baeldung.library.core;
}

The module descriptor for the com.baeldung.library.test will contain a couple of additional directives:

module com.baeldung.library.test {
    requires com.baeldung.library.core;
    requires org.junit.jupiter.api;
    opens com.baeldung.library.test to org.junit.platform.commons;
}

We declared that the com.baeldung.library.test module requires the com.baeldung.library.core and the org.junit.jupiter.api module for testing. Also, we open the com.baeldung.library.test package to the org.junit.platform.commons module, which JUnit requires to access our test classes via reflection.

4.1. Testing Public Methods

Now let’s rewrite our test from the previous section using the modular structure:

class LibraryUnitTest {

    @Test
    void givenEmptyLibrary_whenAddABook_thenLibraryHasOneBook() {
        Library library = new Library();
        Book theLordOfTheRings = new Book("The Lord of the Rings", "J.R.R. Tolkien");
        library.addBook(theLordOfTheRings);
        int expected = 1;
        int actual = library.getBooks().size();
        assertEquals(expected, actual);
    }
}

Our test code hasn’t changed, but the structure of our project has. The main difference from the first example is the imports:

package com.baeldung.library.test;
import com.baeldung.library.core.Book;
import com.baeldung.library.core.Library;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;

We’re not using the classpath to discover the tests and application code and need to explicitly import the classes Book and Library to use in the tests. Exporting the same package from different modules is impossible. That’s why the test code and application core reside in the packages with different names.

4.2. Testing Protected Methods

Separating application and test codes into modules can violate the “Don’t Repeat Yourself” (DRY) principle. We may need to create subclasses or wrapper classes within the test module to test protected members. This code structure replication can lead to maintenance challenges and increase development time, as changes in the application code must also be reflected in the test module.

Let’s consider a protected method:

protected void removeBookByAuthor(String author) {
    books.removeIf(book -> book.getAuthor().equals(author));
}

We can use inheritance to widen the access to this method:

public class TestLibrary extends Library {
    @Override
    public void removeBookByAuthor(final String author) {
        super.removeBookByAuthor(author);
    }
}

Now, we can use this class in our tests:

@Test
void givenTheLibraryWithSeveralBook_whenRemoveABookByAuthor_thenLibraryHasNoBooksByTheAuthor() {
    TestLibrary library = new TestLibrary();
    Book theLordOfTheRings = new Book("The Lord of the Rings", "J.R.R. Tolkien");
    Book theHobbit = new Book("The Hobbit", "J.R.R. Tolkien");
    Book theSilmarillion = new Book("The Silmarillion", "J.R.R. Tolkien");
    Book theHungerGames = new Book("The Hunger Games", "Suzanne Collins");
    library.addBook(theLordOfTheRings);
    library.addBook(theHobbit);
    library.addBook(theSilmarillion);
    library.addBook(theHungerGames);
    library.removeBookByAuthor("J.R.R. Tolkien");
    int expected = 1;
    int actual = library.getBooks().size();
    assertEquals(expected, actual);
}

4.3. Testing Package-Private Methods

To access package-private members for testing, we may need to expose internal implementation details through public APIs or modify module descriptors to allow access.

Let’s consider another test for the removeBook method of the Library class:

void removeBook(Book book) {
    books.remove(book);
}

The method is package-private and is accessible only by classes inside the same packages. We won’t have any issues with it when we have tests in the same module:

package com.baeldung.library.core;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;

class LibraryUnitTest {
    // ...
    @Test
    void givenTheLibraryWithABook_whenRemoveABook_thenLibraryIsEmpty() {
        Library library = new Library();
        Book theLordOfTheRings = new Book("The Lord of the Rings", "J.R.R. Tolkien");
        library.addBook(theLordOfTheRings);
        library.removeBook(theLordOfTheRings);
        int expected = 0;
        int actual = library.getBooks().size();
        assertEquals(expected, actual);
    }
    // ...
}

However, due to the accessibility restrictions placed by the module system, we might need to use reflection in the tests located in a separate module. We should open the core module for tests to be able to do so. This can be done in two ways: add a directive inside our module descriptor or use the –add-opens command while executing the tests:

module com.baeldung.library.core {
    exports com.baeldung.library.core;
    opens com.baeldung.library.core to com.baeldung.library.test;
}

These changes in the module descriptor require com.baeldung.library.test on the module path all the time when we’re using com.baeldung.library.core, which isn’t convenient. A better solution to open the module when executing the tests:

--add-opens com.baeldung.library.core/com.baeldung.library.core=com.baeldung.library.test

After that, we can write our test:

package com.baeldung.library.test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import com.baeldung.library.core.Book;
import com.baeldung.library.core.Library;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import org.junit.jupiter.api.Test;

class LibraryUnitTest {

    // ...
    @Test
    void givenTheLibraryWithABook_whenRemoveABook_thenLibraryIsEmpty()
        throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
        Library library = new Library();
        Book theLordOfTheRings = new Book("The Lord of the Rings", "J.R.R. Tolkien");
        library.addBook(theLordOfTheRings);
        Method removeBook = Library.class.getDeclaredMethod("removeBook", Book.class);
        removeBook.setAccessible(true);
        removeBook.invoke(library, theLordOfTheRings);
        int expected = 0;
        int actual = library.getBooks().size();
        assertEquals(expected, actual);
    }
    // ...
}

However, please remember that this can compromise the system’s intended modularity and introduce the risk of unintended usage of internal APIs by other modules.

4.4. Running JUnit Tests

Having application and test code in separate modules requires additional work setting the tests. It’s simple, but managing all the parameters might be confusing. Let’s run our tests with org.junit.platform.console.ConsoleLauncher:

java --module-path mods \
--add-modules com.baeldung.library.test \
--add-opens com.baeldung.library.core/com.baeldung.library.core=com.baeldung.library.test \
org.junit.platform.console.ConsoleLauncher --select-class com.baeldung.library.test.LibraryUnitTest

The –module-path shows where we can find our test and core modules. However, the fact that these modules are on the module path won’t add them modules to the module resolution graph. To include it, we should use the following command:

--add-modules com.baeldung.library.test

As we discussed, the com.baeldung.library.test has no reflection access to the com.baeldung.library.core module. We can grant it through the module descriptor or directly while running the tests. In the case of tests, it’s better to do it via a command rather than a directive:

--add-opens com.baeldung.library.core/com.baeldung.library.core=com.baeldung.library.test

The last line identifies the class with the tests we want to run:

org.junit.platform.console.ConsoleLauncher --select-class com.baeldung.library.test.LibraryUnitTest

This isn’t the only way we can run the tests, and the documentation for ConsoleLauncher contains information about the flags and parameters we can use.

5. Test Outside of a Module

Often, it’s convenient to have only the application code in a module and leave the test outside without placing it in a separate module. Let’s check how we can achieve this in our application.

5.1. Running From Class Path

The simplest way is to ignore module-info.java entirely and use a “good” old classpath to run the tests:

javac --class-path libs/junit-jupiter-engine-5.9.2.jar:\
libs/junit-platform-engine-1.9.2.jar:\
libs/apiguardian-api-1.1.2.jar:\
libs/junit-jupiter-params-5.9.2.jar:\
libs/junit-jupiter-api-5.9.2.jar:\
libs/opentest4j-1.2.0.jar:\
libs/junit-platform-commons-1.9.2.jar \
-d outDir/library-core \
library-core/src/main/java/com/baeldung/library/core/Book.java \
library-core/src/main/java/com/baeldung/library/core/Library.java \
library-core/src/main/java/com/baeldung/library/core/Main.java \
library-core/src/test/java/com/baeldung/library/core/LibraryUnitTest.java

The libs folder should contain the required JUnit dependencies. The example source provides a script to download them automatically.

And then, we can use ConsoleLauncher to run the tests from the classpath:

java --module-path libs \
org.junit.platform.console.ConsoleLauncher \
--classpath ./outDir/library-core \
--select-class com.baeldung.library.core.LibraryUnitTest

This way, we ignore modules entirely and run the tests from the classpath. Although, for simple projects, this works just fine, for applications that have complex relationships between modules, this approach might not work well. Additionally, this might hide problems that can appear while running the application using modules.

5.2. Patching a Module

A better way to resolve the problem is to patch modules with additional classes before running the tests. This way, we can avoid a complex setup for the interaction between the test and the application modules.

It’s possible to add test classes to the module before we run it. Also, we can have the packages with the same name for the test, so we’ll have access to protected and package-private members out of the box:

java --module-path mods:/libs \
--add-modules com.baeldung.library.core \
--add-opens com.baeldung.library.core/com.baeldung.library.core=org.junit.platform.commons \
--add-reads com.baeldung.library.core=org.junit.jupiter.api \
--patch-module com.baeldung.library.core=outDir/library-test \
--module org.junit.platform.console --select-class com.baeldung.library.core.LibraryUnitTest

We already reviewed –add-modules and –add-opens commands previously. However, here we’re using –add-opens to allow reflective access for JUnit. When we had a dedicated test module, this was done by opens directive.

The directive –add-reads is required as we use the classes from org.junit.jupiter.api module in our tests and need to declare explicitly that we’re using it:

--add-reads com.baeldung.library.core=org.junit.jupiter.api

The key part is the command –patch-module:

--patch-module com.baeldung.library.core=outDir/library-test

This command puts the compiled test class com.baeldung.library.core.LibraryUnitTest inside our module. After this, we can run the tests directly without additional module configuration.

6. Conclusion

We can achieve better organization and maintainability using Java Modularity, allowing us to manage complex applications more efficiently. Additionally, the explicit declaration of dependencies between modules helps us understand the relationships between different system parts, further improving the overall quality of our code.

However, creating separate test modules requires additional setup and might complicate and even violate the boundaries of the application modules. That’s why often it’s easier to place tests outside modules and use –patch-module to inject test classes inside while running tests.

As usual, the source code for this tutorial can be found over on GitHub.

Course – LS – All

Get started with Spring and Spring Boot, through the Learn Spring course:

>> CHECK OUT THE COURSE
res – REST with Spring (eBook) (everywhere)
Comments are closed on this article!