Java-Bibliotheken für den Einsatz in Tests

Testunterstützung

In den meisten Projekten entfällt ein nicht gerade kleiner Anteil des Codes auf Tests. Für diesen Testcode sollten dieselben Regeln wie für Produktivcode gelten. Neben dem Achten auf Verständlichkeit und Wartbarkeit gehört auch das Erschaffen von Abstraktionen und das Vermeiden des Not-invented-here-Syndroms durch den Einsatz von passenden Bibliotheken dazu. Dieser Artikel stellt sechs Bibliotheken vor, die sich im Einsatz als praktisch und hilfreich erwiesen haben.

Projekte kommen heutzutage in der Regel nicht mehr ohne den Einsatz von Fremdbibliotheken aus. Ein Neuerfinden/-schreiben bereits vorhandener Funktionalitäten ist meistens unwirtschaftlich und bremst die Produktivität. Schließlich sollen fachliche Probleme gelöst und nicht primär technischer Code geschrieben werden.

Dasselbe Argument gilt natürlich nicht nur für den Produktiv-, sondern auch für Testcode. Auch hier gibt es für die verschiedensten Anforderungen bereits fertige Bibliotheken, die nur noch eingebunden werden müssen.

Mit wachsender Anzahl wird es jedoch zunehmend schwerer den Überblick zu behalten beziehungsweise zu wissen, ob es für das aktuelle Problem bereits eine fertige Bibliothek gibt. Auch eine Suche im Internet wird aufgrund der großen Anzahl an Treffern immer schwieriger. Dieser Artikel stellt darum sechs Java-Bibliotheken (s. Tabelle 1) vor, die Entwicklern beim Schreiben von Tests unter die Arme greifen. Diese haben sich in der Praxis bewährt und wurden vom Autor bereits in mehreren Projekten erfolgreich eingesetzt.

Tabelle 1: Java-Bibliotheken für den Einsatz in Tests
Bibliothek Link
Awaitility https://github.com/awaitility/awaitility
EqualsVerifier http://jqno.nl/equalsverifier
Java Faker http://dius.github.io/java-faker
Log Collectors https://github.com/haasted/TestLogCollectors
Make It Easy https://github.com/npryce/make-it-easy
System Rules https://stefanbirkner.github.io/system-rules/index.html

EqualsVerifier

An vielen Stellen werden in Java-Programmen Objekte miteinander verglichen. Standardmäßig prüft Java dabei für Klassen, ob es sich bei den zu vergleichenden Objekten um dieselbe Instanz handelt. Soll dieses Verhalten geändert werden, muss die Methode equals aus java.lang.Object überschrieben werden. Die so überschriebene Methode muss anschliessend jedoch immer fünf Eigenschaften aufweisen:

  • Die Implementierung muss reflexiv sein. Das bedeutet, dass die Methode true zurückgeben muss, wenn überprüft wird, ob eine Instanz zu sich selbst identisch ist.
  • Die zweite Eigenschaft ist die der Symmetrie. Hierbei ist sicherzustellen, dass wenn x identisch zu y ist, auch y identisch zu x ist.
  • Zudem muss die Methode auch transitiv sein. Es muss also gelten, dass wenn x identisch zu y und y identisch zu z ist, dass auch x und z identisch sein müssen.
  • Außerdem muss das Ergebnis konsistent sein, also mehrere Aufrufe müssen immer dasselbe Ergebnis zurückgeben, solange nicht eines der beiden Objekte in der Zwischenzeit modifiziert wurde.
  • Die letzte Eigenschaft fordert, dass ein Vergleich mit null immer false zurückzugeben hat.

Neben diesen generellen Eigenschaften besteht auch noch eine Kopplung an die Methode hashCode. Dies führt dazu, dass in der Regel beide Methoden überschrieben werden müssen. Auch hashCode fordert, dass mehrere Aufrufe der Methode immer dasselbe Ergebnis zurückgeben, solange die Instanz in der Zwischenzeit nicht geändert wurde. Zudem muss das Ergebnis von Aufrufen auf gleichen Objekten identisch sein. Für unterschiedliche Objekte gibt es keine Vorgabe, es ist jedoch sinnvoll, hier dafür zu sorgen, dass unterschiedliche Ergebnisse entstehen.

Besonders spannend wird diese Thematik vor allem, wenn Vererbung im Einsatz ist. Gerade die Eigenschaft der Symmetrie wird hierbei schnell verletzt.

Aus diesem Grund bietet es sich an, für Klassen, bei denen equals und hashCode überschrieben wird, Tests zu schreiben, um deren Richtigkeit zu überprüfen. Um hierbei nicht jedes Mal wieder das Rad neu zu erfinden, kann die Bibliothek EqualsVerifier verwendet werden. Mit dieser reduziert sich das Testen aller Eigenschaften auf einen einzelnen Test (s. Listing 1).

@Test
public void equalsAndHashCode_shouldFulfillContract() {
    EqualsVerifier.forClass(Person.class).verify();
}
Listing 1: Test mit EqualsVerifier

EqualsVerifier setzt für die Überprüfung Reflection ein und trifft einige Annahmen, um auch Randfälle überprüfen zu können. So wird beispielsweise gefordert, dass die Klasse oder die beiden Methoden equals und hashCode alle final sind, dass beide Methoden nicht von einem änderbaren Feld abhängen oder dass alle Felder einbezogen werden.

Erfüllt der eigene Code nicht alle Annahmen, ist es möglich, EqualsVerifier mitzuteilen, dass diese ignoriert werden sollen. Hierfür sollte allerdings immer ein guter Grund bestehen.

Awaitility

Das Testen synchroner Methodenaufrufe ist relativ einfach. Der initiale Zustand wird aufgesetzt, die zu testende Methode wird aufgerufen und anschließend überprüft man das Ergebnis des Methodenaufrufes oder ob sich an einer anderen Stelle etwas, durch einen Seiteneffekt, geändert hat.

Bei asynchroner Verarbeitung ist dies bereits schwieriger. Der Aufruf auf der zu testenden Methode gibt dem Test sofort die Kontrolle zurück. Natürlich kann dieser jetzt direkt versuchen, Dinge zu überprüfen. Durch die nun auftretende parallele Verarbeitung kann es aber passieren, dass der Test schneller ist und deswegen Dinge überprüft, die erst später passieren werden.

Genau hier setzt die Bibliothek Awaitility an. Diese erlaubt es mit einfachen Mitteln, nach dem Aufruf einer asynchronen Methode im Test zu warten, bis eine bestimmte Bedingung erreicht wurde. Da dies die Gefahr birgt, dass der Test nun ewig wartet, wenn die Bedingung nie erfüllt wird, wird zudem standardmäßig nur maximal zehn Sekunden gewartet. Tritt die Bedingung nicht innerhalb dieser Zeit ein, wird eine Exception geworfen. Natürlich lässt sich dieser Timeout auch für jeden Aufruf einzeln konfigurieren. Listing 2 zeigt den Einsatz von Awaitility inklusive Konfiguration des Timeouts.

public class AwaitilityTest {
    AtomicBoolean bool = new AtomicBoolean(false);

    void methodWithAsyncSideeffect(int timeout) {
        CompletableFuture.runAsync(() -> {
            try {
                SECONDS.sleep(timeout);
            } catch (InterruptedException e) {}
            bool.set(true);
        });
    }

    @Test
    public void awaitilitySuccess() {
        methodWithAsyncSideeffect(3);
        await().atMost(4, SECONDS).untilAtomic(bool, is(true));
    }

    @Test(expected = ConditionTimeoutException.class)
    public void awaitilityFailure() {
        methodWithAsyncSideeffect(5);
        await().atMost(4, SECONDS).untilAtomic(bool, is(true));
    }
}
Listing 2: Awaitility

Um die Wartebedingungen zu formulieren, wird standardmäßig Hamcrest verwendet. Es ist jedoch auch möglich, diese mit AssertJ zu formulieren.

Zudem ist es möglich, Einfluss auf den Thread- und Poll-Mechanismus zu nehmen. Wird nichts angegeben, nutzt Awaitility ein fixes Intervall, um zu prüfen, ob die formulierte Erwartung bereits eingetroffen ist. Neben dieser Strategie bringt Awaitility bereits ein Fibonacci- und ein iteratives Intervall mit.

System Rules

Wird java.lang.System genutzt, erleichtert einem die nützliche Bibliothek System Rules das Leben. Die drei Regeln SystemErrRule, SystemOutRule und TextFromStandardInputStream helfen vor allem dabei, Tests für Kommandozeilen-Anwendungen zu schreiben. Die ersten beiden Regeln ermöglichen dabei das Überprüfen, ob erwartete Texte geschrieben wurden, die letzte hilft dabei, Eingaben in die Anwendung hineinzugeben. Listing 3 zeigt, wie diese drei genutzt werden können.

public class SystemOutErrInTest {

    @Rule
    public SystemOutRule systemOut =
        new SystemOutRule().enableLog().mute();

    @Test
    public void systemOut() {
        System.out.println("Hello, world!");
        assertEquals("Hello, world!\n", systemOut.getLog());
    }

    @Rule
    public SystemErrRule systemErr =
        new SystemErrRule().enableLog().mute();

    @Test
    public void systemErr() {
        System.err.println("Hello, world!");
        assertEquals("Hello, world!\n", systemErr.getLog());
    }

    @Rule
    public TextFromStandardInputStream systemIn =
        emptyStandardInputStream();

    @Test
    public void systemIn() {
        Scanner sc = new Scanner(System.in);
        systemIn.provideLines("Hello, world!");
        assertEquals("Hello, world!", sc.nextLine());
    }
}
Listing 3: Regeln für System.out, System.err und System.in

Wird eine Anwendung mit grafischer Oberfläche entwickelt, sollte in der Regel nicht mittels System.out oder System.err geloggt werden, sondern über ein Logging-Framework. Um sicherzustellen, dass dies der Fall ist, lassen sich die Regeln DisallowWriteToSystemOut und DisallowWriteToSystemErr verwenden (s. Listing 4).

public class DisallowSystemOutErrTest {

    @Rule
    public DisallowWriteToSystemOut systemOut =
        new DisallowWriteToSystemOut();

    @Test(expected = AssertionError.class)
    public void systemOut() {
        System.out.println("Hello, world!");
    }

    @Rule
    public DisallowWriteToSystemErr systemErr =
        new DisallowWriteToSystemErr();

    @Test(expected = AssertionError.class)
    public void systemErr() {
        System.err.println("Hello, world!");
    }
}
Listing 4: Regeln für das Verbot von System.out und System.err

Vor allem bei Kommandozeilen-Anwendungen relevant ist der Einsatz von System.exit. Mit ExpectedSystemExit kann sichergestellt werden, dass die Anwendung mit dem passenden Exit-Code beendet wurde (s. Listing 5).

public class SystemExitTest {

    @Rule
    public ExpectedSystemExit systemExit =
        ExpectedSystemExit.none();

    @Test
    public void systemExit() {
        systemExit.expectSystemExitWithStatus(4711);
        System.exit(4711);
    }
}
Listing 5: Regel für System.exit

Zudem bietet System Rules noch Unterstützung für System- und Umgebungsvariablen. ClearSystemProperties entfernt die spezifizierte Systemvariable vor dem Test und ProvideSystemProperty setzt sie auf einen vorgegebenen Wert. Beide sorgen dafür, dass nach dem Test der vorher existierende Stand wiederhergestellt wird. RestoreSystemProperties wird verwendet, um sämtliche während des Tests erfolgten Änderungen anschließend zu verwerfen. EnvironmentVariables ist das Pendant zu ProvideSystemProperty für Umgebungsvariablen. Beispiele für diese vier Regeln sind in Listing 6 zu sehen.

public class SystemPropertiesEnvTest {

    @Rule
    public ClearSystemProperties clearedSystemProperty =
        new ClearSystemProperties("java.runtime.name");

    @Test
    public void clearSystemProperty() {
        assertNull(System.getProperty("java.runtime.name"));
    }

    @Rule
    public ProvideSystemProperty provideSystemProperty =
        new ProvideSystemProperty("os.name", "My OS");

    @Test
    public void provideSystemProperty() {
        assertEquals("My OS", System.getProperty("os.name"));
    }

    @Rule
    public RestoreSystemProperties restoreSystemProperties =
        new RestoreSystemProperties();

    @Test
    public void restoreSystemProperty() {
        System.setProperty("foo", "bar");
        System.setProperty("java.runtime.name", "JavaSPEKTRUM VM");
    }

    @Rule
    public EnvironmentVariables environmentVariables =
        new EnvironmentVariables();

    @Test
    public void environmentVariables() {
        environmentVariables.set("FOO", "bar");
        assertEquals("bar", System.getenv("FOO"));
    }
}
Listing 6: Regeln für System- und Umgebungsvariablen

Als letztes gibt es noch die Regel ProvideSecurityManager, mit der man einen eigenen SecurityManager für einen einzelnen Test setzen kann.

Zum aktuellen Zeitpunkt wird von System Rules nur JUnit 4 unterstützt. Es wird jedoch bereits seit einiger Zeit am Support für JUnit 5 gearbeitet.

Java Faker

Häufig müssen in Tests Daten aufgesetzt werden. Um sich nicht immer kreative Werte selbst ausdenken zu müssen, kann Java Faker verwendet werden. Java Faker ist ein Port der beliebten Ruby Faker-Bibliothek, welche wiederum selbst ein Port der Perl-Bibliothek Faker ist.

Java Faker unterstützt mittlerweile über 25 Arten von Daten in über 40 Sprachen. Listing 7 zeigt, wie die Bibliothek verwendet wird. Um die Tests im Fehlerfall mit den gleichen generierten Daten ausführen zu können, sollte man die Initialisierung mit einer Instanz von java.util.Random nutzen. Diese Random-Instanz kann bei jedem Start mit einem zufällig gewählten Integer erzeugt werden. Allerdings sollte man sich den Integer ausgeben lassen, um diesen beim Nachstellen auf einen fixen Wert zu setzen.

public class JavaFakerTest {

    @Test
    public void javaFaker() {
        System.out.println(Faker.instance().artist().name());
        System.out.println(Faker.instance(ENGLISH).color().name());
        System.out.println(Faker.instance(GERMAN).color().name());
        System.out.println(Faker.instance().slackEmoji().nature());
    }

    @Test
    public void reproducableData() {
        Random random = new Random(4711);
        Faker faker = Faker.instance(Locale.GERMAN, random);
        // Chuck Norris kann im Kinderkarussell überholen.
        System.out.println(faker.chuckNorris().fact());
    }
}
Listing 7: Einsatz von Java Faker

Make It Easy

Neben passenden Daten müssen die Objekte in den Tests natürlich auch in einen passenden Zustand versetzt werden. Als Muster hat sich hier das Builder-Pattern etabliert. Das manuelle Schreiben von komplexeren Buildern kann allerdings schnell aufwendig werden und in viel Code ausarten. Make It Easy wurde genau für diesen Fall gebaut. Listing 8 zeigt, wie man mit wenigen Zeilen Code einen Builder für einen Pkw erzeugen kann.

public class MakeItEasyTest {

    static abstract class Auto {
        private int anzahlReifen;

        public int getAnzahlReifen() {
            return anzahlReifen;
        }

        public void setAnzahlReifen(int anzahlReifen) {
            this.anzahlReifen = anzahlReifen;
        }
     }

     static class Pkw extends Auto {
        private int ps;

        public int getPs() {
            return ps;
        }

        public void setPs(int ps) {
            this.ps = ps;
        }
    }

    static class AutoBuilder {
        static final Property<Auto,Integer> reifen = newProperty();
        static final Property<Pkw,Integer> ps = newProperty();
        static final Instantiator<Pkw> Pkw = lookup -> {
            Pkw pkw = new Pkw();
            pkw.setAnzahlReifen(lookup.valueOf(reifen, 4));
            pkw.setPs(lookup.valueOf(ps, 50));
            return pkw;
        };
    }

    @Test
    public void makeItEasy() {
        Pkw einPkw = an(AutoBuilder.Pkw, with(4711, ps)).make();
        assertEquals(4, einPkw.getAnzahlReifen());
        assertEquals(4711, einPkw.getPs());
    }
}
Listing 8: Pkw-Builder mit Make It Easy

Builder lassen sich natürlich auch auf andere Arten, zum Beispiel mit der @Builder-Annotation von Project Lombok, erzeugen. Allerdings ist es mit Make It Easy möglich, Builder zu generieren, ohne dass die Klassen, die man erzeugen möchte, modifiziert werden müssen.

Log Collectors

Die nächste Bibliothek vereinfacht das Überprüfen von Log-Nachrichten. Üblicherweise müssen diese zwar nicht getestet werden. Ab und zu gibt es jedoch Anforderungen an das Logging, die über Tests abgedeckt werden sollten. Hierzu kann Log Collectors eingesetzt werden. Dabei werden JUnit in Version 4 und 5 sowie TestNG unterstützt und auch die Unterstützung für verschiedene Logging-Implementierungen ist mit Log4J2, Logback, java.util.logging und slf4j nahezu vollständig. Listing 9 zeigt den Einsatz in JUnit 4 für slf4j.

public class LogCollectorTest {
    Logger logger = LoggerFactory.getLogger(LogCollectorTest.class);

    @Rule
    public JUnit4LogCollector collector = new JUnit4LogCollector(logger);

    @Test
    public void logCollector() {
        logger.info("Hello, world!");
        assertThat(collector.getLogs())
            .containsExactly("Hello, world!");
    }
}
Listing 9: Log Collectors im Einsatz

Neben der Methode getLogs, die eine Liste aller geloggten Nachrichtentexte zurück liefert, gibt es mit getRawLogs noch die Möglichkeit, an die kompletten Log-Nachrichten zu gelangen. Hiermit lassen sich dann auch noch weitere Dinge, wie beispielsweise die Anzahl der vorhandenen Nachrichten für ein bestimmtes Log-Level, überprüfen.

Fazit

Neben einem Framework zum Ausführen von Tests und einer Bibliothek, um seine Erwartungen in Assertions zu formulieren, helfen einem viele weitere Bibliotheken, die Spezialaspekte abdecken.

Dieser Artikel hat hierzu mit EqualsVerifier, Awaitility, System Rules, Java Faker, Make It Easy und Log Collectors sechs Bibliotheken für unterschiedliche Einsatzzwecke vorgestellt. Natürlich gibt es noch viele weitere Bibliotheken, die erwähnenswert sind. Es lohnt sich also, mit offenen Augen durch den Bibliotheksdschungel auf der JVM zu wandern.

TAGS

Comments

Please accept our cookie agreement to see full comments functionality. Read more