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.
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.
mvn test # nur die Unit-Tests (*Test) unter Surefire
mvn verify # zusätzlich die Integrationstests (*IT) unter Failsafe
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.
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.
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.
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.
*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.
*Test unter Surefire, *IT unter FailsafeMaven 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.
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.
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.