Unit-Testing mit Project-Jigsaw

#JCON2017 #ProjectJigsaw #JUnit

Die Modularisierung von Projekten durch Project Jigsaw wurde von der Java-Community lange erwartet und ist nun endlich da. Doch diese Neuerung bringt auch neue Herausforderungen mit sich; eine davon ist das Testen von modularisierten Projekten. Hier sollen einige der auftretenden Schwierigkeiten beleuchtet sowie passende Lösungen betrachtet werden die auch bei mit Jigsaw modularisierte JDK 9-Projekte ein sinnvolles Testvorgehen ermöglichen.

Um ein Java-Projekt durch Unit-Tests überprüfen zu können hat sich JUnit 4 in vielen Projekten bewährt und mit JUnit 5 wird sich dieser Trend voraussichtlich fortsetzen. Um JUnit oder eine vergleichbare Bibliothek nutzen zu können müssen sowohl zum Zeitpunkt der Kompilierung der Tests als auch zur Laufzeit dieser Tests eine Reihe von JAR-Dateien verfügbar sein. Traditionell hieß das im Java-Umfeld, dass sich diese Dateien auf dem Klassenpfad (Classpath) befinden mussten. Dies wurde häufig mit Hilfe von Build-Werkzeugen wie Maven oder Gradle durch den entsprechenden Scope erreicht. Mit Project-Jigsaw jedoch tritt vielerorts der Modulpfad (Modulepath) an die Stelle des Classpath. Wie bisher ist vermutlich in den meisten Fällen jedoch nicht erwünscht, dass Testabhängigkeiten wie JUnit, AssertJ oder Mockito in ausgelieferten Produkten enthalten sind.
Mit Version 4.1 bringt Gradle experimentelle Unterstützung für einen solchen Modulpfad mit und auch Maven lässt sich überreden ein Jigsaw-Projekt zu bauen. Beide Integrationen sind jedoch abhängig davon, welche Möglichkeiten Jigsaw mitbringt.

Wie funktioniert das Modulsystem von Project Jigsaw?

Betrachten wir zunächst einmal, wie es das Java 9 Modulsystem grundsätzlich die Modularisierung ermöglicht und mit bestehenden Bibliotheken umgeht. Unerlässlich hierfür ist die Beschreibung von JEP 261. Zur Verdeutlichung der folgenden Konzepte verwenden wir ein kleines Beispielprojekt mit der folgenden Dateistruktur:

Listing 1
.
├── lib
│    ├── main
│    │   └── vavr-0.9.0.jar
│    └── test
│    ├── hamcrest-core-1.3.jar
│    └── junit-4.12.jar
└── src
     ├── main
     │   └── java
     │       └── com.senacor.greeting
     │           ├── com
     │           │   └── senacor
     │           │       └── greeting
     │           │           ├── Main.java
     │           │           └── internal
     │           │               └── Hello.java
     │           └── module-info.java
     └── test
         └── java
             └── com.senacor.greeting
                 └── com
                     └── senacor
                         └── greeting
                             └── internal
                                 └── HelloTest.java

Die module-info.java hat dabei den folgenden Aufbau:

Listing 2
module com.senacor.greeting {
   requires vavr;
}

Es wird zunächst nichts bereitgestellt und lediglich eine Abhängigkeit definiert. Hierbei ist explizit zu beachten, dass es hier keine definierten Testabhängigkeiten gibt.
Beim Kompiliervorgang müssen dem Compiler ggf. vorhandene Abhängigkeiten mitgeteilt werden. Dies passiert, indem man den Applikations-Modulepath durch die Option –module-path definiert. Das kann wie folgt aussehen:

Listing 3
$ javac … --module-path <module>(:<module>)* …

Ein konkretes Beispiel dafür wäre wie folgt:

Listing 4

$ javac&amp;amp;nbsp;--module-path lib/main \
        -d "target/main/com.senacor.greeting" \
$(find src/main -name "*.java")

Die erste Zeile weist den Compiler an, unter lib/main Module zu suchen, die für den Kompiliervorgang notwendig sind. Dass hier das lib Verzeichnis in mehrere Teile geteilt wurde, liegt daran, dass die Testabhängigkeiten (hier JUnit und Hamcrest) nicht mit kompiliert werden sollen. Die zweite Zeile gibt dem Compiler den Ausgabepfad target/main/com.senacor.greeting an. Dies hat sich gegenüber früheren Java-Versionen nicht verändert. Die dritte Zeile sucht alle Java-Dateien, die unter src/main liegen und kompiliert diese dann mit den gegebenen Abhängigkeiten. Wir finden nun im target Verzeichnis die folgende Dateistruktur:

Listing 5

target/
└── main
    └── com.senacor.greeting
        ├── com
    │   └── senacor
    │       └── greeting
    │           ├── Main.class
    │           └── internal
    │               └── Hello.class
    └── module-info.class

Diese Struktur spiegelt offensichtlich diejenige unter src/main/java wieder. Sie beinhaltet außerdem nicht die übergebenen Module (JARs) auf dem Modulpfad.
Um den kompilierten Code auszuführen müssen wir nun lediglich den folgenden Befehl aufrufen:

Listing 6
$ java --module-path=target/main:lib/main \
       --module com.senacor.greeting/com.senacor.greeting.Main
Hello World!

Hier übergeben wir in der ersten Zeile den Modulpfad. Wichtig ist hierbei zu beachten, dass sowohl der Inhalt des target/main Verzeichnisses, als auch der des bereits beim Kompilieren übergebenen Verzeichnisses lib/main auf dem Modulpfad liegen müssen, sofern sich in letzterem Laufzeitabhängigkeiten
befinden. Dies ist hier der Fall.
Die zweite Zeile gibt nun an, welches das auszuführende Modul sowie die darin liegende auszuführende Klasse ist. Als Ausgabe sehen wir Hello World!.

Die bedingte Einbindung zusätzlicher Module

Während dies für die reine Ausführung unseres Codes hinreichend ist, können wir damit noch keine Tests schreiben. Die Testabhängigkeiten sollen zum Kompilierzeitpunkt des Produktionscodes nicht mit kompiliert werden. Glücklicherweise bringt das JDK 9 die Möglichkeit mit, Module nach der Übersetzung zu
ändern (patchen). Dies erfordert jedoch einige weitere Schritte. Zunächst müssen wir die Testklassen kompilieren. Dies passiert mit dem folgenden Aufruf:

Listening 7
$ javac --module-path target/main:lib/main:lib/test \
        --patch-module com.senacor.greeting=src/test \
        -d target/test/com.senacor.greeting \
$(find src/test -name "*.java")

Ähnlich wie beim vorherigen Kompiliervorgang müssen wir den Modulpfad, das Kompilierziel sowie die zu kompilierenden Klassen angeben. In diesem Fall befindet sich auf dem Modulpfad, zusätzlich zu den Kompilierzeit-Modulen für den Produktionscode, auch der bereits kompilierte Code sowie die
Testbibliotheken.
Neu ist hier die Option ‑‑patch-module, welche die folgende Syntax hat:

Listing 8
$ javac … --patch-module <module>=<file>(<pathsep><file>)* …

Hiermit geben wir an, dass zum Kompilierzeitpunkt das Modul com.senacor.greeting durch den Inhalt von src/test gepatched werden soll. Das resultierende target Verzeichnis sieht nun wie folgt aus:

Listing 9
target/
├── main
│   └── com.senacor.greeting
│   ├── com
│   │   └── senacor
│   │       └── greeting
│   │           ├── Main.class
│   │           └── internal
│   │               └── Hello.class
│   └── module-info.class
└── test
    └── com.senacor.greeting
        └── com
            └── senacor
                └── greeting
                    └── internal
                        └── HelloTest.class

Zu bemerken ist hier, dass in dem neu angelegte test Verzeichnis lediglich die geänderten bzw. neu hinzugekommenen Dateien hinterlegt wurden. Das Kompilat im main Verzeichnis bleibt unverändert und kann dementsprechend weiterverwendet werden. Um nun die Tests auszuführen müssen wir erneut den Patch einbinden:

Listing 10
$ java --module-path=target/main:lib/main:lib/test \
       --add-modules com.senacor.greeting \
       --patch-module
com.senacor.greeting=target/test/com.senacor.greeting \
      --add-exports
com.senacor.greeting/com.senacor.greeting.internal=junit \
      --module junit/org.junit.runner.JUnitCore \
com.senacor.greeting.internal.HelloTest
JUnit version 4.12
...
Time: 0.041

OK (3 tests)

Wie beim vorherigen Aufruf eines Moduls geben wir den zur Laufzeit notwendigen Modulpfad an. Ebenfalls wie zuvor geben wir das aufzurufenden Modul sowie die darin aufzurufende Klasse an. Zur Testausführung mit JUnit 4 ist dies: junit/org.junit.runner.JUnitCore mit der auszuführenden Testklasse als Argument. Es finden sich aber auch ein paar Kommandozeilenoptionen in dem Aufruf, die vorher nicht notwendig waren. Die erste Option ist ‑‑add‑modules mit der Syntax:

Listing 11
$ java ... --add-modules &lt;module&gt;(,&lt;module&gt;)* ...

Diese Option sit notwendig, da das Hautpmodul in diesem Fall junit ist. Dieses benötigt aber Zugriff auf Klassen aus dem zu testenden modul. Hierbei ist zu beachten, dass wir in der module-info.java keinerelei Exporte definieren mussten. Die nächste Option –patch-module ist analog zur gleichnamigen Option des Komileraufrufs zu verstehen. Anders als die Verwendung zur Kompilierzeit geben wir hiermit aber der Java-Laufzeitumgebung mit, dass das Modul com.senacor.greeting durch den Inhalt von target/test/com.senacor.greeting gepatched werden soll. Dadurch erscheint es für die Laufzeitumgebung so, als wäre das Modul wie folgt aufgebaut:

Listing 12
com.senacor.greeting/
├── com
│   └── senacor
│       └── greeting
│           ├── Main.class
|           └── internal
|               ├── Hello.class
|               └── HelloTest.class
└── module-info.class

Dadurch, dass Hello.class und HelloTest.class – soweit es die Laufzeitumgebung beurteilen kann – im gleichen Modul und Package liegen, gibt es keinerlei Zugriffsprobleme. Damit die können Tests ausgeführt werden.

Überprüfung der Modularisierung

Da wir nun innerhalb der Module Tests ausführen können, bleibt noch zu prüfen ob die Modularisierung korrekt funktioniert. Die Erwartung wäre, dass ausschließlich die mit dem Schlüsselwort export in der Modulbeschreibung explizit freigegebenen Klassen außerhalb des Moduls verfügbar sind. Verfügbar heißt in diesem Fall, dass Importe fehlschlagen werden und die Instanzieruzbg per Reflection ebenfalls unterbunden wird. Es ist, zumindest mit dem ersten Release-Candidate von JDK 9, weiterhin möglich per Reflection auf die Klasse selbst zuzugreifen, nicht aber diese zu isntanzieren. Damit ist es weiterhin möglich, gewisse Informationen über eine nicht freigegebene Klasse zu erhalten.
Um zu prüfen, dass die Modularisierung und das damit einhergehende Sperren einiger interner Klassen erfolgreich war, bedarf es Integrationstests. Zunächst erweitern wir deshalb die module-info.java um eine Freigabe:

Listing 13
module com.senacor.greeting {
   requires vavr;
   exports com.senacor.greeting;
}

Damit sollte die Main-Klasse, nicht aber die Hello-Klasse von außerhalb verfügbar sein. Dies wäre für den reinen Integrationstest nicht notwendig, erlaubt uns aber sowohl einen Positiv-, als auch einen Negativfall zu überprüfen. Um nun zu testen, was von außen erreichbar ist, benötigen wir an dieser Stelle ein neues Modul. Dieses kann im Test-Verzeichnis liegen und benötigt einen anderen Modulnamen als das zu testende Modul. Die Struktur kann beispielsweise wie folgt aussehen:

Listing 14
test
└── java
    ├── com.senacor.greeting
    │   └── com
    │       └── senacor
    │           └── greeting
    │               ├── Main.java
    │               └── internal
    │                   └── HelloTest.java
    └── com.senacor.greeting.it
        ├── com
        │   └── senacor
        │       └── greeting
        │           └── it
        │               ├── MainIT.java
        │               └── internal
        │                   └── HelloIT.java
        └── module-info.java

Die Struktur, spezifisch für die Integrationstests, sieht man hier in der unteren Hälfte. In diesem Fall beinhaltet der neue Modulname also lediglich das Suffix .it nach dem obersten Paketnamen. Anders als die Unit-Tests werden die Integrationstests seperat als Modul kompiliert, weshalb hier ein eigenes module-info.java vonnöten ist. Dieses sieht wie folgt aus:

Listing 15
module com.senacor.gretting.it {
   requires com.senacor.greeting;
   requires junit;
}

Da es sich hier um ein reines Testmodul handelt, ist eine Einbindung von JUnit auf dieser Ebene unproblematisch.
Die tatsächlichen Testklassen sind ebenfalls recht simpel gehalten. Im Fall der Main-Klasse erwarten wir volle Erreichbarkeit, da diese exportiert wird. Der Test sieht entsprechend wie folgt aus:

Listing 16
package com.senacor.greeting.it;

import com.senacor.greeting.Main;
import org.junit.Test;

public class MainIT {
   @Test public void mainClassIsInstantiatable() { new Main(); }
   @Test public void mainMethodIsCallable() { Main.main(); }
}

Wir prüfen also lediglich, ob die Klasse instanziiert werden kann und ob die darin liegende statische Methode aufrufbar ist. Warum das als Test für diese Klasse genügt, sieht man am besten im Vergleich zum Test der Hello Klasse:

Listing 17
package com.senacor.greeting.it.internal;

import org.junit.Test;
import java.lang.reflect.InvocationTargetException;
import static org.junit.Assert.*;

public class HelloIT {
   @Test public void classHelloFromGreetingIsAvailable() throws
ClassNotFoundException {
      Class<?> helloClass = getClass().getClassLoader()
         .loadClass("com.senacor.greeting.internal.Hello");
      assertNotNull(helloClass);
}
}
   @Test public void helloCannotBeInstantiated() throws
ClassNotFoundException {
      Class<?> helloClass = getClass().getClassLoader()
         .loadClass("com.senacor.greeting.internal.Hello");
      try {
         helloClass.getConstructor().newInstance();
         fail("Hello could be instantiated after all");
      } catch (Exception e) {
         assertEquals(IllegalAccessException.class, e.getClass());
      }
   }
}

Hier ist zwar die Klasse per Reflection abrufbar, der Versuch sie zu instanzieren schlägt jedoch mit einer IllegalAccessExcetion fehl.
Dieser Test-Code kan nun wie folgt kompiliert werden:

Listing 18
$ javac --module-path target/main/com.senacor.greeting:lib/test \
        -d "target/it/com.senacor.greeting.it" \
        $(find src/test/java/com.senacor.greeting.it -name "*.java")

Das Resultat liegt dann im target Verzeichnis in einem Unterordner namens it. Ausgeführt werden können die Tests dann mit folgendem Befehl.

Listing 19
$ java --module-path=target/main:target/it:lib/main:lib/test \
       --add-modules com.senacor.greeting,com.senacor.greeting.it \
       --add-exports
com.senacor.greeting.it/com.senacor.greeting.it=junit \
       --add-exports
com.senacor.greeting.it/com.senacor.greeting.it.internal=junit \
       --add-reads com.senacor.greeting=junit \
       --module junit/org.junit.runner.JUnitCore \
       com.senacor.greeting.it.MainIT \
       com.senacor.greeting.it.internal.HelloIT
JUnit version 4.12
..Hello World!
..
Time: 0.009

Da hier von außen auf das Modul zugegriffen werden soll, ist auch kein Patching nötig, wie es bei den Unit-Tests der Fall war.

Fazit

Das Modularisieren von Java-Projekten mittels Project-Jigsaw erfordert an einigen Stellen ein Umdenken im Bereich des automatisierten Testens. Mit den Erkenntnissen dieses Artikels jedoch sollte das Schreiben von Unit-Tests im Vergleich zu unmodularisierten Projekten keine größeren Probleme bereiten. Weiterhin ist relativ leicht eine Überprüfung der korrekten Modularisierung möglich. Der gesamte Sourcecode zu diesem Artikel ist auf Github verfügbar.


Autor – Alasdair Collinson

Alasdair Collinson – software developer for Senacor Technologies by day, I have long been interested in the „metadata“ of computers and programming. This has lead to – amoung other things – the exploration of some esoteric programming languages, the culture around geeks and nerds (of which I am one myself) and the history of computers and programming. It also leads me to exploring new concepts and how those influence what we do for a living.
twitter

(Visited 32 times, 1 visits today)

Leave a Reply