Kontakt
stefan.bente[at]th-koeln.de
+49 2261 8196 6367
Discord Server
Prof. Bente Personal Zoom
Adresse
Steinmüllerallee 4
51643 Gummersbach
Gebäude LC4
Raum 1708 (Wegbeschreibung)
Sprechstunde nach Vereinbarung
Terminanfrage: calendly.com Wenn Sie dieses Tool nicht nutzen wollen, schicken Sie eine Mail und ich weise Ihnen einen Termin zu.

Wie testet man eine REST-Schnittstelle? Mit MockMvc treibt dieses Modul die Controller-Endpunkte durch den vollständigen Spring-MVC-Stack, ohne einen Netzwerk-Socket zu öffnen. Zwei gleichwertige Wege werden gegenübergestellt: der volle vertikale Durchstich mit echtem Service und Datenbank, und der isolierte Web-Slice mit gemocktem Service. Zugleich zeigt es, wie man die schnellen Unit-Tests (Test, Surefire) von den langsameren Integrationstests (IT, Failsafe) trennt.

Beispielcode-Repository
https://gitlab.com/archi-lab/public/testing-good-practices/-/tree/main/web-integration-tests-with-mockmvc
Keywords
MockMvc, REST, Controller, Integrationstests, Web, WebMvcTest, Failsafe, Surefire, Test

Web-Integrationstests mit MockMvc

Dieses Modul zeigt zweierlei: wie man eine REST-Schnittstelle mit MockMvc testet, und wie man solche Tests von den schnellen Unit-Tests trennt, nämlich über die Namensendung *Test (Surefire) gegenüber *IT (Failsafe). Es ist der Web-Gegenpart zum Modul integration-testing-mockito und macht die in ST2-M4 geforderte Aufteilung der Testsuite konkret.

Den Controller kann man dabei auf zwei gleichwertigen Wegen testen, die sich nur darin unterscheiden, was hinter dem Controller steht: der echte Service samt Datenbank, oder ein gemockter Service. MockMvc treibt in beiden Fällen die echten Endpunkte.

Die getestete Anwendung ist absichtlich winzig: eine Ressource Message mit drei Endpunkten (anlegen, eine lesen, alle lesen), dahinter ein In-Memory-H2 als Persistenzschicht.

Ausprobieren

mvn test     # nur die Unit-Tests (*Test) unter Surefire
mvn verify   # zusätzlich die Integrationstests (*IT) unter Failsafe

Was MockMvc ist

MockMvc fährt den vollständigen Spring-MVC-Stack hoch (DispatcherServlet, Routing, JSON-Konvertierung, Exception-Handling) und ruft die echten Endpunkte auf, aber ohne einen Netzwerk-Socket zu öffnen. Das ist schneller als ein echter Server und realistischer, als die Controller-Methode einfach direkt aufzurufen: Statuscode, Header und JSON-Serialisierung laufen wirklich durch den Stack. MockMvc ist das gemeinsame Werkzeug beider Wege; der Unterschied liegt allein darin, was hinter dem Controller antwortet.

Weg 1: Voller Durchstich mit dem echten Service

MessageApiIT startet mit @SpringBootTest die ganze Anwendung samt Persistenzschicht und lässt sich von @AutoConfigureMockMvc ein MockMvc injizieren. Web-Schicht, Service und Datenbank laufen echt zusammen; der Test treibt die Endpunkte von außen und prüft das beobachtbare Verhalten über den ganzen Durchstich:

mockMvc.perform( post( "/messages" )
                .contentType( MediaType.APPLICATION_JSON ).content( body ) )
        .andExpect( status().isCreated() )
        .andExpect( header().exists( "Location" ) )
        .andExpect( jsonPath( "$.text", is( "hello over HTTP" ) ) );

Dafür: Der Test deckt den kompletten vertikalen Durchstich ab. Routing, JSON, Service-Logik und Persistenz laufen wirklich zusammen, deshalb findet er Verdrahtungs- und Zusammenspielfehler, die ein isolierter Test nicht sehen kann.

Dagegen: Er ist langsam und breit. Er fährt den ganzen Kontext samt Datenbank hoch, und ein Fehlschlag grenzt nicht ein, wo der Fehler steckt: im Controller, im Service oder in der Persistenz.

Ein Array lesen: JSONPath mit Index

Der Endpunkt GET /messages liefert nicht ein einzelnes Objekt, sondern eine Liste. Im Antwort-Body ist das ein JSON-Array, und darauf greift man mit einem anderen Pfad zu als auf ein Einzelobjekt:

postMessage( "first over HTTP" );
postMessage( "second over HTTP" );

mockMvc.perform( get( "/messages" ) )
        .andExpect( status().isOk() )
        .andExpect( jsonPath( "$", hasSize( 2 ) ) )
        .andExpect( jsonPath( "$[0].id" ).exists() )
        .andExpect( jsonPath( "$[*].text", containsInAnyOrder( "first over HTTP", "second over HTTP" ) ) );

Die Ausdrücke in jsonPath(...) sind eine kleine Abfragesprache über den Antwort-Body. $ ist die Wurzel. Bei einem Einzelobjekt (GET /messages/{id}) zeigt $.text direkt auf das Feld. Sobald die Wurzel aber ein Array ist, führt $.text ins Leere: ein Array hat kein Feld text, seine Elemente haben eines. Man muss also erst ein Element auswählen, und dazu dient der Index in eckigen Klammern:

  • $[0] ist das erste Element, $[1] das zweite; $[0].text ist dessen Textfeld, $[0].id seine id.
  • $[*] steht für alle Elemente; $[*].text sammelt das Feld text jedes Objekts zu einer Liste ein, die man als Ganzes prüfen kann, hier mit containsInAnyOrder(...).
  • $ selbst ist das ganze Array, also prüft hasSize( 2 ) seine Länge.

Der Endpunkt sichert keine Reihenfolge zu, deshalb prüft der Test die Menge der Texte über $[*].text und nicht die Position eines einzelnen: ein is( "first over HTTP" ) auf $[0].text wäre eine Wette auf eine Sortierung, die es nicht gibt. Damit hasSize( 2 ) verlässlich stimmt, trägt die Testklasse @Transactional: jeder Test wird danach zurückgerollt, sodass die geteilte H2 für den nächsten wieder leer ist und dieser Test nur seine eigenen zwei Nachrichten sieht.

Weg 2: Nur die Web-Schicht mit gemocktem Service

MessageControllerIT lädt mit @WebMvcTest( MessageController.class ) nur die MVC-Schicht dieses einen Controllers und ersetzt den Service durch einen @MockBean. Kein echter Service, keine Datenbank:

when( messageService.post( "hello over HTTP" ) ).thenReturn( created );

mockMvc.perform( post( "/messages" )
                .contentType( APPLICATION_JSON ).content( "{ \"text\": \"hello over HTTP\" }" ) )
        .andExpect( status().isCreated() )
        .andExpect( header().string( "Location", containsString( "/messages/" + created.getId() ) ) )
        .andExpect( jsonPath( "$.text", is( "hello over HTTP" ) ) );

Geprüft wird allein, was der Controller selbst verantwortet: Routing, Statuscode, Location-Header und JSON-Bindung. Was der Service tut, ist gestubbt.

Auch das Lesen aller Nachrichten (GET /messages) wird hier getestet, mit demselben JSONPath auf ein Array wie in Weg 1. Der Unterschied: Der Stub legt die Reihenfolge der Liste fest, deshalb darf dieser Test die Positionen direkt prüfen, $[0].text gleich "first", $[1].text gleich "second", statt wie in Weg 1 nur die Menge über $[*].text.

Dafür: Der Test ist schnell und eng fokussiert. Er lädt nur die Web-Schicht, sodass ein Fehlschlag genau auf den Controller zeigt.

Dagegen: Der Service ist gemockt, also weist der Test nie nach, dass Controller und echter Service (samt Persistenz) wirklich zusammenspielen. Genau dieses Zusammenspiel bleibt hier ungetestet.

Beide Wege sind Integrationstests (*IT)

Beide Tests starten einen Spring-Kontext, den vollen bei Weg 1, den Web-Slice bei Weg 2. Damit ist keiner von beiden ein solitärer Unit-Test; beide tragen die Endung *IT und laufen unter Failsafe. Ein *Test unter Surefire darf gar keinen Kontext hochfahren, sonst ist er kein Unit-Test mehr, sondern ein langsamer Integrationstest unter falschem Namen. Genau diese Grenze prüft in ST2-M4 die Regel aus E3.

Die Trennung: *Test unter Surefire, *IT unter Failsafe

Maven trennt die beiden Phasen anhand der Namensendung. Surefire läuft in der Phase test und nimmt standardmäßig die Klassen auf, die auf Test enden. Failsafe läuft in der Phase verify und nimmt die Klassen auf, die auf IT enden. Damit Failsafe überhaupt mitläuft, ist sein Plugin in der pom.xml eingebunden:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-failsafe-plugin</artifactId>
    <executions>
        <execution>
            <goals>
                <goal>integration-test</goal>
                <goal>verify</goal>
            </goals>
        </execution>
    </executions>
</plugin>

Der Sinn der Trennung: Die schnellen Unit-Tests bleiben in der inneren Schleife (mvn test), während die langsameren, aufwendigeren Integrationstests erst bei mvn verify laufen. Genau diese Aufteilung führen Sie in ST2-M4 in Ihrem eigenen Projekt ein.

Der Unit-Gegenpart

Als Basis der Pyramide steht daneben MessageServiceTest: ein “solitary” Unit-Test des Service, bei dem das Repository mit Mockito gemockt ist. Kein Spring-Kontext, keine Datenbank, nur die Logik des Service. Dieselbe Anwendung, dreifach getestet, zeigt die Ebenen der Testpyramide nebeneinander: der solitäre Service-Test (*Test) als schnelle Basis, darüber die beiden Web-Tests (*IT) mit ihren zwei Zuschnitten.

Das Kleingedruckte: echter Socket mit TestRestTemplate

Beide Wege oben nutzen MockMvc und öffnen daher keinen Netzwerk-Socket. Wenn Sie einen echten Server auf einem Port wollen, nutzen Sie @SpringBootTest( webEnvironment = RANDOM_PORT ) mit TestRestTemplate. Das ist noch realistischer, aber langsamer und aufwendiger. MockMvc liegt bewusst dazwischen: voller Stack, aber kein Socket.