Unit Tests mit dem Spring Boot WebClient

“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.