Bessere Integrationstests mit WireMock

Die Anbindung an externe HTTP Services richtig testen

Ronny Bräunlich

©Shutterstock / eamesBot

Mit der verstärkten Verbreitung von Microservices steigen auch die Abhängigkeiten zwischen den verschiedenen Anwendungen. Aufgrund der eigenen Datenhoheit müssen Services auf die ein oder andere Art Informationen miteinander austauschen, um ihr (Business-)Ziel zu erreichen. Häufig ist REST bzw. HTTP dafür das Mittel der Wahl. Egal, ob man der klassischen Testpyramide oder einem der neueren Ansätze wie der Testhonigwabe [1] folgt: Irgendwann während der Entwicklung sollte die Kommunikation mit Integrationstests geprüft werden. Das ist der Punkt, an dem WireMock [2] ins Spiel kommt.

Als Beispiel für diesen Artikel soll uns ein einfaches System, bestehend aus zwei Services, dienen, wobei sich der zweite unserer Kontrolle entzieht. Abbildung 1 stellt das Ganze stark vereinfacht dar.

Abb. 1: Beispielsystem

Abb. 1: Beispielsystem

Unsere Anwendung bietet eine REST-Schnittstelle an, über die sich Daten abfragen lassen. Diese befinden sich aber nicht in der eigenen Datenhaltung, sondern müssen von einem weiteren, externen Service geholt werden. Unsere Implementierung könnte ein Anti-Corruption-Layer im Sinne von Domain-driven Design darstellen, die externen Daten aufbereiten oder die geholten Daten für Berechnungen benötigen (z. B. aktuelle Wechselkurse). Die Möglichkeiten sind zahlreich.

Um unser Beispiel nicht abstrakt halten zu müssen, wird der externe Service durch das Chuck Norris Fact API [3] dargestellt. Der Web Service bietet eine gut dokumentierte Schnittstelle, die uns im JSON-Format Fakten über den allseits bekannten Schauspieler liefert. Das gesamte Beispiel basiert auf Spring, kann aber leicht auf andere Frameworks übertragen werden.

Wir verwenden einen einfachen REST Controller namens ChuckNorrisFactController als API für unseren Microservice. Neben den Businessklassen gibt es den ChuckNorrisService. Dieser kapselt den Aufruf zum externen API. Er verwendet das Spring RestTemplate, um den HTTP-Aufruf auszuführen. Es wird ein zufälliger Fakt abgerufen und das erhaltene JSON geringfügig verändert. Wir ignorieren den Status, der im gleichnamigen Feld der Antwort steckt und geben einfach den Wert des Felds joke zurück. Listing 1 zeigt den Controller und Listing 2 die einfache Implementierung des Service, die wir im Laufe des Artikels verbessern werden. Das komplette Beispiel ist auf GitHub zu finden [4].

@RestController
public class ChuckNorrisFactController {
 
  private final ChuckNorrisService chuckNorrisService;
 
  @Autowired
  public ChuckNorrisFactController(ChuckNorrisService chuckNorrisService) {
    this.chuckNorrisService = chuckNorrisService;
  }
 
  @GetMapping(path = "/fact", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
  public ChuckNorrisFact getFact(){
    return chuckNorrisService.retrieveFact();
  }
}
@Service
public class ChuckNorrisService {
 
  static final ChuckNorrisFact BACKUP_FACT = new ChuckNorrisFact(-1L, ""It works on my machine" always holds true for Chuck Norris.");
 
  private final RestTemplate restTemplate;
  private final String url;
 
  @Autowired
  public ChuckNorrisService(RestTemplate restTemplate, @Value("${fact.url}") String url) {
    this.restTemplate = restTemplate;
    this.url = url;
  }
 
  public ChuckNorrisFact retrieveFact() {
    ResponseEntity<ChuckNorrisFactResponse> response = restTemplate.getForEntity(url, ChuckNorrisFactResponse.class);
    return Optional.ofNullable(response.getBody()).map(ChuckNorrisFact-Response::getFact).orElse(BACKUP_FACT);
  }
 }

Was oft zu sehen ist, sind Tests, die das RestTemplate mocken und eine vorgefertigte Antwort zurückgeben. Neben den üblichen Unit-Tests zur Überprüfung der Erfolgsfälle gibt es meist einen Test, der den Fehlerfall abdeckt, also mit einen 4xx- oder 5xx-Statuscode antwortet. Listing 3 zeigt diesen typischen Testfall mit Hilfe von Mockito [5] Mocks.

@Test
public void shouldReturnBackupFactInCaseOfError() {
  String url = "http://localhost:8080";
  RestTemplate mockTemplate = mock(RestTemplate.class);
  ResponseEntity<ChuckNorrisFactResponse> responseEntity = newResponseEntity<>(HttpStatus.SERVICE_UNAVAILABLE);
  when(mockTemplate.getForEntity(url, ChuckNorrisFactResponse.class)).thenReturn(responseEntity);
  var service = new ChuckNorrisService(mockTemplate, url);
 
  ChuckNorrisFact retrieved = service.retrieveFact();
 
  assertThat(retrieved).isEqualTo(ChuckNorrisService.BACKUP_FACT);
}

Test und Implementierung erfüllen die meisten Anforderungen, die man an den Code haben könnte. Alles ist mehr oder weniger lesbar und wir decken den Fehlerfall ab. Die ResponseEntity gibt einen 503-Fehlercode zurück und die Anwendung stürzt nicht ab. Der Nutzer erhält zwar immer denselben Fakt, aber unser Service wird durch externe Fehler nicht beeinträchtigt. Alle Tests sind grün und unsere Anwendung ist einsatzbereit.

Doch leider funktioniert Springs RestTemplate so nicht. Die Methodensignatur von getForEntity gibt uns einen kleinen Hinweis auf das tatsächliche Verhalten. Dort steht, dass eine RestClientException geworfen werden kann. Hier unterscheidet sich das gemockte RestTemplate von der tatsächlichen Implementierung. Wir werden niemals eine ResponseEntity mit einem 4xx- oder 5xx-Statuscode erhalten. Das RestTemplate wird eine Unterklasse von RestClientException werfen. Wenn wir einen Blick auf die Klassenhierarchie in Abbildung 2 werfen, erhalten wir einen guten Eindruck davon, welche Exception möglicherweise geworfen wird. Sehen wir also, wie wir den Test und unsere Implementierung verbessern können.

Abb. 2: Klassenhierarchie der RestClientException

Abb. 2: Klassenhierarchie der RestClientException

WireMock eilt zur Rettung

WireMock simuliert Web Services, indem es einen Mock-Server startet und Antworten zurückgibt, die vorher konfiguriert wurden. Der Einstieg in Tests mit WireMock ist relativ einfach und das Mocking von Anfragen dank einer guten DSL ebenfalls. Für JUnit 4 gibt es eine WireMockRule, die beim Starten und Stoppen des Servers hilft. Für JUnit 5 muss dies aktuell selbst gemacht werden. Im Beispielprojekt befindet sich der ChuckNorrisServiceIntegrationTest. Es handelt sich um einen Spring-Boot-Test auf Basis von JUnit 4. Der wichtigste Teil darin ist die ClassRule:

  @ClassRule
public static WireMockRule wireMockRule = new WireMockRule();

Wie bereits erwähnt, startet und stoppt sie den WireMock-Server. Die Regel kann auch als normale @ Rule verwendet werden, um den Server für jeden einzelnen Test zu starten und zu stoppen. In unserem Fall ist das nicht nötig. Im Test gibt es verschiedene configureWireMockFor…-Methoden. Listing 4 zeigt die Methoden für den Erfolgs- und den Fehlerfall. Beide enthalten die Anweisungen an WireMock, wann welche Antwort zurückgegeben werden soll. Die Aufteilung der WireMock-Konfiguration in mehrere Methoden ist ein Ansatz, der sich in meinen Tests bewährt hat. Die einzelnen Tests rufen dann die Methode auf, die sie benötigen. So ist immer klar zu sehen, was WireMock antworten soll. Natürlich könnte auch alles zusammen in einer großen @Before-Methode konfiguriert werden.

public void configureWireMockForOkResponse(ChuckNorrisFact fact) throws JsonProcessingException {
  ChuckNorrisFactResponse chuckNorrisFactResponse = new ChuckNorrisFactResponse("suc-cess", fact);
  stubFor(get(urlEqualTo("/jokes/random"))
    .willReturn(okJson(OBJECT_MAPPER.writeValueAsString(chuckNorrisFactResponse))));
}
private void configureWireMockForErrorResponse() {
  stubFor(get(urlEqualTo("/jokes/random"))
    .willReturn(serverError()));
}

Alle Methoden in Listing 4 werden statisch von com.github.tomakehurst.wiremock.client.WireMock importiert. Für den Erfolgsfall konfigurieren wir einen Stub, der bei einem HTTP GET auf den Pfad /jokes/random mit einem JSON-Objekt antwortet. Die okJson()-Methode ist nur eine Abkürzung für eine Antwort mit dem Statuscode 200 und JSON-Inhalt. Um unser Antwortobjekt in JSON umzuwandeln, hilft uns der Jackson [6] ObjectMapper. Wie zu sehen ist, ist die Konfiguration für den Fehlerfall noch einfacher. Dank der WireMock DSL kann auf einen Blick erfasst werden, was für ein Aufruf erwartet wird und wie er beantwortet werden soll.

Wenn wir nun den Integrationstest mit WireMock starten, sehen wir, dass unsere Implementierung den Fehlerfall wirklich nicht abdeckt und der Test aufgrund der vom RestTemplate geworfenen Exception fehlschlägt. Folglich müssen wir unseren Code um Exception Handling erweitern, wie in Listing 5 zu sehen.

public ChuckNorrisFact retrieveFact() {
  try {
    ResponseEntity<ChuckNorrisFactResponse> response = restTemplate.getForEntity(url, ChuckNorrisFactResponse.class);
    return Optional.ofNullable(response.getBody()).map(ChuckNorrisFactResponse::get-Fact).orElse(BACKUP_FACT);
  } catch (HttpStatusCodeException e){
    return BACKUP_FACT;
  }
}

Damit ist bereits gezeigt, wie WireMock für einfache Anfrage-Antwort-Tests verwendet werden kann. Mit Hilfe der DSL konfigurieren wir, welche Anfragen erwartet und welche Antworten zurückgeliefert werden sollen. Sollte eine Anfrage kommen, für die keine Antwort konfiguriert wurde, teilt uns WireMock die erhaltenen Parameter mit und warum sie nicht passten. So kann entweder der Test oder der Code angepasst werden.

Ein dynamischer Port für WireMock

Nachdem man eine Weile mit WireMock und Spring gearbeitet hat, und im Besonderen, wenn die Integrationstests in einer Cloud-Umgebung ausgeführt werden, tritt ein Problem auf, für das es bisher noch keine WireMock-native Lösung gibt. Um WireMock mit unserem RestTemplate ansprechen zu können, müssen wir den Port kennen. Aber wenn wir einen Port hart codieren, kann es sein, dass er in der Cloud-Umgebung, die sich meist unserer Kontrolle entzieht, belegt ist. Abhilfe schafft hier ein Spring ApplicationContextInitializer.

Schauen wir zurück auf Listing 2. Dem Service wird der URL zum Chuck Norris Fact API über die @Value-Annotation injiziert. Das bedeutet, dass wir den URL über eine @TestPropertySource-Annotation überschreiben können. Allerdings müssen wir den dynamischen Port mit den Test-Properties zusammen bekommen. Genau hier hilft uns der ApplicationContextInitializer. Wir fügen dem Anwendungskontext den dynamisch zugewiesenen Port hinzu und können dann im Beispiel über die Property ${wiremock.port} darauf verweisen. Der einzige Nachteil ist, dass nun eine @ClassRule verwendet werden muss. Andernfalls kann nicht auf den Port zugegriffen werden, bevor die Spring-Anwendung initialisiert wurde. Die ersten Zeilen des angepassten Tests mit dem Initializer als innere Klasse sind in Listing 6 zu sehen.

  @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@RunWith(SpringRunner.class)
@ContextConfiguration(initializers = ChuckNorrisServiceIntegrationTest.WireMockPortInitializer.class)
@TestPropertySource(properties = {
    "fact.url=http://localhost:${wiremock.port}/jokes/random"
})
public class ChuckNorrisServiceIntegrationTest {
 
  private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
 
  @ClassRule
  public static WireMockRule wireMockRule = new WireMockRule();
 
  @Autowired
  private ChuckNorrisService service;
 
  static class WireMockPortInitializer implements ApplicationContextInitializer<Config-urableApplicationContext> {
 
    @Override
    public void initialize(ConfigurableApplicationContext configurableApplicationContext) {
      TestPropertyValues.of("wiremock.port:" + wireMockRule.port())
          .applyTo(configurableApplicationContext);
    }
  }
...

Timeouts

WireMock bietet viel mehr Möglichkeiten für Testfälle als nur einfache Antworten auf GET- oder POST-Anfragen. Ein weiterer Testfall, der abgedeckt werden sollte, ist das Testen von Timeouts. Oftmals wird, teils aus Unwissen, teils aus Unachtsamkeit, vergessen, Timeouts an RestTemplates oder URLConnections zu setzen. Ohne Time-outs warten beide standardmäßig unendlich lange auf Antworten. Im besten Fall wird das fehlende Time-out nicht bemerkt, im schlimmsten Fall warten alle Threads auf eine Antwort, die nie ankommen wird, und unsere Anwendung wird unbenutzbar. Daher sollten wir einen Test hinzufügen, der einen Time-out simuliert. Natürlich könnten wir eine verzögerte Antwort auch mit einem Mockito-Mock erzeugen, aber in diesem Fall würden wir wieder raten, wie sich das RestTemplate verhält. Eine Verzögerung zu simulieren, lässt sich mit der WireMock DSL einfach umsetzen, wie Listing 7 zeigt.

private void configureWireMockForSlowResponse() throws JsonProcessingException {
  ChuckNorrisFactResponse chuckNorrisFactResponse = new ChuckNorrisFactResponse("suc-cess", new ChuckNorrisFact(1L, ""));
  stubFor(get(urlEqualTo("/jokes/random"))
    .willReturn(
      okJson(OBJECT_MAPPER.writeValueAsString(chuckNorrisFactResponse))
        .withFixedDelay((int) Duration.ofSeconds(10L).toMillis())));
}

withFixedDelay() erwartet einen int-Wert, der die Verzögerung in Millisekunden repräsentiert. Alternativ gäbe es noch Methoden, um eine zufällige Verzögerung zu simulieren und die Methode withChunkedDribbleDelay(), die die Antwort in mehrere Blöcke aufteilt und sie über einen bestimmten Zeitraum zurückgibt.

Nachdem wir ein Time-out auf unserem restTemplate gesetzt und einen Test für eine langsame Antwort hinzugefügt haben, sehen wir, dass restTemplate im Fall eines Time-outs eine ResourceAccessException wirft. Folglich genügt unsere Anpassung aus Listing 5 noch nicht. Um die Exception zu fangen, könnten wir natürlich einen weiteren Catch-Block hinzufügen oder den vorhandenen Catch-Block zu einem Multi-Catch umwandeln. Ein Blick zurück auf Abbildung 2 zeigt uns aber, dass wir die Oberklasse beider Exceptions, die RestClientException fangen können. Listing 8 zeigt die finale Version unserer Methode.

public ChuckNorrisFact retrieveFact() {
  try {
    ResponseEntity<ChuckNorrisFactResponse> response = restTemplate.getForEntity(url, ChuckNorrisFactResponse.class);
    return Optional.ofNullable(response.getBody()).map(ChuckNorrisFactResponse::get-Fact).orElse(BACKUP_FACT);
  } catch (RestClientException e){
    return BACKUP_FACT;
  }
}

Fazit

Dieser Artikel sollte zwei Aspekte genauer beleuchten. Zum einen sollte die Bedeutung von Integrationstest herausgestellt werden. Unit-Tests sind ein wichtiger Teil der Entwicklung, aber Mocks helfen nur bis zu einem bestimmten Punkt. Deshalb sollten die Unit-Tests durch Integrationstests ergänzt werden.

Zum anderen sollte gezeigt werden, wie Integrationstests mit WireMock geschrieben werden und wie sie uns bei der Entwicklung helfen können. Als Leser haben Sie hoffentlich ein Gefühl für die Nutzung und den Funktionsumfang von WireMock bekommen. Wer noch mehr über WireMock wissen möchte, sei auf die Dokumentation verwiesen [7]. Die Möglichkeiten für Tests gehen weit über einfache HTTP-GET-Anfragen hinaus. Man könnte z. B. Headerwerte noch genauer prüfen, und mit seinem Stateful Behaviour bietet WireMock die Option, weitaus komplexere Szenarien zu simulieren.

 

Geschrieben von
Ronny Bräunlich
Ronny Bräunlich
Ronny Bräunlich war nach seinem Master am KAIST zwei Jahre als IT-Consultant bei der codecentric AG beschäftigt. Seit 2019 arbeitet er bei der ProMaterial GmbH als Lead Developer und versucht dort, mit Hilfe guter Tests und neuester Technologien die Baustoffbranche zu digitalisieren. Nebenbei ist er noch Maintainer der Gatling JDBC Erweiterung und der camunda BPM Platform OSGi Integration.
Kommentare

Hinterlasse einen Kommentar

avatar
4000
  Subscribe  
Benachrichtige mich zu: