“No amount of testing can prove a software right, a single test can prove a software wrong.”
Amir Ghahrai
Der Spring Boot WebClient ist der reaktive, nicht blockierende Alternative zum RestTemplate. Obwohl der WebClient für die Anwendung in reaktiven Anwendungen entworfen wurde, kann er auch in klassischen Anwendungen das RestTemplate ersetzen. Insbesondere die moderne Implementierung und die Fluent API sind es, die den Software Entwickler die Entscheidung für den WebClient leicht machen.
Im folgenden Beispiel wird mit einer vorher konfigurierten WebClient
Instanz eine REST Schnittstelle aufgerufen und das Ergebnis als Instanz der Klasse Person
zurückgegeben.
public Optional<Person> getAncestorById(Long id) { if (id < 1000L) { return getInternalAncestorById(id); } return webClient.get().uri("/ancestors/{id}", id).retrieve().bodyToMono(Person.class).blockOptional(); }
In diesem Beispiel wird ein klassischer Aufruf verwendet, bei dem der Client blockiert, bis das Ergebnis des Aufrufs bereit steht und als Optional
zurück geliefert wird.
Obwohl die Fluent API des WebClient ein Segen für die Entwicklung ist, stellt sie einen Fluch für die Unit Tests da. Um die obige Methode zu testen, muss entweder ein sehr komplexer Mock oder ein echter REST Service bereitgestellt werden.
Ein angenehmer Mittelweg ist die Verwendung der Bibliothek mock-server. Sie stellt u.a. einen lokalen Mock-Server für Unit Tests bereit. Einen ähnlichen Ansatz für E-Mail Protokolle mit der Bibliothek greenmail wurde im Beitrag Ab die Post vorgestellt.
Für Unit Tests mit der Bibliothek mock-server unter JUnit 5 reicht dabei folgende zusätzliche Maven Dependency.
<dependency> <groupId>org.mock-server</groupId> <artifactId>mockserver-junit-jupiter</artifactId> <version>5.11.1</version> </dependency>
Danach kann ein JUnit 5 Test mit der MockServerExtension
annotiert werden. Diese Annotation stellt die Klasse ClientAndServer
bereit, über die der lokale Mock-Server konfiguriert werden kann. Im folgenden Beispiel wird die ClientAndServer
Instanz über Parameter-Injektion im Konstruktor der Testklasse übergeben und in einem Attribut gespeichert. Damit steht sie für alle Test zur Verfügung.
@ExtendWith(MockServerExtension.class) class AncestorServiceTest { private AncestorService service; private final ClientAndServer mockServer; AncestorServiceTest(ClientAndServer mockServer) { this.mockServer = mockServer; } @BeforeEach void initialize() { service= new AncestorService(String.format("http://localhost:%s", mockServer.getPort())); mockServer.reset(); } @Test void getAncestorById() { mockServer.when(request().withPath("/ancestors/65536").withContentType(APPLICATION_JSON)) .respond(HttpResponse.response("{ \"id\": \"65536\", \"name\": \"Friedrich Magnus /Kayser/\" }").withContentType(APPLICATION_JSON)); Ancestor ancestor = service.getAncestorById(65536L); assertEquals("Friedrich Magnus /Kayser/", ancestor.getName()); } }
In der Testmethode getAncestorById
wird zuerst das Verhalten des REST Service definiert. Zu diesem Zweck wird der Mock-Server mit einer Expectation versehen, die den Anruf und die Antwort darauf definiert. In diesem Beispiel also ein Aufruf GET /ancestors/65536
und dem Content-Type application/json
. Also Antwort gibt es einen JSON Body und den impliziten HTTP Status Code 200 (OK)
.
Danach kann die zu testende Methode aufgerufen und die Antwort geprüft werden. Üblicherweise bleibt eine Expectation nach ihrer Erzeugung dauerhaft im Mock-Server gespeichert. Damit alle Tests voneinander unabhängig laufen, wird deshalb in der initialize
Methode der Mock-Server zurückgesetzt und damit alle existierenden Expectations entfernt.
Die Mock-Server Bibliothek gestattet auch die Verifikation der Aufrufe. Im obigen Beispiel wird der WebClient
nur für ID Werte \geq 1000 verwendet. Der nachfolgende Test verifiziert, dass für die Aufrufe service.getAncestorById(65536L)
und service.getAncestorById(42L)
nur ein einziger Aufruf an den Mock-Server gerichtet wird.
@Test void getAncestorByIds() { HttpRequest request = request().withPath("/ancestors/{id}").withPathParameters(Parameter.param("id", "\\d+")).withContentType(APPLICATION_JSON); mockServer.when(request).respond(response("{ \"id\": \"65536\", \"name\": \"Friedrich Magnus /Kayser/\" }").withContentType(APPLICATION_JSON)); service.getAncestorById(65536L); service.getAncestorById(42L); mockServer.verify(request, VerificationTimes.once()); }
Damit der Mock-Server Aufrufe mit beliebigen Path Parameter reagiert, wird hier ein regulärer Ausdruck verwendet. Am Ende des Tests wird mit mockServer.verify(request, VerificationTimes.once());
geprüft, ob es tatsächlich nur einen einzigen Aufruf gab.
Neben den Einsatz als Mock-Server für Unit Tests, kann die Bibliothek mock-server aber noch eine ganze Reihe weiterer Szenarien abdecken. Durch statisches und dynamisches Mocking, Proxying und Port-Forwarding kann der Mock-Server existierende Systeme partiell mocken, die Kommunikation für Testzwecke aufzeichnen, Reale Anfragen und Antworten für Tests modifizieren oder einzelne Dienste zum Debuggen auf lokale Services umleiten.