1. HTTP-Antwort als Vertrag: Status und Header
Wenn Menschen zum ersten Mal Controller-Tests schreiben, haben sie den natürlichen Wunsch: „lasst uns sofort JSON vergleichen“. Dieser Wunsch ist verständlich, schadet aber der Lesbarkeit und Diagnostik. Die HTTP-Antwort ist nicht nur der Body. Sie besteht auch aus dem Status (als Urteil), den Headern (als Metadaten) und erst dann dem Inhalt. Wenn Sie die ersten beiden Schichten ignorieren, wird der Test zu einer Überprüfung des Brieftextes, ohne den Betreff zu lesen und ohne zu verstehen, ob der Brief überhaupt zugestellt wurde.
An diesem Punkt haben wir die Anfrage bereits zusammengestellt: Methode, URI, Body und die erforderlichen Anfrage-Header sind vorhanden. Jetzt ist es wichtig, die Antwort in einer Reihenfolge zu lesen, die die Diagnose unterstützt: zuerst das Urteil des Servers verstehen, dann die Metadaten des Vertrags überprüfen und erst danach ins JSON gehen.
In MockMvc wird dies bequem durch eine einfache Regel zum Lesen des Tests ausgedrückt: zuerst erwarten wir den Status, dann prüfen wir wichtige Header und den Medientyp, und erst dann gehen wir zum Body über. Diese Reihenfolge macht Fehlschläge viel „menschlicher“. Wenn Sie statt 201 Created ein 400 Bad Request erhalten, ist es normalerweise sinnlos zu diskutieren, welches JSON genau kam – es könnte ganz anders sein (oder es könnte gar nicht existieren).
Um dieses Bild im Kopf zu verankern, ist es hilfreich, sich die Antwort als drei Schichten vorzustellen:
| Antwort-Schicht | Was ist das? | Warum ist das im Test wichtig? |
|---|---|---|
| Statuscode | „Wie der Server das Ergebnis interpretiert hat“ | Das Erste, was der Client (und der Test) verstehen sollte |
| Header | „Metadaten des Vertrags“ | Location, Content-Type, manchmal Caching usw. |
| Body | „Daten“ | Die Nutzlast, die wir separat prüfen werden |
2. Überprüfung des Statuscodes: günstige und informative Assertion
Der Statuscode in HTTP ist wie eine Anzeigetafel am Flughafen. Solange Sie nicht „Flug gestartet“ sehen, ist es zu früh, über die Qualität des Sandwiches im Flugzeug zu streiten. In Controller-Tests ist der Status Ihr schnellster „Detektor“: Er zeigt, in welchen Verarbeitungszweig die Anfrage gelangt ist, und erklärt oft sofort, was überhaupt passiert ist.
In MockMvc wird der Status über den status()-Matcher überprüft. Und hier gibt es eine kleine, aber wichtige Disziplin: Besser den spezifischen Status inhaltlich überprüfen, statt „na ja, irgendwas Erfolgreiches“. In einem realen Projekt unterscheidet das einen Test, der den Vertrag schützt, von einem Test, der einfach nur die grüne Farbe mag.
Zum Beispiel ist für das öffentliche Lesen eines Artikels in ContentHub erwartungsgemäß 200 OK zu erwarten. Für die Erstellung eines Entwurfs über POST /api/editor/articles ist es logisch, 201 Created zu erwarten. Selbst wenn der Antwortkörper übereinstimmt, hat der Status eine Bedeutung für den Client: „Ressource erstellt“ – das ist nicht dasselbe wie „Ressource einfach zurückgegeben“.
Eine kleine „Vokabelliste“ der häufigsten Statuscodes, die in einer typischen REST-API (und auch in unserem ContentHub) vorkommen:
| Status | Wann normalerweise verwendet | Wie es vom Client interpretiert wird |
|---|---|---|
| 200 OK | GET, erfolgreicher , manchmal Befehle |
„Alles gut, hier ist das Ergebnis“ |
| 201 Created | POST Ressourcenerstellung | „Neue Ressource erstellt; hier können Sie sie abrufen“ |
| 204 No Content | Befehl ohne Body (oft ) |
„Erledigt, aber ich gebe keine Daten zurück“ |
Beachten Sie: Wir gehen jetzt nicht auf Fehler (400/404/409/500) und deren Vertrag ein – das ist eine separate Ebene des Kurses. Heute interessiert uns die Disziplin der Überprüfung einer erfolgreichen Antwort: nicht „damit es grün wird“, sondern „damit es korrekt ist“.
Praktisches Beispiel: minimale Überprüfung von 200 OK für einen öffentlichen Endpunkt. Hier berühren wir den Body nicht – wir fixieren nur das Ergebnis auf der Ebene der HTTP-Bedeutung.
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void getPublicArticle_returns200() throws Exception {
// Wir führen eine Anfrage an den öffentlichen Endpunkt durch
mockMvc.perform(get("/api/public/articles/{slug}", "spring-mockmvc"))
// Wir fixieren nur den HTTP-Status: Dies ist die erste und informativste Schicht des Vertrags
.andExpect(status().isOk());
}
Und jetzt ein Beispiel für ein Erstellungsszenario. Wir werden Content-Type und Accept hinzufügen (aus der vorherigen Vorlesung), aber das Hauptziel ist es, 201 zu sehen.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void createDraft_returns201() throws Exception {
// Anfragekörper als JSON: So ist Lesen und Bearbeiten einfacher als ein „escaped String“
String json = """
{
"title": "Intro",
"summary": "S",
"body": "B",
"category": "java"
}
""";
mockMvc.perform(post("/api/editor/articles")
// Wir teilen dem Server mit, dass wir JSON senden
.contentType(MediaType.APPLICATION_JSON)
// Wir bitten darum, JSON in der Antwort zurückzugeben (Teil des HTTP-Vertrags)
.accept(MediaType.APPLICATION_JSON)
// Wir übergeben den Anfragekörper
.content(json))
// Für die Ressourcenerstellung erwarten wir genau 201, nicht „irgendein 2xx“
.andExpect(status().isCreated());
}
Wenn dieser Test plötzlich fehlschlägt und 400 anzeigt, haben Sie bereits eine nützliche Diagnose, ohne den Body zu überprüfen: „Der Controller hält die Anfrage für ungültig“. Das führt Sie schnell zur Ursache: möglicherweise der falsche Content-Type, es fehlen Felder, der Parameter heißt anders, das Binding ist kaputt oder etwas anderes an der Web-Grenze.
Und hier ist der body-lose Write-Flow, den es sich lohnt, getrennt im Kopf zu behalten. Ein erfolgreicher DELETE wird oft nur durch den Status 204 No Content bestätigt: Die Aktion wurde ausgeführt, es gibt keinen Grund, einen Body zurückzugeben.
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@Test
void deleteDraft_returns204() throws Exception {
mockMvc.perform(delete("/api/editor/articles/{id}", 42L))
.andExpect(status().isNoContent());
}
In einem solchen Test reichen normalerweise status().isNoContent() und, falls der Vertrag es erfordert, ein paar Header-Prüfungen. Den Antwortkörper suchen wir hier bewusst nicht.
3. Antwort-Header, Location und Medientyp
Response-Header: Was prüfen
Wenn ein Entwickler zum ersten Mal eine HTTP-Antwort „erwachsen“ sieht (in Postman oder in den Browser-DevTools), könnte ihn der Gedanke überkommen: „Wow, so viele Header! Lass uns alles prüfen!“ Das ist ein natürlicher Weg … zu fragilen Tests und schlechter Laune. Header können vertraglich sein (ihre Änderung bricht Clients) oder technisch/nebensächlich (ihre Änderung ist eine normale Evolution des Servers und der Infrastruktur).
In MockMvc werden Header über header() geprüft. Das nützlichste Minimum ist, nur das zu prüfen, was wirklich Teil des Vertrags ist. In unserem aktuellen Fokus sind das zwei Arten von Headern: Location (für die Ressourcenerstellung) und Content-Type (wir prüfen es in einem separaten Abschnitt über content().contentType..., aber technisch ist es auch ein Header).
Rein technische Header wie Date, Content-Length, Connection ändern sich oft von Version zu Version oder hängen von der Umgebung ab. Sie in einem @WebMvcTest zu prüfen, ist normalerweise sinnlos: Sie testen nicht Tomcat/Jetty/Undertow und nicht das Netzwerk, sondern den Web-Vertrag des Controllers.
Einfaches Beispiel: Prüfen, dass der Server bei der Erstellung eines Entwurfs den Location-Header zurückgibt. Zunächst ohne genauen Wert – einfach das Vorhandensein (das stärkt bereits den Vertrag).
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void createDraft_hasLocationHeader() throws Exception {
// Minimales JSON für das Erstellungsszenario (Beispiel)
String json = """
{
"title": "Intro",
"summary": "S",
"body": "B",
"category": "java"
}
""";
mockMvc.perform(post("/api/editor/articles")
// JSON senden
.contentType(MediaType.APPLICATION_JSON)
// JSON in der Antwort erwarten
.accept(MediaType.APPLICATION_JSON)
// Anfragekörper
.content(json))
// Ressourcenerstellung: erwarten 201
.andExpect(status().isCreated())
// Vertraglicher Header für den Create-Endpunkt: Location muss vorhanden sein
.andExpect(header().exists("Location"));
}
Sehen Sie den Nutzen? Selbst wenn der Body komplex ist (und das wird er, weil DTO), ist der Location-Header ein separates Versprechen an den Client: „Ich habe eine Ressource erstellt, und hier ist ihre Adresse“. Das ist praktisch für das Frontend, für Integratoren und für Ihr zukünftiges Ich, wenn Sie in einem halben Jahr versuchen, sich zu erinnern, warum dieser POST überhaupt existiert.
Location: Wie man die Ressourcenerstellung testet
Der Location-Header ist eines der Elemente, die eine API von „funktioniert irgendwie“ zu „funktioniert wie vereinbart“ machen. Wenn POST eine Ressource erstellt, ist es für den Client wichtig zu wissen, wo diese Ressource jetzt lebt. Man könnte natürlich die ID im Body zurückgeben, und der Client setzt die URL selbst zusammen. Aber wenn Sie Location zurückgeben, nehmen Sie dem Client Verantwortung ab und verringern die Wahrscheinlichkeit, dass verschiedene Clients die URL unterschiedlich zusammensetzen.
Ein typischer Vertrag für einen Create-Endpunkt sieht so aus: Status 201 Created und ein Location-Header, der auf den Lese-Endpunkt der erstellten Ressource verweist. In ContentHub für die Editor-Zone ist das normalerweise etwas wie GET /api/editor/articles/{id} (ein solcher Endpunkt ist im Projekt vorgesehen).
Das Problem in Tests ist einfach: Der Wert von Location hängt davon ab, welche ID aus der Service-Schicht zurückkommt. In @WebMvcTest werden Services normalerweise durch Mocks ersetzt, daher ist es vorteilhaft, die ID deterministisch zu machen. Das ist, als würde man mit dem Kassierer vereinbaren, dass das Wechselgeld immer genau 42 Bitcoins beträgt – rein für das Experiment.
Unten ein Beispiel: Wir mocken den Service so, dass er einen Artikel mit id=42 „erstellt“, und prüfen dann den Location-Header. Damit der Test nicht zu fragil wird, prüfen wir nicht die gesamte Zeichenkette, sondern dass sie endet mit dem erwarteten Pfad. Das schützt vor kleinen Unterschieden wie http://localhost am Anfang (in MockMvc kann das vorkommen).
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import static org.hamcrest.Matchers.endsWith;
import static org.mockito.BDDMockito.given;
import static org.mockito.ArgumentMatchers.any;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void createDraft_returnsLocationToEditorGetById() throws Exception {
// Mocken den Service-Layer: machen das Ergebnis deterministisch (id immer 42)
given(articleEditorService.createDraft(any())).willReturn(42L);
String json = """
{
"title": "Intro",
"summary": "S",
"body": "B",
"category": "java"
}
""";
mockMvc.perform(post("/api/editor/articles")
// Content-Type ist wichtig: sonst kann der Controller JSON möglicherweise nicht parsen und 400 zurückgeben
.contentType(MediaType.APPLICATION_JSON)
// Accept fixiert die Erwartungen des Clients auf HTTP-Ebene
.accept(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated())
// Wir prüfen die Bedeutung: Location verweist auf das Lesen der erstellten Ressource mit der richtigen id
.andExpect(header().string("Location", endsWith("/api/editor/articles/42")));
}
Beachten Sie den Gedanken, der hier wichtiger ist als der Code: Wir testen nicht, „wie genau der Controller die URL baut“ (Schema, Host, Port) im kleinsten Detail. Wir testen die Bedeutung: Der Location-Header führt zum Lesen der erstellten Ressource und enthält die richtige ID. Das ist die „minimal ausreichende Prüfung“ im Web-Kontext.
Noch ein kleiner, aber nützlicher Trick: Statt des String-Literals "Location" kann man die Konstante aus HttpHeaders verwenden. Das verringert die Tippfehlerwahrscheinlichkeit (ein Tippfehler im Headernamen ist ein sehr ärgerlicher Grund für einen Testfehler).
import org.springframework.http.HttpHeaders;
// ... innerhalb der Erwartungskette:
.andExpect(header().exists(HttpHeaders.LOCATION));
Das ist eine Kleinigkeit, aber solche Kleinigkeiten machen Tests etwas „stabiler“.
Medientyp der Antwort: Content-Type prüfen
Wir haben bereits Accept in der Anfrage festgelegt, also dem Server gesagt: „gib mir JSON zurück“. Jetzt fairerweise zu prüfen, dass der Server tatsächlich JSON zurückgegeben hat – das ist Teil des Vertrags. Und diese Prüfung betrifft nicht „ich sehe geschweifte Klammern im Body“, sondern eine strenge Vereinbarung zwischen Client und Server auf HTTP-Ebene.
In MockMvc gibt es dafür praktische Content-Matcher: content().contentType(...) und content().contentTypeCompatibleWith(...). Der Unterschied zwischen ihnen ist der Unterschied zwischen „Buchstabe für Buchstabe“ und „sinngemäß kompatibel“. Für einen Anfänger ist das sehr ähnlich dem Unterschied zwischen „ich brauche genau die Datei report.pdf“ und „ich brauche ein Dokument im PDF-Format, der Name ist nicht entscheidend“.
Wenn Sie eine zu strenge Prüfung verwenden, kann der Test aufgrund nebensächlicher Details fehlschlagen, z. B. wegen des Vorhandenseins/Fehlens von charset oder wegen des Formats der Header-Notation. Daher ist es in den meisten JSON-API-Tests für die Web-Grenze praktischer, contentTypeCompatibleWith(MediaType.APPLICATION_JSON) zu verwenden.
Beispiel für das öffentliche Lesen eines Artikels: Wir fixieren 200 und die Kompatibilität des Medientyps mit JSON.
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@Test
void getPublicArticle_returnsJson() throws Exception {
mockMvc.perform(get("/api/public/articles/{slug}", "spring-mockmvc")
// Der Client fordert explizit JSON an
.accept(MediaType.APPLICATION_JSON))
// Zuerst der Status
.andExpect(status().isOk())
// Dann Medientyp: Der Server hat tatsächlich JSON zurückgegeben, nicht „etwas Text/HTML“
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON));
}
Dieser Test sagt noch nichts über die JSON-Struktur (das kommt in der nächsten Vorlesung), aber er schützt bereits einen wichtigen Teil des Vertrags: Der Client hat JSON angefordert und tatsächlich JSON erhalten, nicht etwa eine HTML-Fehlerseite oder irgendeinen Text.
Und hier ein Beispiel, wo eine strenge Prüfung gerechtfertigt sein kann: Wenn Sie genau wissen, dass der Endpunkt einen bestimmten Typ zurückgeben muss (z. B. immer application/json ohne Ausnahmen) und Sie dies bewusst „festschreiben“ möchten. Aber für einen Anfängerkurs und die meisten Controller-Tests ist es besser, mit Kompatibilität zu beginnen, damit die Tests die Bedeutung schützen und nicht die Formatierung des Headers.
Reihenfolge Status -> Header -> Body
Im echten Leben scheitern Tests meistens nicht, weil man „keine Tests schreiben kann“, sondern weil sich Code ändert. Und genau in dem Moment des Scheiterns ist es wichtig, so schnell wie möglich die Antwort auf die Frage zu bekommen: „Ist das ein Fehler in der Geschäftslogik, ein Fehler im Web-Binding, ein Fehler in den Verträgen oder habe ich mich nur vertippt?“
Die Reihenfolge Status -> Header -> Body funktioniert wie eine saubere Diagnoseleiter. Der Status beantwortet, ob die Anfrage „durch die richtige Tür“ gegangen ist. Die Header zeigen, ob die wichtigsten Metadaten des Vertrags eingehalten wurden (z. B. Location bei der Erstellung). Und erst danach ist es sinnvoll, den Body zu behaupten: sonst riskieren Sie, den JSON eines Fehlers oder den JSON eines ganz anderen Szenarios zu vergleichen.
Eine kleine Grafik, die hilft, dies im Kopf zu behalten:
flowchart TD
A["perform(request)"] --> B["assert status"]
B --> C["assert headers"]
C --> D["assert Content-Type"]
D --> E["assert body"]
Und ja, das klingt fast wie „zuerst die Socken anziehen, dann die Schuhe“. Aber die Praxis zeigt: Diejenigen, die es umgekehrt machen, bekommen normalerweise schnell nasse Füße.
4. Typische Fehler bei der Überprüfung von Status/Header/Medientyp in MockMvc
Fehler Nr. 1: Der Test geht sofort in den Body und prüft den Status nicht.
Ein solcher Test wird oft zum Rätsel: Sie erwarten ein Feld im JSON, aber in Wirklichkeit hat der Server einen Fehler oder ein anderes Szenario zurückgegeben, und der JsonPath/JSON-Vergleich schlägt mit Meldungen fehl, die schwer mit der Ursache in Verbindung zu bringen sind. Wenn Sie zuerst status().isOk() oder status().isCreated() fixieren, wird die Diagnose fast sofort: „Falscher Status – das bedeutet, wir suchen nicht einmal an der richtigen Stelle“.
Fehler Nr. 2: Prüfung auf „irgendeinen erfolgreichen Status“ statt auf einen konkreten.
Manchmal schreibt man so etwas wie „irgendein 2xx soll reichen“. In der Praxis verwischt das den Vertrag. Für den Client sind 200 OK und 201 Created unterschiedliche semantische Antworten, besonders wenn die Clientlogik von der Art der Operation abhängt. Besser, den Status an die Bedeutung des Endpunkts zu binden und damit die Absicht des Controllers festzuschreiben.
Fehler Nr. 3: Versuch, alle Header der Reihe nach zu prüfen.
Es ist sehr leicht, anzufangen, Date, Content-Length, einige technische Header zu prüfen und plötzlich eine fragile Testsuite zu haben, die bei kleinen Änderungen in der Umgebung zerbricht. In einem Web-Slice-Test sollte man normalerweise nur vertraglich relevante Header prüfen: Location dort, wo eine Ressource erstellt wird, und Content-Type (über content().contentType...) dort, wo JSON zurückgegeben wird.
Fehler Nr. 4: Zu strenge Prüfung des Content-Type.
Eine Prüfung wie „erwarte exakt application/json;charset=UTF-8“ bricht oft auf einer glatten Ebene, weil die Notation variieren kann, charset je nach Einstellungen und Version auftauchen oder verschwinden kann. In den meisten Fällen reicht contentTypeCompatibleWith(MediaType.APPLICATION_JSON): Das prüft die Bedeutung (JSON), hängt sich aber nicht an Nebensächlichkeiten auf.
Fehler Nr. 5: Erwartung von Location dort, wo er nicht sein muss.
Location ist der „Star“ der Create-Endpunkte und Redirects. Bei einem normalen GET oder PUT darf er fehlen, und das ist normal. Wenn Sie in jeder Antwort „für alle Fälle“ Location prüfen, werden Sie entweder falsche Fehlschläge ernten oder anfangen, den Header nur für den Test in die Antworten einzubauen – das ist schon traurig (der Vertrag lebt dann für die Tests, nicht für die Clients).
GO TO FULL VERSION