1. Migration als Refactoring
Wenn rohes MockMvc bereits funktioniert und die Tests den HTTP-Vertrag zuverlässig absichern, stellt sich die nächste praktische Frage: Wie wechselt man zu MockMvcTester, ohne diese Absicherung zu verlieren? Der häufigste Fehler liegt hier nicht in der neuen API, sondern im Wunsch, alles auf einmal umzuschreiben – zusammen mit URIs, Mocks, JSON und Erwartungen.
Daher ist es sinnvoll, die Migration wie ein gewöhnliches Refactoring von Tests zu betrachten. Refactoring bedeutet: Die Form des Codes ändern, ohne die Bedeutung zu verändern. In unserem Fall ist die „Bedeutung“ der HTTP-Vertrag, den wir festlegen: welcher Endpunkt, welche Methode, welche Header, welcher Status, welche Felder im JSON. Die „Form“ ist die konkrete Syntax der Prüfung: .andExpect(...) gegenüber .hasStatusOk().
Um die Kontrolle zu behalten (und die Migration nicht in ein künstlerisches Eigenbauprojekt zu verwandeln), ist es hilfreich festzuhalten, was wir genau erhalten und was sich tatsächlich ändert:
| Testachse | Was bleibt unverändert | Was ändert sich bei der Migration |
|---|---|---|
| Testebene | @WebMvcTest, schmaler MVC-Slice, „ein Controller – eine Testklasse“ | Nichts |
| Controller-Abhängigkeiten | gleiche @MockitoBean, gleiches Stubbing given(...).willReturn(...) | Nichts |
| HTTP-Szenario | gleicher URI, gleiche HTTP-Methode, gleicher Accept, gleicher Content-Type, gleicher Request-Body | Nichts |
| Prüfungen | gleiche Erwartungen an status, Header und JSON | nur der Stil der Assertions: AssertJ-Ketten statt .andExpect(...) |
Diese Trennung ist psychologisch wichtig. Wenn Sie Tests auf MockMvcTester migrieren, „überarbeiten“ Sie den Test nicht, sondern übersetzen ihn in einen anderen Dialekt, wobei die Handlung erhalten bleibt. Wenn sich die Handlung geändert hat, ist das keine Migration mehr, sondern ein Umschreiben des Szenarios (und Ihr Szenario ist der API-Vertrag).
2. Side-by-Side-Strategie
Eine Migration „frontal“ zerbricht das Testpaket nicht, weil MockMvcTester schlecht wäre, sondern weil eine Person gleichzeitig zu viel ändert: den Request umschreibt, Mocks korrigiert, Namen verbessert und gleichzeitig die Assertions „ein wenig vereinfacht“ – und schon ist unklar, wo der Vertrag verloren gegangen ist. Daher lautet die sicherste Regel, die zwar langweilig klingt, aber immer funktioniert: Ein Szenario – eine Änderungsachse.
Praktisch sieht das so aus: Sie nehmen ein stabiles Testszenario (normalerweise ein einfaches GET), erstellen daneben eine Version mit MockMvcTester, führen die Tests aus und vergleichen. Wenn sich das Verhalten nicht geändert hat, löschen Sie die alte Form (oder behalten sie während der Migration, wenn Sie „im Branch“ arbeiten und einen Vergleich sehen möchten). Erst dann gehen Sie zum nächsten Szenario über.
Anschanlich lässt sich „Wie migriert man ohne Schmerz“ so darstellen:
flowchart TD
A[Es gibt einen grünen rohen MockMvc-Test] --> B[Szenario daneben kopieren]
B --> C[Arrange nicht anfassen: Mocks/Daten]
C --> D[Act nicht anfassen: URI, Header, Body]
D --> E[Nur Assert ändern: andExpect -> AssertJ]
E --> F{Tests grün?}
F -->|Ja| G[Alte Form löschen oder Migration per Commit festschreiben]
F -->|Nein| H[Vergleichen: wo wurde ein HTTP-Detail verloren?]
Ein wichtiger Bonus: MockMvcTester unterstützt speziell „Brücken“ für eine schrittweise Migration. Wenn Sie bereits viele RequestBuilder aus der alten API haben oder an bestimmte ResultMatcher gewöhnt sind, können Sie diese vorübergehend behalten und nur die Assertions ändern. Das ist kein „Krückstock“, sondern eine normale Strategie für reale Projekte.
3. Migration eines GET-Szenarios: GET /api/public/articles/{slug}
Am besten beginnt man die Migration mit dem ruhigsten Szenario: GET ohne Body, aber mit explizitem Accept und JSON-Antwort. In ContentHub ist der ideale Kandidat die öffentliche Artikelkarte nach slug. Die Service-Schicht mocken wir (wie es sich für @WebMvcTest gehört), und der Test stellt sicher, dass sich die HTTP-Grenze wie versprochen verhält: richtiger Status, richtiger Content-Type, richtige Felder im JSON.
Zweisprachige Testklasse
In der Migrationsphase ist es praktisch, sowohl rohes MockMvc als auch MockMvcTester nebeneinander zu halten. Nicht, um sie in einem Test zu mischen, sondern damit Sie die Tests einzeln umschreiben und die Ansätze in derselben Umgebung vergleichen können.
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
@WebMvcTest(PublicArticleController.class)
class PublicArticleControllerWebMvcTest {
@Autowired MockMvc rawMvc; // Alte "Fernbedienung": raw MockMvc API
@Autowired MockMvcTester mvc; // Neue "Fernbedienung": AssertJ-Stil über demselben MVC-Kontext
@MockitoBean ArticleQueryService articleQueryService; // Mocken der Service-Schicht, damit der Test die HTTP-Grenze behandelt
}
MockMvcTester ist in diesem Szenario einfach eine andere „Fernbedienung“ für denselben MVC-Kontext: Der Request wird über MockMvc ausgeführt, das Ergebnis ist über AssertJ-Assertions und das Objekt MvcTestResult verfügbar.
Vorher: rohes MockMvc
Der alte Test sieht normalerweise ungefähr so aus: „Führe GET aus, setze Accept, prüfe 200“.
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
@Test
void shouldReturnArticleDetails_rawMockMvc() throws Exception {
// Arrange: Die Service-Schicht wird gemockt, damit der Test nur das Verhalten des Controllers festhält
given(articleQueryService.findBySlug("spring-basics"))
.willReturn(new ArticleDetailsResponse("spring-basics", "Spring Basics"));
// Act + Assert: Prüfen des HTTP-Vertrags (URI + Accept + Status)
rawMvc.perform(get("/api/public/articles/spring-basics")
.accept(MediaType.APPLICATION_JSON)) // Wichtig: Accept explizit festlegen
.andExpect(status().isOk()); // Erwarteter Status laut Vertrag
}
Beachten Sie: Der Sinn des Tests ist hier korrekt. Er versucht nicht zu prüfen, „wie der Service den Artikel gefunden hat“, sondern ob der Controller auf HTTP-Ebene den erwarteten Status unter der erwarteten URL liefert.
Nachher: MockMvcTester
Jetzt schreiben wir nur die Assertions um. Der Arrange-Teil (given(...)) bleibt gleich, URI und Accept bleiben gleich, die Erwartung an den Status bleibt gleich.
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
@Test
void shouldReturnArticleDetails_mockMvcTester() {
// Arrange: Mocks und Fixtures während der Migration nicht anfassen
given(articleQueryService.findBySlug("spring-basics"))
.willReturn(new ArticleDetailsResponse("spring-basics", "Spring Basics"));
// Act + Assert: Nur der Stil der Prüfungen ändert sich (AssertJ-Kette)
assertThat(mvc.get()
.uri("/api/public/articles/spring-basics") // Exakten URI festlegen
.accept(MediaType.APPLICATION_JSON)) // Exakten Accept festlegen
.hasStatusOk(); // Prüfen auf genau 200 OK
}
Der Haupteffekt der Migration ist sofort sichtbar: Der Test benötigt kein throws Exception mehr und das „visuelle Rauschen“ durch die .andExpect(...)-Kette verschwindet. Dabei geht die HTTP-Semantik nicht verloren: uri(...) und accept(...) bleiben sichtbar, und jeder, der den Test liest, sieht den Vertrag auf einen Blick.
JSON-Prüfungen in MockMvcTester
Im nächsten Schritt der Migration möchte man normalerweise die gleichen Prüfungen des Antwort-Bodys zurückhaben. In rohem MockMvc ist das oft jsonPath(...). In MockMvcTester gibt es stattdessen bodyJson() mit Unterstützung für Extraktion per JsonPath und anschließende AssertJ-Prüfungen.
Raw-Variante:
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
rawMvc.perform(get("/api/public/articles/spring-basics")
.accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
// Prüfen eines bestimmten Feldes in der JSON-Antwort
.andExpect(jsonPath("$.slug").value("spring-basics"));
Variante mit MockMvcTester:
import static org.assertj.core.api.Assertions.assertThat;
assertThat(mvc.get().uri("/api/public/articles/spring-basics")
.accept(MediaType.APPLICATION_JSON))
.hasStatusOk()
.bodyJson() // Zum JSON-Body der Antwort wechseln
.extractingPath("$.slug").asString() // Wert per JsonPath holen und Typ umwandeln
.isEqualTo("spring-basics"); // Konkreten erwarteten Wert prüfen
Hier gibt es einen wichtigen „psychologischen“ Sieg: Sie bleiben in einem Stil (AssertJ). Sie müssen nicht zwischen AssertJ und Hamcrest-Matchern springen, und der Test liest sich als zusammenhängendes Szenario. Zudem bietet JsonPathValueAssert verständliche „Typumwandlungen“ wie .asString(), .asNumber(), .asMap() – das lindert den Schmerz der Frage: „Welcher Typ kam da eigentlich aus JsonPath zurück?“
Längere Prüfungen: exchange()
Das reicht, solange der Test eine einzige Aussage prüft. Sobald die Antwort aber gleichzeitig Status, Content-Type, mehrere JSON-Felder oder einen error-Payload enthalten muss, beginnt eine lange Fluent-Kette genauso zu stören wie das alte .andExpect(...).
In diesem Moment ist es sinnvoll, auf exchange() umzusteigen, ein MvcTestResult zu erhalten und die Assertions nach Sinngruppen zu gliedern. Es wird derselbe Stub articleQueryService.findBySlug(...) wie im obigen Beispiel vorausgesetzt; hier ändert sich nicht das Szenario, sondern die Form der Prüfungen.
import static org.assertj.core.api.Assertions.assertThat;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.assertj.MvcTestResult;
MvcTestResult result = mvc.get()
.uri("/api/public/articles/spring-basics")
.accept(MediaType.APPLICATION_JSON)
.exchange();
assertThat(result).hasStatusOk();
assertThat(result).hasContentType(MediaType.APPLICATION_JSON);
assertThat(result).bodyJson().extractingPath("$.slug").asString().isEqualTo("spring-basics");
assertThat(result).bodyJson().extractingPath("$.title").asString().isEqualTo("Spring Basics");
Dasselbe Muster lässt sich auch für den error-Payload anwenden: Status und Antwortformat separat festhalten, separat ein oder zwei Schlüsselfelder des Fehlers prüfen. Eine kurze Kette ist gut, solange der Test eine einzige Aussage trifft. Sobald die Response mehrteilig ist, macht exchange() den Test meist lesbarer und den Fehlerfall verständlicher.
4. Migration von POST: POST /api/editor/articles
Nach GET möchte man normalerweise sicherstellen, dass die Migration auch bei „echten“ Requests mit Request-Body und streng festgelegtem Content-Type nicht bricht. In ContentHub ist das das Szenario zum Erstellen eines Artikelentwurfs: POST /api/editor/articles. Hier kann besonders leicht ein HTTP-Detail verloren gehen: zum Beispiel den Content-Type vergessen, den JSON-Body ändern oder statt eines genauen 201 nur „irgendein 2xx“ prüfen.
Raw MockMvc: 201 + Location
Zuerst halten wir ein verständliches rohes Szenario fest. Darin ist der Request-Body Teil des Vertrags, deshalb verstecken wir ihn nicht „der Schönheit wegen“.
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
import org.springframework.http.MediaType;
// JSON ist Teil des Vertrags: Es soll im Test explizit sichtbar sein
String json = """
{"title":"T","summary":"S","body":"B","category":"java"}
""";
rawMvc.perform(post("/api/editor/articles")
.contentType(MediaType.APPLICATION_JSON) // Wichtig: Content-Type des Requests festlegen
.accept(MediaType.APPLICATION_JSON) // Wichtig: Accept der Antwort festlegen
.content(json)) // Wichtig: Body des Requests festlegen
.andExpect(status().isCreated()) // Erwarten genau 201
.andExpect(header().exists("Location")); // Vertrag: Location ist obligatorisch
Ja, das ist etwas wortreich. Aber es ist sehr klar: welcher Endpunkt, welcher Content-Type, welcher Accept, welcher Body, was in der Antwort erwartet wird.
MockMvcTester: dieselben Erwartungen
Jetzt dasselbe Szenario in MockMvcTester. Beachten Sie: 201 prüfen wir nicht über „OK“, sondern über den expliziten HttpStatus.CREATED. MockMvcTester bietet hasStatusOk(), aber das ist genau 200 OK, und man sollte es nicht durch „Created“ ersetzen (sonst verschlechtern Sie selbst Ihren Vertrag).
import static org.assertj.core.api.Assertions.assertThat;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
// Dasselbe JSON, denn Migration ist Refactoring der Form, nicht Änderung des Vertrags
String json = """
{"title":"T","summary":"S","body":"B","category":"java"}
""";
assertThat(mvc.post()
.uri("/api/editor/articles") // Derselbe Endpunkt
.contentType(MediaType.APPLICATION_JSON) // Content-Type des Requests festlegen
.accept(MediaType.APPLICATION_JSON) // Accept festlegen
.content(json)) // Request-Body festlegen
.hasStatus(HttpStatus.CREATED) // Prüfen auf genau 201 Created
.containsHeader("Location"); // Vertrag: Header Location muss vorhanden sein
Die Methode containsHeader("Location") ist das „Analogon“ zu header().exists(...) aus rohem MockMvc, nur im AssertJ-Stil.
Disziplin: Mock-Setup nicht anfassen
An dieser Stelle beginnen viele (besonders nach ein paar erfolgreichen Umschreibungen), „alles Mögliche zu verbessern“. Zum Beispiel ändern sie gleichzeitig mit der Migration das Stubbing: Sie ersetzen given(...).willReturn(...) durch when(...).thenReturn(...), benennen den Service um, ändern DTOs. Das ist der Fall, wenn Tests einer Küchenrenovierung ähneln: Sie wollten den Wasserhahn austauschen und liegen plötzlich zwischen den Fliesen, weil „man kann ja gleich …“.
Wenn Sie eine sichere Migration möchten, halten Sie sich an die Regel: Bei der Übersetzung von rohem MockMvc zu MockMvcTester ändern Sie nur, wie der Request ausgeführt wird und wie Assertions geschrieben werden. Den Arrange-Teil (Mocks, Fixtures, Daten) lassen Sie wie er ist. Dann bedeutet jede „rote“ Prüfung: Sie haben ein HTTP-Detail verloren, nicht dass Sie versehentlich die Geschäftslogik geändert haben.
5. Brücke: perform(...) und matches(...)
Manchmal sind Tests bereits mit Infrastruktur zugewachsen: Sie haben eigene RequestBuilder, eigene ResultMatcher oder gewohnte Code-Stücke wie status().isOk(). Alles auf einmal in den neuen Stil umzuschreiben ist teuer und riskant. Daher bietet MockMvcTester eine eingebaute Strategie für den „Umzug ohne Panik“: Sie können den Request über perform(...) ausführen und sogar alte Matcher über .matches(...) verwenden.
Beispiel, in dem Sie einen alten RequestBuilder behalten, aber bereits auf AssertJ-Assertions umsteigen:
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import org.junit.jupiter.api.Test;
@Test
void shouldReuseOldRequestBuildersDuringMigration() {
// "Brücke": Request mit altem RequestBuilder ausführen, aber Ergebnis im neuen Stil prüfen
assertThat(mvc.perform(get("/api/public/articles")))
.hasStatusOk();
}
Und hier ein Beispiel, in dem Sie sogar einen alten ResultMatcher behalten (z.B. weil Sie viele andExpect(status().isOk()) haben und schrittweise migrieren möchten):
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
// Brücke auf beiden Seiten: sowohl RequestBuilder als auch ResultMatcher bleiben alt,
// die "Umhüllung" ist bereits MockMvcTester
assertThat(mvc.perform(get("/api/public/articles")))
.matches(status().isOk());
Das sollte man als Zwischenstopp betrachten. Letztendlich wird man wahrscheinlich zum „heimischeren“ Stil mvc.get().uri(...) wechseln wollen, aber der Weg „über die Brücke“ erlaubt es, das Testpaket nicht zu zerbrechen und sich kein Wochenende mit der Frage „Warum fällt jetzt die Hälfte der Klasse durch?“ zu bescheren.
6. Typische Fehler bei der Migration auf MockMvcTester
Migration ist ein seltener Fall, in dem Sie den Test kürzer und schöner machen können, aber dabei versehentlich schwächer machen. Die Fehler liegen hier fast nie an Spring oder Mockito, sondern an menschlicher Eile: Wir haben „im Grunde“ dasselbe umgeschrieben, aber eine kleine HTTP-Detail übersehen, das gerade den Sinn des Tests ausmachte. Im Folgenden die häufigsten Stolpersteine.
Fehler Nr. 1: Stil umschreiben und gleichzeitig das Szenario ändern.
Der häufigste Fehlschlag sieht so aus: „Ich habe den Test auf MockMvcTester umgeschrieben, und gleichzeitig beschlossen, dass man Accept weglassen kann, und überhaupt, der Endpunkt sollte besser über einen Helper aufgerufen werden.“ Das Ergebnis: Der Test ist grün, beweist aber nicht mehr dasselbe. Halten Sie bei der Migration eisern fest: URI, Methode, Header, Body und der Sinn der Assertions bleiben gleich. Nur die Syntax der Prüfung ändert sich.
Fehler Nr. 2: „HTTP der Schönheit wegen verstecken“.
MockMvcTester ermöglicht zwar kompakteres Schreiben. Aber wenn Sie um der Kompaktheit willen aufhören, explizit accept(MediaType.APPLICATION_JSON) oder contentType(MediaType.APPLICATION_JSON) anzugeben, machen Sie den Test weniger ehrlich: Er prüft jetzt nur noch „irgendwas hat geklappt“, nicht den konkreten Vertrag. In unserem Kurs haben wir speziell gelernt, solche Dinge nicht „standardmäßig“ zu belassen – heben Sie diese Regel nicht auf, nur weil die API hübscher geworden ist.
Fehler Nr. 3: Raw MockMvc und MockMvcTester in derselben Testmethode mischen.
Technisch können Sie sowohl MockMvc als auch MockMvcTester in der Klasse halten (das ist für die Migration praktisch), aber innerhalb einer Methode sollten Sie sich für eines entscheiden. Sonst entsteht ein seltsames Gemisch: Act mit einer API, Assert mit einer anderen, und nach einer Woche erinnern Sie sich selbst nicht mehr, warum. Wenn Sie schon beide Felder halten – verwenden Sie sie in verschiedenen Testmethoden.
Fehler Nr. 4: Ketten zu lang machen und schwer debuggbar.
assertThat(mvc.get(...)...) mit einer Kette von .hasStatusOk().hasContentTypeCompatibleWith(...).bodyJson()... sieht gut aus, solange die Kette kurz ist. Sobald Sie viele Prüfungen „draufgesattelt“ haben, verwenden Sie exchange() und mehrere separate assertThat(result).... Das macht den Test nicht schlechter; im Gegenteil, es wird lesbarer und ein Fehlschlag ist leichter zu diagnostizieren.
Fehler Nr. 5: Exakte Prüfungen durch zu allgemeine ersetzen.
Manchmal denkt jemand während der Migration: „Na ja, ich prüfe doch, dass es 2xx ist, also ist alles gut.“ Und setzt dann hasStatus2xxSuccessful() statt hasStatus(HttpStatus.CREATED). Dadurch hört der Test auf, Vertragsänderungen zu erkennen. In Web-Layer-Tests ist die Genauigkeit von Stati und Headern ein Teil des Wertes – werfen Sie ihn nicht weg.
GO TO FULL VERSION