© Excellent backgrounds/Shutterstock.com
Testen von verteilten REST-Services mit Eventual Consistency

Alle Tests unter einen Hut kriegen


Unit-Tests reichen nicht aus, um verteilte REST-Services zu testen und zu beschreiben. Neben funktionalen Whitebox-Tests ist es für eine bessere Qualitätssicherung wünschenswert, wenn sich die offizielle Spezifikation eines REST-APIs auch in den Blackboxtests widerspiegelt. Dieser Artikel stellt Anforderungen und Teststrategien für verteilte und skalierbare Systeme vor und gibt einen praxisnahen Einblick, wie sich solche Systeme, z. B. im Fall eines REST-API, mit Spock, REST Assured und Awaitility testen lassen.

Der Aufruf eines auf REST basierenden Diensts mit Java ist im Vergleich zu anderen Sprachen mit dynamischer Typisierung langatmig und bisweilen umständlich. Zur Vereinfachung bietet REST Assured [1] dem Programmierer eine „sprechende Schnittstelle“ [2] mit Methodenketten, über die alle HTTP-Anfragemethoden angesprochen, Pfad- oder Anfrageparameter gesetzt sowie HTTP-Header-Werte und HTTP-Nachrichtenrümpfe übertragen werden können. Zusätzlich sind auch die Matcher von Hamcrest in REST Assured integriert [3]. Hiermit lassen sich die Antworten, die ein REST-Service verschickt, auf einfache Weise mit den erwarteten Werten vergleichen. REST Assured bietet Methoden an, um einfach auf die verschiedenen Felder der Antwort zuzugreifen. Insgesamt liegt der Vorteil dieser Bibliothek in der guten Lesbarkeit der Aufrufe (Listing 1).

Listing 1: Anfragemethoden

given() .param("a", "b") .header("c", "d") when() .get("/zahl") .then() .statusCode(200) .body("x.y", equalTo("42"));

Awaitility für ein wenig Wartezeit

Eine zusätzliche Schwierigkeit von REST-basierten Diensten ist die Tatsache, dass die Systeme nicht notwendigerweise hundertprozentig synchron arbeiten, z. B. aufgrund von Eventual Consistency (siehe Kasten). Awaitility löst dieses Problem sehr elegant, indem es dem Programmierer ermöglicht, eine bestimmte Zeit auf das Eintreten eines gewünschten Zustands zu warten. Die zu überprüfende Bedingung wird von der Bibliothek mehrmals in einem Zeitintervall abgefragt, bis sie entweder zutrifft oder die maximale Wartezeit überschritten wurde. Die verschiedenen Parameter, wie maximale Wartezeit, Zeit zwischen den einzelnen Aufrufen oder die Wartezeit vor dem ersten Test lassen sich mit einer gut lesbaren Programmierschnittstelle konfigurieren. Die Bedingungen, die Awaitility überprüfen kann, lassen sich auf viele verschiedene Weisen definieren. Die einfachste Möglichkeit ist es, ein Callable für einen boole­schen Rückgabewert zu implementieren. Dieser Ansatz lässt sich mit einem Ham­crest Matcher verfeinern, was den Test deutlich lesbarer macht (Listing 2).

Eventual Consistency

Das Modell der Eventual Consistency ist vor allem in verteilten Systemen mit vielen Anfragen und hohen Datenmengen ein häufiges Muster. Durch viele Daten, Anfragen und verteilte Knoten hat sich die Wahrscheinlichkeit des Auftretens bestimmter Konflikte deutlich erhöht (Nebenläufigkeit oder Netzwerk). Bei der Konfliktlösung kommt die Aussage des CAP-Theorems zum Tragen, dass in einem partitionierten System im Fehlerfall entweder die Verfügbarkeit der Daten oder deren Konsistenz garantiert werden kann, aber nicht beide Eigenschaften zugleich.

Systeme mit Eventual Consistency legen den Schwerpunkt auf hohe Verfügbarkeit, garantieren aber dennoch, dass entgegengenommene Änderungen irgendwann verarbeitet werden: Nachdem ein Client ein Update abgesetzt hat, dauert es eine unbestimmte Zeit, bis diese Änderung auch im System sichtbar ist. Das bedeutet, wenn zwischenzeitlich ein lesender Zugriff stattfindet, wird eine inkonsistente Sicht auf das System gegeben. Das ist insbesondere bei der Applikationslogik im Hinterkopf zu behalten, bei der Schreib- und Leseoperationen, die nicht unabhängig voneinander sind, in einem kurzen Zeitraum hintereinander folgen. Das ist beispielsweise der Fall, wenn eine Schreiboperation auf Basis einer vorhergehenden Leseoperation ausgeführt wird.

In dem Anwendungsbeispiel wird diese Problematik ausgeblendet, indem der Counter-Service nur die kommutativen Operationen Addition und Subtraktion anbietet. Dadurch ist sowohl die Reihenfolge der Serviceaufrufe als auch der zwischenzeitliche Ursprungswert irrelevant, der jeweilige Counter nimmt immer den gleichen Ergebniswert an. Für eine weiterführende Lektüre bietet sich als Einstieg Werner Vogels Blog an [8].

Listing 2: Callable implementieren

private Callable<Integer> zaehler() { return new Callable<Integer>() { public Integer call() throws Exception { return zahlRepository.getCount(); } }; } await().until(zaehler(), equalTo(42));

Eine weitere Verfeinerung sind die sogenannten Proxy-basierten Bedingungen. Bei ihnen wird keine Implementierung eines Callables benötigt:

await().untilCall(to(zahlRepository).getCount(), equalTo(3));

Weiterhin können bestimmte Werte in Feldern von Objekten, in atomaren Strukturen (z. B. AtomicInteger) oder in Lambdaausdrücken von Java 8 erwartet werden.

Vorteile von Groovy

Üblicherweise verwendet man für Unit-Tests entweder JUnit oder TestNG zusammen mit Matchern und einem Mocking-Framework, z. B. Mockito, EasyMock oder PowerMock. Hierbei gestaltet es sich bei mittleren bis großen Projekten mitunter schwer, lesbare und leicht verständliche Tests zu schreiben. Das Konstruieren der Mock-Objekte oder auch der erwarteten Rückgabewerte für getestete Methoden nimmt oft viele Zeilen Code in Anspruch, und die eigentliche Intention eines Tests wird unübersichtlich. An dieser Stelle kann sich eine Skriptsprache wie Groovy mit nativer Unterstützung für Mocks und Stubs, aber vor allem auch durch die native Syntax für Javas Collection-Typen als hilfreich erweisen [4]. Im Zusammenspiel mit Typzwang über den as-Operator sind diese Features der Lesbarkeit von Tests sehr zuträglich. Java-POJOs lassen sich z. B. sowohl via impliziter als auch expliziter Coercion über Listen konstruieren:

def patch = ["add", "path", 42] as Patch // explicit coercion Patch patch = ["add", "path", 42] // implicit coercion def patch = new Patch("add", "path", 42) // default invocation

Über den gleichen Mechanismus lassen sich Mocks definieren (Listing 3).

Listing 3: Mocks definieren

// Simple Java class public class CounterService { ... public long getValue(UUID id) { return counters.get(id); } } // Gr...

Neugierig geworden? Wir haben diese Angebote für dich:

Angebote für Teams

Für Firmen haben wir individuelle Teamlizenzen. Wir erstellen Ihnen gerne ein passendes Angebot.

Das Library-Modell:
IP-Zugang

Das Company-Modell:
Domain-Zugang