Nach sechs Jahren und bisher 37 Kolumnen ist meine Standardantwort, wenn ich auf das Schreiben von Artikeln angesprochen werde, dass ich das Schreiben selbst nicht so kompliziert finde, aber die Themenfindung von Mal zu Mal schwerer wird.

So ist es mir auch dieses Mal wieder passiert, dass ich an der Themenfindung verzweifelt bin und die Frist zur Abgabe gekommen ist. Einerseits möchte ich dem Namen der Kolumne „Der Praktiker“ gerecht werden, andererseits ist die Praxis nicht immer so spannend, wie wir uns das wünschen. Viele Themen meines Alltags sind zu spezifisch oder füllen thematisch keinen ganzen Artikel. Gleichzeitig sollte es mit Java oder der JVM zu tun haben und idealerweise noch nah am Code bleiben.

Aus diesem Grund habe ich diese Kolumne unter dem Motto „bitte eine gemischte Tüte Süßes für 50 Pfennig“ begonnen und habe erst am Ende meines Schreibprozesses festgestellt, dass alle behandelten Themen einen Bezug zum Testen von und mit Java haben und es somit wenigstens einen übergreifenden Slogan geben kann.

Lange Rede, kurzer Sinn. Auch wenn es dieses Mal keinen durchgehenden roten Faden gibt, wünsche ich viel Spaß beim Lesen und hoffe, dass zumindest ein hier behandeltes Thema von Interesse ist.

Testdaten mit Instancio generieren

In Tests, egal auf welcher Ebene und auch unabhängig von der konkreten Implementierung, nimmt schnell die Initialisierung von Testdaten einen großen Platz ein. Häufig interessieren uns dabei für den konkreten Test nur einige wenige Eigenschaften. Die restlichen sind irrelevant, obwohl diese verpflichtend sind. Eine Lösung, um das Set-up zu reduzieren, besteht darin, das Object-Mother-Muster einzusetzen. Eine solche Klasse hat die Aufgabe, konkrete, valide Objekte zu erzeugen. Um Überraschungen zu vermeiden, empfiehlt es sich, hierbei nur verpflichtende Werte beim Erzeugen zu setzen. Somit entsteht ein minimal valides Objekt (s. Listing 1).

public class InvoiceBuilder {
    public static Invoice invoice() {
        return new Invoice(
            new BigDecimal("42.0815"),
            new Person("Michael", 35));
    }
}
Listing 1: ObjectMother in Java

Dieser Ansatz bringt jedoch ein Problem mit sich. Ein so erzeugtes, valides Objekt enthält Werte für alle relevanten Attribute. Somit ist in einem Test, der sich auf diese hartcodierten Werte verlässt, nicht mehr sichtbar, welche Attribute nun konkret relevant sind. Eine mögliche Lösung für dieses Problem besteht darin, unsere ObjectMother mit dem FluentInterface-Muster zu kombinieren. Die Tests können dann wie in Listing 2 aussehen.

@Test
void somebody_shouldDoSomething_whenInvoiceIsAbove100() {
    // given
    final var invoiceAbove100 = invoice()
        .amount(new BigDecimal("100.01"))
        .build();

    // when/then
}
Listing 2: Nutzung von ObjectMother mit Fluent-Interface im Test

Obwohl wir nun in unseren Tests sehen können, welche Attribute relevant sind, schleicht es sich mit der Zeit ein, dass wir uns doch auf einen derhartcodierten Werte verlassen und nicht bedacht haben, dass dieses Attribut für den Test doch eigentlich relevant ist. So entsteht über die Zeit eine hohe Abhängigkeit zu den konkreten Werten, und unsere Tests sind leider doch nicht so leicht verständlich wie gewünscht.

Um nun auch noch dieses Problem zu lösen, bietet es sich an, die verpflichtenden Attribute bei der Erzeugung, innerhalb unserer ObjectMother, mit zufälligen Werten zu füllen. Listing 3 zeigt, wie das, mit Unterstützung von Apache Commons Lang, aussehen kann.

public class InvoiceBuilder {
    private final Invoice invoice;

    private InvoiceBuilder(Invoice invoice) {
        this.invoice = invoice;
    }

    public InvoiceBuilder amount(BigDecimal amount) {
        return new InvoiceBuilder(new Invoice(
            amount,
            invoice.recipient()));
    }

    public InvoiceBuilder recipient(Person recipient) {
        return new InvoiceBuilder(new Invoice(
            invoice.amount(),
            recipient));
    }

    public Invoice build() {
        return invoice;
    }

    public static InvoiceBuilder invoice() {
        return new InvoiceBuilder(new Invoice(
            BigDecimal.valueOf(nextDouble()),
            new Person(randomAlphabetic(1, 255), nextInt(14, 120))));
    }
}
Listing 3: ObjectMother mit zufälligen Werten

Anstelle von Apache Commons Lang hätten wir auch das bereits von mir in „Java-Bibliotheken für den Einsatz in Tests“ vorgestellte Java Faker nutzen können. Doch auch hiermit bleibt uns eine Menge manuelle Arbeit. Wir müssen die ObjectMother-Klassen schreiben und je nach Anzahl von Attributen und Modellklassen kann das eine ganze Menge sein.

Genau hier setzt Instancio an. Instancio ermöglicht es uns, mit wenigen Zeilen Code ein komplettes Objekt mit Werten zu befüllen. Wir können dabei, siehe Listing 4, pro Attribut festlegen, nach welchen Regeln der Wert generiert wird.

public static InvoiceBuilder instancioInvoice() {
    var invoice = Instancio.of(Invoice.class)
        .generate(field("amount"),
            gen -> gen.math().bigDecimal().min(ONE).max(TEN))
        .create();
    return new InvoiceBuilder(invoice);
}
Listing 4: Verwendung von Instancio zur Erzeugung von Objekten

Möchten wir zusätzlich auf die Strings zur Angabe von Feldnamen verzichten, können wir mittels Annotation Processor ein Metamodell generieren und anschließend, siehe Listing 5, im Code nutzen. Zudem bringt Instancio noch direkte Unterstützung für JUnit 5 mit. Diese bietet uns noch zwei weitere Vorteile.

@InstancioMetamodel(classes = {Invoice.class, Person.class})
public class InvoiceBuilder {
    // ...

    public static InvoiceBuilder instancioMetaModelInvoice() {
        var invoice = Instancio.of(Invoice.class)
            .generate(Invoice_.amount,
                gen -> gen.math().bigDecimal())
            .create();
        return new InvoiceBuilder(invoice);
    }
}
Listing 5: Verwendung des Instancio-Metamodells

Zum Ersten enthalten nun die Ausgaben bei einem fehlschlagenden Test die Nummer des Seeds, der für die Generierung der Zufallsdaten verwendet wurde. Wir haben nun die Möglichkeit, diese Nummer mittels @Seed-Annotation am Test, siehe Listing 6, oder bei Erzeugung der Instancio-Instanz anzugeben. Anschließend erhalten wir bei jedem Lauf exakt dieselben zufällig generierten Werte und können den Testlauf somit reproduzieren.

@ExtendWith(InstancioExtension.class)
class InstancioTests {

    @Test
    @Seed(1774022773)
    void instancio_shouldCreateRandomInvoice() {
        // given
        final var invoice = instancioInvoice()
            .build();

        // then
        assertEquals(
            new BigDecimal("8.4170807559267"),
            invoice.getAmount());
    }
}
Listing 6: Reproduzierbarer Testlauf mittels @Seed-Annotation

Zum Zweiten enthält die JUnit 5 Unterstützung von Instancio mit der @InstancioSource-Annotation eine fertige Lösung für parametrisierte Tests (s. Listing 7).

@ParameterizedTest
@InstancioSource(Invoice.class)
void instancioSource_shouldSupplyRandomInstance(Invoice invoice) {
    // then
    assertNotNull(invoice.getAmount());
}
Listing 7: Parametrisierter Test mit @InstancioSource

Eintauchen mit Deep Dive

Auch wenn JUnit 5 bereits Methoden zur Überprüfung, Assertion, von Werten mitbringt, ist es – mittlerweile – üblich, hierzu eine eigene Bibliothek zu nutzen. Die beiden Bibliotheken mit der höchsten Verbreitung sind dabei AssertJ und Hamcrest. Einen detaillierten Vergleich dieser beiden gab es bereits 2015 im Artikel „Ein Vergleich von Hamcrest, AssertJ und Truth“ von Marc Philipp hier im Heft.

Der größte und auch sichtbarste Unterschied besteht darin, dass bei Hamcrest die Assertions mittels statischer Methoden verschachtelt werden, wohingegen AssertJ auf ein Fluent-API setzt. Listing 8 zeigt diesen Unterschied.

@Test
void assertj() {
    var person = new Person("Michael", 35);
    Assertions.assertThat(person.name())
        .hasSize(7)
        .containsPattern("[A-Za-z]*");
    Assertions.assertThat(person.age())
        .isBetween(12, 99);
}

@Test
void hamcrest() {
    var person = new Person("Michael", 35);
    MatcherAssert.assertThat(person.name(), is(allOf(
        hasLength(7),
        matchesRegex("[A-Za-z]*"))));
    MatcherAssert.assertThat(person.age(), is(allOf(
        greaterThanOrEqualTo(12),
        not(greaterThan(100)))));
}
Listing 8: Nutzung von AssertJ und Hamcrest

Beide Ansätze funktionieren und sind sich dabei sehr ähnlich. Hamcrest ist etwas flexibler, beispielsweise bei der Negation von Prüfungen, aber es ist auch etwas schwerer, die passenden statischen Methoden zu finden, wenn wir nicht wissen, wonach wir suchen sollen. Bei AssertJ müssen wir uns hingegen nur den Einstiegspunkt merken, die möglichen Überprüfungen ergeben sich anschließend durch die zur Verfügung stehenden Methoden und sind mittels Code-Vervollständigung schnell zu sehen. Dafür lassen sich diese Überprüfungen nicht bei Nutzung kombinieren.

Genau an dieser Stelle setzt Deep Dive an. Wie AssertJ kommt es mit einer Fluent-API daher, bietet uns aber, wie Hamcrest, die Möglichkeit, Überprüfungen mittels not() zu negieren. Zudem ist es uns möglich, mittels back() während der laufenden Überprüfung eines verschachtelten Objekts wieder eine, oder mehrere, Ebenen hochzuspringen und dort weitere Überprüfungen durchzuführen. Listing 9 zeigt, wie die Verwendung von Deep Dive aussieht.

@Test
void deepDive() {
    var person = new Person("Michael", 35);
    ExpectThat.expectThat(person.name())
        .length().equal(7)
        .back()
        .matches("[A-Za-z]*");
    ExpectThat.expectThat(person.age())
        .positive()
        .greaterEq(12)
        .not().greater(100);
}
Listing 9: Verwendung von Deep Dive

Überprüfen von JSON

Wo wir gerade schon beim Thema Überprüfungen sind. Gerade wenn wir Dateiformate wie JSON oder XML überprüfen wollen, lohnt sich der Einsatz von spezifisch hierauf zugeschnittenen Bibliotheken. Natürlich können wir ein solches Format auch in einen String konvertieren und diesen vergleichen, allerdings riskieren wir hierbei, dass unser Test rot wird, wenn sich die Formatierung oder Reihenfolge ändert, und auch die Fehlermeldungen, die wir bei einem fehlschlagenden String-Vergleich erhalten, sind, wie in Listing 10, in der Regel wenig hilfreich.

org.opentest4j.AssertionFailedError:
expected: <{
"a": 123,
"b": true,
"c": "Michael"
}> but was: <{
"a": 123,
"b": true,
"c":"Michael"
}>
    at ....stringComparisonErrors(JsonUnitTests.java:10)
Listing 10: Fehlermeldung eines JSON-String-Vergleichs

Für JSON können wir hierfür zu JsonUnit greifen. Entweder wir nutzen das von JsonUnit mitgebrachte, und an AssertJ angelehnte, assertThatJson (s. Listing 11) oder wir nutzen Matcher in Kombination mit Hamcrest (s. Listing 12).

@Test
void assertj() {
    assertThatJson(someJson)
        .isEqualTo("{\"a\":123,\"b\":true,\"c\":\"Michael\"}");
}
Listing 11: AssertJ basierte Verwendung von JsonUnit
@Test
void hamcrest() {
    assertThat(someJson,
        jsonEquals("{\"a\":123,\"b\":true,\"c\":\"Michael\"}"));
}
Listing 12: Nutzung von JsonUnit-Hamcrest-Matchern

Sehr angenehm finde ich dabei vor allem, dass JsonUnit es uns erlaubt, bei dem für die Prüfung benötigten JSON etwas ungenauer sein zu können. Listing 13 zeigt, dass wir auf die Anführungszeichen um Schlüssel verzichten können und einfache Anführungszeichen für String-Werte ausreichen. Beides macht die Tests lesbarer, weil wir nicht ständig Anführungszeichen mit einem Backslash escapen müssen.

@Test
void relax() {
    assertThatJson(someJson)
        .isEqualTo("{ a: 123, b: true, c: 'Michael' }");
}
Listing 13: Verwendung von relaxtem JSON mit JsonUnit

Um Tests noch lesbarer zu machen, können wir die Überprüfung mittels Optionen noch erweitern. Dies ermöglicht es uns, beispielsweise nur ein spezifisches Subset von Feldern zu überprüfen und nicht jedes Mal das ganze Objekt komplett prüfen zu müssen (s. Listing 14).

@Test
void options() {
    assertThatJson(someJson)
        .when(IGNORING_VALUES, IGNORING_EXTRA_FIELDS)
        .isEqualTo("{ c: 'Stefan' }");
}
Listing 14: JsonUnit-Optionen

Wenn uns diese Optionen noch nicht reichen, können wir noch zusätzliche spezielle Schlüsselwörter einsetzen oder sogar eigene Submatcher registrieren und nutzen. In Listing 15 nutzen wir mehrere der Schlüsselwörter und einen eigenen Matcher.

@Test
void keywordsAndMatcher() {
    assertThatJson(someJson)
        .withMatcher("threeDigits", allOf(
            greaterThan(BigDecimal.valueOf(99)),
            lessThan(BigDecimal.valueOf(1000))))
        .isEqualTo("""
            {
                a: '${json-unit.matches:threeDigits}',
                b: '${json-unit.any-boolean}',
                c: '${json-unit.regex}[A-Z][a-z]+'
            }
            """);
}
Listing 15: JsonUnit-Schlüsselwörter und eigene Matcher

Zu guter Letzt haben wir auch noch die Möglichkeit, mittels JsonPath direkt bestimmte Elemente oder Werte zu überprüfen. Ist man im Spring-Umfeld unterwegs, gibt es außerdem JsonUnit-Module, um dieses in Tests für Spring MVC, das RestTemplate oder den WebClient bequem nutzen zu können.

Integrationstests für HTTP-Clients

In der Mitte der Testpyramide befinden sich Integrationstests. Diese testen im Gegensatz zu Unittests mehrere unserer Module im Zusammenspiel und beziehungsweise oder die Integration mit einer Schnittstelle außerhalb unserer Anwendung.

Bibliotheken, um HTTP-basierte Schnittstellen anzubinden, habe ich letztes Jahr bereits vorgestellt. Häufig kapseln wir eine solche Bibliothek innerhalb unserer Anwendung mit einer eigenen Klasse, welche die konkrete Anbindung realisiert. Um diese Klasse zu testen, haben wir nun zwei Möglichkeiten. Entweder wir mocken die eingesetzte HTTP-Bibliothek oder wir schreiben einen Integrationstest. Da externe Klassen nach Möglichkeit nicht gemockt werden sollten, halte ich hier den Integrationstest für die bessere Wahl. Hierzu können wir Hoverfly, Jadler oder Wiremock nutzen, um ad hoc in unserem Test einen HTTP-Server zu starten und auf spezifische Requests mit einer passenden Response zu antworten.

Jadler (s. Listing 16) bietet hierzu eine vom Testframework unabhängige Fluent-API an, bei der wir beim Matchen von Requests allerdings auch Hamcrest-Matcher nutzen können.

@Test
void test() {
    onRequest()
        .havingMethodEqualTo("POST")
        .havingQueryString(containsString("bar=baz"))
        .havingBody(jsonEquals("{ a: 123 }"))
        .respond()
        .withStatus(200)
        .withBody("{ \"result\": \"Success\" }");

    // ...
}
Listing 16: Verwendung von Jadler in Tests

Hoverfly geht noch ein Stück weiter. Ähnlich wie Jadler können wir Hoverfly in unserem Test nutzen, um einen HTTP-Server zu starten. Für die Nutzung von JUnit 4 oder 5 steht uns aber bereits eine fertige Integration zur Verfügung (s. Listing 17). Wie wir sehen können, brauchen wir uns selbst nicht mehr um das Starten oder Stoppen zu kümmern und wir können uns voll auf die Definition von erwartetem Request und zu gebender Response konzentrieren.

@Test
void test(Hoverfly hoverfly) {
    hoverfly.simulate(dsl(
        service("http://test")
            .post("/foo")
            .queryParam("bar", "baz")
            .header("version", "1.0")
            .body(equalsToJson("{ \"a\": 123 }"))
            .willReturn(response()
                .status(200)
                .body("{ \"result\": \"Success\" }"))));

    // ...

    hoverfly.verifyAll();
}
Listing 17: JUnit 5 Integration für Hoverfly

Darüber hinaus können wir Hoverfly auch unabhängig von Java nutzen und Stand-alone als Server starten. Dies bietet sich vor allem dann an, wenn wir Hoverfly als Proxy zwischen uns und das wirkliche externe System setzen und den Capture-Modus anstellen. In diesem zeichnet Hoverfly unsere Requests und die gegebenen Antworten auf. Im Anschluss können wir Hoverfly anweisen, diese Aufnahme wieder abzuspielen.

Hoverfly erlaubt außerdem die Definition von Zustand und die Konfiguration während der Laufzeit via HTTP-Schnittstelle. Somit könnten wir in einem End-to-End-Test aus dem Testframework heraus die Interaktion mit dem gemockten externen System spezifizieren und müssen uns nicht auf eine statische Konfiguration verlassen.

Wiremock ähnelt vom Featureset her Hoverfly, bringt aber natürlich eine eigene Java-API mit.

Conclusion

Wir haben uns in diesem Artikel einen gemischten bunten Strauß an Themen und Bibliotheken für das Testen von und mit Java angeschaut. Dabei haben wir gesehen, wie die Implementierung einer ObjectMother zur Testdatengenerierung mittels Instancio vereinfacht werden kann. Als weiteren Vorteil erhalten wir dabei zufällig generierte Werte und verbessern somit nebenbei die Verständlichkeit und Stabilität unserer Tests.

Anschließend haben wir mit Deep Dive und JsonUnit zwei Bibliotheken betrachtet, die uns bei der Überprüfung von Werten in Tests unterstützen können. Deep Dive ist eine Alternative zu den beiden verbreiteten Bibliotheken AssertJ und Hamcrest, die versucht, das Beste aus beiden Ansätzen zu vereinen. JsonUnit hingegen unterstützt uns bei Überprüfungen von JSON. Hierzu haben wir die verschiedenen Stile und Features kennengelernt.

Zuletzt haben wir mit Hoverfly, Jadler und Wiremock noch drei Bibliotheken kennengelernt, die uns das Schreiben von Integrationstests für HTTP-Interaktionen ermöglichen. Alle drei nutzen hierzu eine sehr ähnliche API zur Definition von Requests und Responses, unterscheiden sich aber in den darüber hinausgehenden Features.