Spring Boot: Webservice Integrationstest

13.03.2019

Spring Boot bietet mit MockMvc einen Zwischenweg zwischen einem echten Integrationstest und einem Unit-Test und ermöglicht es, einfach Integrationsaspekte zu testen. So können sich Entwickler auf die Businesslogik konzentrieren und schnell Ergebnisse liefern. Der Artikel zeigt ein vollständiges Beispiel, wie man mit Spring MockMvc eine geschützte Webschnittstelle getestet werden kann.

HINWEIS: Die Überschriften im Artikel sind so gewählt, dass Leser mit Vorwissen Abschnitte überspringen können. Überschriften von speziell für Spring Boot Einsteiger relevanten Abschnitten sind grün und mit *** markiert.

Was ist Spring Boot? ***

Spring Boot ist ein Framework, das es einfach macht, sofort einsatzbereite, eigenständige, produktionsreife, Spring-basierte Anwendungen zu erstellen. Daher findet es auch immer mehr Verbreitung.

Unter anderem erlaubt es das Framework, sehr schnell einen RESTful Webservice zu implementieren. Doch wie lässt sich dieser auf einfache Art und Weise inklusive Security automatisiert testen?

Nachteile eines echten Integrationstest

Natürlich kann man dazu einen richtigen Integrationstest entwickeln. Dies erfordert jedoch, dass die Anwendung zunächst separat installiert und gestartet wird. Bei Login-geschützten Anwendungen ist es erforderlich, auch die Login-Schritte programmatisch auszuführen. Das geht grundsätzlich, ist aber mit einem gewissen Overhead verbunden. Bei Änderung des Login-Mechanismus (z.B. Umstellung von Basic Auth auf Oauth2, o.ä.) wird eine entsprechende Anpassung der Tests nötig. Weiterhin ist das Ganze sehr träge. Bei Änderungen muss die Anwendung erst wieder deployed werden, bevor der Test ausgeführt werden kann. Um all dies will ich mich als Entwickler gar nicht kümmern müssen und mich stattdessen auf die zu entwickelnde fachliche Logik konzentrieren.

Fokus echter Integrationstests

Das heißt nicht, dass man auf echte Integrationstests verzichten sollte. Diese sind durchaus wichtig, um das Zusammenspiel mit den externen Komponenten wie z.B. Datenbanken zu prüfen. Echte Integrationstests sollten sich darauf beschränken zu testen, ob das Zusammenstecken der Komponenten wirklich funktioniert. Sie sollten nicht erneut die konkrete Logik der einzelnen Komponenten prüfen. Den Test mit unterschiedlichen Parameterkombinationen und die Prüfung aller Randfälle deckt man möglichst durch Unit-Tests ab.

Zwischenweg mit Spring Mock MVC

Manche Dinge lassen sich allerdings mit reinen Unit-Tests nicht oder nur sehr schwer testen. Glücklicherweise bietet Spring Boot hier einen Mittelweg. Es ist möglich, ohne separates Starten oder Installieren der Anwendung, eine spezielle Art von Integrationstests zu realisieren.

Mocken von Beans aus dem Applikationskontext

Beans aus dem echten Applikationskontext können hier auf ganz einfache Art und Weise durch Mocks ersetzt werden. Bei einem echten Integrationstest hätten wir hierauf keinen Zugriff, da die Anwendung separat liefe. Doch mit Spring Mock Mvc können wir beispielsweise das RestTemplate mocken. So können wir die Anwendung im Test veranlassen, dass sie für einen Aufruf bestimmter externer URLs definierte Werte zurückliefert, anstatt tatsächlich einen http-Aufruf des externen Dienstes auszuführen.

Simulieren eingeloggter Benutzer

Auch die Security Konfiguration einer mit Spring Security geschützten HTTP-Schnittstelle lässt sich damit relativ einfach testen. Ein eingeloggter Benutzer mit bestimmten Rollen kann dabei unabhängig von dem verwendeten Login-Mechanismus simuliert werden. Es spielt also keine Rolle, ob Basic Auth oder OAuth2 eingesetzt wird oder gar der Open Source Identity Management Server KeyCloak angebunden ist.

Weiteres Handwerkszeug

Außerdem bietet Spring eine Fluent-API zum einfachen Absetzen der zu testenden HTTP-Aufrufe und zum Validieren der Antworten.

Vorteil: Lesbarkeit von Tests

Mit all diesen Tools ist es möglich, eine HTTP-Schnittstelle mit wenigen Zeilen Code zu testen. Boilerplate-Code entfällt fast vollständig. Auf diese Weise kann man sich auf die Fachlichkeit konzentrieren und Tests werden so leserlich, dass sie selbst ohne großen technischen Hintergrund verständlich sind.

Dabei halte ich verständliche Tests fast für wichtiger als verständlichen Produktiv-Code. Denn Tests definieren, was die Anwendung tatsächlich fachlich tun soll (und werden optimaler Weise geschrieben, bevor der eigentliche Code entwickelt wird). Sofern die Tests durch Continuous Integration regelmäßig ausgeführt werden, besteht – im Gegensatz zu textueller Dokumentation – nicht die Gefahr, dass Code und Beschreibung auseinander laufen und irgendwann nicht mehr zusammen passen. Wer hat sich nicht schon mal die Frage gestellt: „Was stimmt denn nun? Der Code oder die Dokumentation?“ Ein Test wird daher vermutlich häufiger gelesen als die eigentliche Implementierung. Gut lesbare Tests helfen zudem enorm bei der Einarbeitung in ein unbekanntes Projekt.

Doch nun genug der einleitenden Worte. Wie verwendet man nun diese ganzen Dinge in seinem Spring Boot Projekt?

Hinweis für Spring Boot Einsteiger ***

Wer Spring Boot noch gar nicht kennt, sollte sich zunächst ein paar der vielen Tutorials anschauen z.B. die von der offiziellen Webseite:

Unser Beispiel

Als Beispiel verwenden wir eine ganz einfache Spring Boot Hello-World-Webapplikation, die mit Spring Security geschützt ist. Der Einfachheit halber verwenden wir eine Konfiguration, welche zwei Benutzer mit hartkodiertem Passwort bereitstellt: Ein Benutzer mit der Rolle „ADMIN“ und einen mit der Rolle „USER“. Wir verwenden außerdem die standardmäßig eingestellte formularbasierte Authentisierung.

Der vollständige Code findet sich auf github:

https://github.com/doubleSlashde/boot-mock-mvc-example

Hello World vs. Realität?

Wem nun ein so simples Setup zu realitätsfern ist, dem sei gesagt, dass es einem Spring Security sehr leicht macht, auf andere Mechanismen zu wechseln und Benutzerdatenbanken anzubinden – auch in kleinen Schritten die gut zu agilen Entwicklungszyklen passen. Für das Testen einer gesicherten HTTP-Schnittstelle soll dieses einfache Setup jedoch genügen. Ein komplexeres Setup würde an unserem Test kaum etwas ändern, da dieser unabhängig von der konkreten Authentisierungsart ist. Dabei wird aber nicht einfach die Sicherheitskonfiguration umgangen, sondern es können per Annotation eingeloggte Benutzer und Rollen simuliert werden. So muss später nichts mehr angepasst werden wenn die Anwendung auf einen anderen Authentisierungsmechanismus umgestellt wird.

Das ist eine der großen Stärken von Spring Boot: Man kann schnell etwas zum Laufen bringen und dann schrittweise erweitern – ohne viel umzubauen.

Grundlegendes Setup der Spring Boot Anwendung***

Das Grundgerüst erstellen wir z.B. mittels https://start.spring.io/

Wir definieren hier eine beliebige Group- und Artifact-ID für unsere Anwendung (diese kann natürlich auch nachträglich in der pom.xml geändert werden). Als Dependencies verwenden wir Security und Web. Dadurch werden dem Projekt die benötigten Maven Dependencies hinzugefügt. Mittels „Generate Project“ können wir das Anwendungsgerüst herunterladen und in unsere präferierte Entwicklungsumgebung importieren.

Abhängigkeiten

Nach diesem Setup haben wir eine Anwendung mit den Abhängigkeiten spring-boot-starter-web und spring-boot-starter-security sowie den zugehörigen Abhängigkeiten spring-boot-starter-test und spring-security-test für den Test. Um später im Test auf einfache Art Werte aus dem zurückgelieferten JSON extrahieren zu können, fügen wir noch folgende Abhängigkeit hinzu (die Version muss nicht angegeben werden, da sie von der Spring Boot Parent POM verwaltet wird):

<dependency>
    <groupId>com.jayway.jsonpath</groupId>
    <artifactId>json-path</artifactId>
    <scope>test</scope>
</dependency>

Nun haben wir eine Spring Boot Anwendung, die zwar noch nichts tut, aber bereits alles mitbringt, was wir für unser Beispiel benötigen.

Security-Konfiguration

Folgende Konfigurationsklasse konfiguriert die zwei Benutzer:

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    public void configureGlobalSecurity(AuthenticationManagerBuilder auth) 
    throws Exception {
       
        auth.inMemoryAuthentication()
            .withUser("user")
            .password("{noop}user")
            .roles("USER");
        auth.inMemoryAuthentication()
            .withUser("admin")
            .password("{noop}admin")
            .roles("ADMIN");
    }
}

Der RESTful Webservice (HelloController)

Die Klasse, die die Struktur definiert, welche vom RESTful-Webservice zurückgegeben werden soll:

public class Hello {

    private final String message;

    public Hello(String message) {
        this.message = message;
    }

    public String getMessage() {
        return message;
    }
}

Und schließlich der Webservice, der in Abhängigkeit von der Rolle einen anderen Gruß liefert: „Hello admin“ oder „Hello user“.

import javax.servlet.http.HttpServletRequest;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HelloController {

    @RequestMapping("/hello")
    public Hello getMessage(HttpServletRequest request) {
        return new Hello(request.isUserInRole("ADMIN") ? "Hello admin" : "Hello user");
    }
}

Test für unseren RESTful Webservice

Nun ergänzen wir einen Test für den HelloController, der die Schnittstelle inklusive Security prüft. In einem echten Projekt sollten wir natürlich nicht nur gültige Aufrufe prüfen, sondern auch mit unbekanntem Benutzer, falschem Passwort etc.:

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.context.junit4.SpringRunner;
import org.springframework.test.web.servlet.MockMvc;

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMockMvc
public class HelloControllerTests {

    @Autowired
    private MockMvc mvc;

    @Test
    @WithMockUser(roles = "ADMIN")
    public void testAdminAccess_Ok() throws Exception {

        // Hier erfolgt der HTTP-GET Aufruf an unseren eigenen RESTful Service
        mvc.perform(get("/hello")) 

                 // Ausgabe des Request zu Testzwecken
                .andDo(print()) 

                // Prüfung des HTTP-Statuscodes
                .andExpect(status().isOk()) 

                // Prüfung des zurückgelieferten JSONs
                // (wir verwenden jsonPath hier um mittels eines Pfadausdrucks 
                // Teile aus dem JSON zu extrahieren)
                .andExpect(jsonPath("$.message").value("Hello admin"));
    }

    @Test
    @WithMockUser(roles = "USER")
    public void testUserAccess_Ok() throws Exception {

        mvc.perform(get("/hello")) 
            .andDo(print()) 
            .andExpect(status().isOk()) 
            .andExpect(jsonPath("$.message").value("Hello user"));    

    }
}

Dieser Test ist lauffähig ohne dass die Anwendung irgendwo installiert oder separat gestartet wird. Dennoch ist es mehr als ein reiner Unit Test. Tatsächlich wird intern für den Test ein HTTP-Server gestartet. Das Verhalten entspricht also im Wesentlichen der realen Applikation.

Der Login wird mittels der Annotation @WithMockUser simuliert.

Abhängigkeit zu externen Diensten

Nun soll unser Hello-Service so erweitert werden, dass er nicht einfach rollenabhängig einen statischen Text zurückliefert, sondern diesen von externen Diensten holt. Um dies vom unserem RestController zu entkoppeln, implementieren wir einen Spring-Service mit folgendem Interface:

public interface MessageServiceInterface {
    String getMessage();
    String getAdminMessage();    
}

Und hier die Implementierung (in unserem Beispiel der Einfachheit halber ohne Authentisierung beim externen Dienst und ohne Fehlerhandling):

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

@Service
public class MessageService implements MessageServiceInterface {
    
    private RestTemplate restTemplate;
    
    @Autowired
    public MessageService(RestTemplate restTemplate) {
        super();
        this.restTemplate = restTemplate;
    }

    @Override
    public String getAdminMessage() {
        ResponseEntity<String> response = restTemplate.getForEntity(
            "https://example.com/admin/message", 
            String.class);
        return response.getBody();
    }

    @Override
    public String getMessage() {
        ResponseEntity<String> response = restTemplate.getForEntity(
            "https://example.com/user/message", 
            String.class);
        return response.getBody();
    }
}

Hier habe ich absichtlich URLs hinterlegt die es nicht gibt. Wir können uns schließlich auch in unseren Tests nicht darauf verlassen, dass der externe Service immer erreichbar ist. Außerdem können wir nicht kontrollieren, was diese zurückliefern. Daher wollen wir diese in unserem Test später mocken.

Aber zunächst müssen wir ihn natürlich noch in unserem HelloController einbauen. Dieser sieht nun wie folgt aus:

@RestController
public class HelloController {
    
    @Autowired
    private MessageService messageService;

    @RequestMapping("/hello")
    public Hello getMessage(HttpServletRequest request) {
        return new Hello(request.isUserInRole("ADMIN") ? 
             messageService.getAdminMessage() : messageService.getMessage());
    }
}

Außerdem müssen wir noch ein RestTemplate bereitstellen. Dazu ergänzen wir z.B. die Applikationsklasse um eine Provider-Methode:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;

@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
    
    @Bean
    public RestTemplate restTemplate() {
    	return new RestTemplate();
    }
}

Manueller Test der Spring Boot Anwendung ***

Nun können wir die Spring Boot Applikation starten und im Browser http://localhost:8080/hello öffnen. Wir werden auf ein, automatisch von Spring Security erzeugtes, Login-Formular weitergeleitet:

Automatisch generiertes Login-Formular
Das von Spring Security automatisch generierte Login-Formular

Nach dem Login bekommen wir erwartungsgemäß einen Fehler, da wir absichtlich ungültige URLs hinterlegt haben. In der Logausgabe sehen wir nun „HttpClientErrorException: 404 Not Found“ und im Webbrowser wird folgende Seite angezeigt:

Fehlermeldung aufgrund des nicht verfügbaren externen Service
Screenshot der Fehlermeldung aufgrund des nicht verfügbaren externen Service

Hätten wir hier die URL eines existierenden Service hinterlegt, würden wir natürlich auch eine gültige Antwort erhalten. Dies können wir ausprobieren, indem wir im MessageService als URL z.B. http://scooterlabs.com/echo?ip verwenden. Dieser Beispielservice gibt die IP als Klartext zurück. Unser Service würde dann seinerseits z.B. wie folgt antworten:

{"message":"12.34.56.7"}

Unsere Antwort funktioniert also grundsätzlich.

Unit-Test mit Spring MockMvc

Natürlich wollen wir unsere Anwendung auch automatisch testen. Dazu müssen wir im Test die Aufrufe eines externen Dienstes verhindern und stattdessen ein definiertes Ergebnis zurückliefern. Hätten wir nun einen echten Integrationstest, so wäre die Anwendung auf einem Server installiert und wir müssten diese zunächst so konfigurieren, dass sie nicht die echte URL aufruft, sondern einen Mock-Service. Alles sehr viel Overhead. Zum Glück geht es auch einfacher. Wir können beim Testen tatsächlich im laufenden Spring-Applikationskontext Beans durch Mocks ersetzen.

Jetzt haben wir zwei Möglichkeiten:

  1. Mocken der ganzen MessageService-Klasse
  2. Mocken des RestTemplate

Um möglichst die gesamte Kommunikation zu testen, entscheiden wir uns hier für die zweite Option. Dazu genügt es in der HelloControllerTest-Klasse folgende Zeilen zu ergänzen:

import org.springframework.boot.test.mock.mockito.MockBean;
…
    @MockBean
    private RestTemplate restTemplate;

Der Test schlägt nun mit Nullpointer Exceptions fehl, da ein MockBean natürlich null zurückgibt. Daher ergänzen wir nun noch folgendes:

import static org.mockito.BDDMockito.*;

import static org.mockito.BDDMockito.*;
…

    @Before
    public void before() {

        // Verhalten der Admin-Message-URL definieren
        when(restTemplate.getForEntity(
                 contains("/admin/message"), any()))
            .thenReturn(ResponseEntity.ok().body("Hello admin"));

        // Verhalten der User-Message-URL definieren
        when(restTemplate.getForEntity(
                  contains("/user/message"), any()))
            .thenReturn(ResponseEntity.ok().body("Hello user"));
    }

Und fertig:

Tests sind grün
Das Bild zeigt die erfolgreich ausgeführten Integrationstests

Fazit zu Testing mit Spring Boot und MockMvc

Es macht einfach Spaß mit Spring Boot zu entwickeln. Spring MockMvc bietet dabei das Handwerkszeug, um beim Test sehr einfache alle wichtigen Aspekte abzudecken und ermutigt dadurch zur Testgetriebenen Entwicklung. Natürlich gibt es auch bei Spring Boot Stolpersteine. Dann helfen die sehr gute Dokumentation, viele offizielle Tutorials und die riesige Community.

 

Zurück zur Übersicht

Kommentar verfassen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

*Pflichtfelder

*