Die Web-Schicht im Test: Controller-Integrationstests mit @WebMvcTest und MockMvcTester
https://bildung.social/@oerinformatik/
https://oer-informatik.de/spring-boot-controller-integrationstest
Integrationstest, Web-Schicht, WebMvcTest, MockMvcTester, MockitoBean, MockMvc, Controller-Test, Problem Details, Testslice, Spring Boot Test
tl/dr; (ca. 21 min Lesezeit): Bisher prüfen nur
.http-Dateien von Hand, ob die Controller richtig antworten. Jetzt übernimmt das ein Integrationstest der Web-Schicht:@WebMvcTeststartet nur den Web-Teil von Spring,@MockitoBeanersetzt den Service durch einen Mock, undMockMvcTesterschickt Anfragen ohne echten Server. Damit lassen sich Statuscodes, Header, JSON und alle Fehlerfälle aus Artikel 09 mit Problem Details automatisch prüfen. Unterwegs zeigen zwei Stolpersteine, was ein Testausschnitt (Slice) weglässt [AI-Assisted]. (Zuletzt geändert am 11.11.2026)
Dieser Artikel ist Teil einer Artikelreihe über ein REST-Backend mit Spring Boot: Überblick und Fahrplan / 01 Anforderungen und API-Vertrag / 02 Projekt anlegen / 03 Codegenerierung / 04 Fachlogik und Komponententest / 05 Build-Qualität und CI / 06 Persistenz mit JPA / 07 Repository-Integrationstest / 08 Service und Dependency Injection / 09 REST-API und Fehlerbehandlung / 10 Controller-Integrationstest / Übungsaufgaben zu 10
Schon fit in diesem Thema? Nutze die Kann-Liste und die Leitfragen unter dem Artikel als Checkliste, ob Du die relevanten Kompetenzen bereits hast.
Dieser Artikel baut auf der Kann-Liste der Artikel Repository-Integrationstest, Service-Schicht und Dependency Injection und REST-API und Fehlerbehandlung auf.
Als Grundlagen zum Integrationstest der Web-Schicht kann ich nach Durchlesen des Artikels…
… erklären, was ein Integrationstest der Web-Schicht prüft und wie er sich vom Komponententest und vom Systemtest abgrenzt.
… beschreiben, welche Teile der Anwendung
@WebMvcTeststartet und welche nicht.… mit
@MockitoBeaneinen Service im Spring-Kontext durch einen Mock ersetzen und sein Verhalten mitwhen(...)festlegen.… mit
MockMvcTesterAnfragen formulieren und Statuscode, Header, Content-Type und JSON-Body der Antwort prüfen.… Fehlerfälle mit Problem Details (
type,detail, Erweiterungfehler) in Tests absichern.
Darüber hinaus kann ich als Expertenwissen zu Testausschnitten (Slices)…
… begründen, warum ein Test der Web-Schicht mit
verifyNoInteractionssicherstellt, dass ungültige Anfragen den Service gar nicht erreichen.… erkennen, wann ein Testausschnitt eine Konfiguration nicht lädt, und sie gezielt mit
@Importergänzen.… erklären, warum eine unbehandelte Ausnahme im
MockMvcTesternicht als500, sondern als fehlgeschlagene Anfrage erscheint.
In Artikel 09 ist die API des Mitfahrlabors vollständig geworden: Alle elf Operationen antworten, Fehler kommen als Problem Details nach RFC 9457. Geprüft wurde das aber nur von Hand mit den .http-Dateien. Ändert jemand später einen Mapper, die ProblemBehandlung oder den Vertrag, merkt es niemand, bis ein Frontend falsche Daten bekommt.
Im V-Modell steigt die Reihe deshalb auf der rechten Seite eine Ebene nach oben: Nach dem Komponententest der Fachlogik (04, 08) und dem Integrationstest der Persistenz (07) folgt jetzt der Integrationstest der Web-Schicht. Er prüft das Zusammenspiel von HTTP, JSON, Bean Validation, Controller, Mapper und Fehlerbehandlung, also genau die Teile, die der Architekturentwurf aus Artikel 01 der Schicht web zuordnet.
Ausgangspunkt ist das Projekt aus Artikel 09. Das Ergebnis findest Du im Ordner src/10-controller-integrationstest des Repositorys. Am Code der Anwendung ändert sich nichts; neu sind nur Tests und eine höhere Grenze im Abdeckungs-Gate.
Was ein Test der Web-Schicht prüfen soll
Die bisherigen Tests der Reihe decken die Schichten unterhalb der Controller ab. Was zwischen der HTTP-Anfrage und dem Aufruf des Service passiert, prüft noch keiner:
| Testart | Beispiel im Projekt | prüft | ersetzt bzw. lässt weg |
|---|---|---|---|
| Komponententest | KostenregelTest, AngebotServiceTest, AngebotMapperTest |
eine Klasse für sich | alle Abhängigkeiten (Mocks), Spring startet nicht |
| Integrationstest Persistenz | AngebotRepositoryTest (@DataJpaTest) |
Repository, Hibernate, SQL, Datenbank | Web-Schicht und Services |
| Integrationstest Web-Schicht | AngebotControllerTest (@WebMvcTest) |
HTTP-Pfad, JSON, Bean Validation, Controller, Mapper, ProblemBehandlung |
Services (Mocks), Datenbank, Server |
| Systemtest (Artikel 11) | RestTestClient gegen die gestartete Anwendung |
die ganze Anwendung über HTTP | nichts |
Ein Integrationstest der Web-Schicht beantwortet Fragen wie diese: Kommt POST /v1/angebote beim richtigen Controller an? Wird aus "zur-schule" die Fahrtrichtung.ZUR_SCHULE? Lehnt Bean Validation „7 Uhr“ ab, bevor der Service aufgerufen wird? Steht im Location-Header die neue ID? Wird aus einer KostenbeitragZuHochException wirklich 422 mit application/problem+json? Ob die Fachregel selbst richtig rechnet, prüft er dagegen nicht; das erledigt der Komponententest aus Artikel 04.
@WebMvcTest: nur die Web-Schicht starten
Spring Boot bietet für solche Tests Testausschnitte (englisch test slices). @DataJpaTest aus Artikel 07 war einer davon: Er startet nur die Persistenz. Das Gegenstück für die Web-Schicht heißt @WebMvcTest. Laut Dokumentation von Spring Boot registriert es nur Beans für Spring MVC, darunter @Controller, @ControllerAdvice, Converter, Filter und WebMvcConfigurer. Gewöhnliche @Component-, @Service- und @Repository-Beans lädt es nicht, eine Datenbank und einen Webserver auch nicht. Statt Tomcat nimmt eine Attrappe des Servlet-Containers die Anfragen entgegen, ohne Netzwerk und ohne Port.
Mit @WebMvcTest(AngebotController.class) startet nur dieser eine Controller. Die ProblemBehandlung ist trotzdem dabei, denn als @RestControllerAdvice gehört sie zur Web-Schicht. Der AngebotService fehlt dagegen und muss ersetzt werden:
Der erste Test: Haltepunkte
Der HaltepunktController ist am einfachsten, weil er nur das HaltepunktRepository braucht. Der vollständige Test:
@WebMvcTest(HaltepunktController.class)
class HaltepunktControllerTest {
@Autowired
private MockMvcTester mvc;
@MockitoBean
private HaltepunktRepository haltepunktRepository;
@Test
void listeEnthaeltAlleHaltepunkte() {
when(haltepunktRepository.findAll()).thenReturn(List.of(Testdaten.guetersloh()));
assertThat(mvc.get().uri("/v1/haltepunkte"))
.hasStatusOk()
.bodyJson().isLenientlyEqualTo("""
[{"id": 1, "name": "Hauptbahnhof, ZOB Steig 3", "ort": "Gütersloh", "entfernungKm": 18.6}]
""");
}
@Test
void unbekannterHaltepunktErgibt404() {
when(haltepunktRepository.findById(42L)).thenReturn(Optional.empty());
assertThat(mvc.get().uri("/v1/haltepunkte/42"))
.hasStatus(HttpStatus.NOT_FOUND)
.bodyJson().extractingPath("$.detail").isEqualTo("Haltepunkt 42 existiert nicht.");
}
}Drei Bausteine tauchen in jedem Test dieses Artikels auf:
| Baustein | Paket (Spring Boot 4) | Aufgabe |
|---|---|---|
@WebMvcTest(…) |
org.springframework.boot.webmvc.test.autoconfigure |
startet den Spring-Kontext nur mit der Web-Schicht und dem genannten Controller |
@MockitoBean |
org.springframework.test.context.bean.override.mockito |
legt einen Mockito-Mock als Bean in den Spring-Kontext; der Controller bekommt ihn per Konstruktorinjektion |
MockMvcTester |
org.springframework.test.web.servlet.assertj |
baut Anfragen, schickt sie an den DispatcherServlet und liefert ein Ergebnis, das AssertJ prüfen kann |
Die Abhängigkeit spring-boot-starter-webmvc-test hat der Initializr schon in Artikel 02 in build.gradle.kts bzw. pom.xml eingetragen; sie bringt alles mit. @MockitoBean stammt aus dem Spring Framework selbst und ersetzt seit Spring Boot 3.4 das ältere @MockBean. Der Unterschied zu @Mock aus Artikel 08: @Mock erzeugt nur ein Objekt für den Test, @MockitoBean macht den Mock zu einer Bean, die Spring in den Controller einsetzt. Nach jedem Test setzt Spring den Mock zurück, damit Festlegungen mit when(...) nicht in den nächsten Test hineinwirken.
Die Entities für die Tests baut eine kleine Hilfsklasse Testdaten im Testordner. Wie in Artikel 08 setzt sie die IDs mit ReflectionTestUtils.setField, weil ohne Datenbank niemand eine ID vergibt.
Was passiert, wenn der Mock fehlt?
Lässt man @MockitoBean weg, scheitert der Test, bevor er eine Anfrage schickt. Spring kann den Controller nicht erzeugen, weil sein Konstruktor ein Repository verlangt, das im Ausschnitt nicht vorkommt:
java.lang.IllegalStateException: Failed to load ApplicationContext for [WebMergedContextConfiguration ... testClass = de.dbh.mitfahrlabor.web.HaltepunktControllerTest ...]
...
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'de.dbh.mitfahrlabor.repository.HaltepunktRepository' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
Dieselbe Meldung erscheint mit 'de.dbh.mitfahrlabor.service.AngebotService', wenn man @WebMvcTest ohne Controller-Klasse schreibt: Dann startet Spring alle vier Controller, und jeder braucht seine Abhängigkeit. Ein Testausschnitt je Controller hält die Tests unabhängig voneinander.
Anfragen schicken und Antworten prüfen
MockMvcTester gibt es seit Spring Framework 6.2. Anders als das ältere MockMvc mit perform(...).andExpect(...) arbeitet er mit den gewohnten AssertJ-Prüfungen aus Artikel 04: Die Anfrage selbst kommt in assertThat(...). Der Test für ein neues Angebot zeigt den Aufbau Arrange, Act, Assert:
@WebMvcTest(AngebotController.class)
@Import(EnumConverterConfiguration.class) // erzeugte Konverter für zur-schule, nach-hause …
class AngebotControllerTest {
private static final String NEUES_ANGEBOT = """
{"fahrerId": 1, "haltepunktId": 1, "richtung": "zur-schule", "datum": "2026-11-19",
"abfahrt": "07:00", "freiePlaetze": 2, "kostenbeitragEur": 1.80}
""";
@Autowired
private MockMvcTester mvc;
@MockitoBean
private AngebotService angebotService;
@Test
void neuesAngebotErgibt201MitLocationUndJson() {
when(angebotService.legeAn(any())).thenReturn(Testdaten.angebot8());
var antwort = mvc.post().uri("/v1/angebote")
.contentType(MediaType.APPLICATION_JSON)
.content(NEUES_ANGEBOT);
assertThat(antwort).hasStatus(HttpStatus.CREATED)
.hasHeader("Location", "/v1/angebote/8")
.hasContentType(MediaType.APPLICATION_JSON)
.bodyJson().isLenientlyEqualTo("""
{"id": 8, "fahrerId": 1, "haltepunktId": 1, "richtung": "zur-schule",
"datum": "2026-11-19", "abfahrt": "07:00", "freiePlaetze": 2,
"kostenbeitragEur": 1.8, "status": "offen"}
""");
}
// weitere Tests siehe Projekt
}Die Zeile mit @Import erklärt der Abschnitt „Ein Stolperstein: Konverter für Query-Parameter“. Die wichtigsten Prüfungen im Überblick:
| Prüfung | prüft |
|---|---|
hasStatus(HttpStatus.CREATED), hasStatusOk() |
den Statuscode |
hasHeader("Location", "/v1/angebote/8") |
einen Header mit genau diesem Wert |
hasContentType(MediaType.APPLICATION_PROBLEM_JSON) |
den Medientyp des Bodys |
bodyJson().isLenientlyEqualTo("""…""") |
den JSON-Body; Reihenfolge der Felder egal, zusätzliche Felder in der Antwort erlaubt |
bodyJson().isStrictlyEqualTo("""…""") |
den JSON-Body ohne zusätzliche Felder |
bodyJson().extractingPath("$.detail").isEqualTo(…) |
einen einzelnen Wert per JSONPath |
….extractingPath("$.fehler").asArray().containsExactly(…) |
eine Liste im Body |
Was ein solcher Test zusätzlich zum Mapper-Komponententest aus Artikel 09 abdeckt, zeigt die Antwort: Der Pfad /v1/angebote und die Methode POST stammen aus der erzeugten Schnittstelle, kostenbeitragEur wird von Jackson als Zahl 1.8 geschrieben, die Richtung als "zur-schule", und der Location-Header entsteht im Controller. Ein Fehler an einer dieser Stellen macht den Test rot.
Was übergibt der Controller an den Service?
Der Mock antwortet hier auf jeden Aufruf von legeAn gleich (any()). Ob der Controller die Werte aus dem JSON richtig umwandelt, prüft ein zweiter Test mit verify. Weil NeuesAngebot ein Record ist, vergleicht equals alle Felder:
@Test
void controllerUebergibtDieUmgewandeltenWerteAnDenService() {
when(angebotService.legeAn(any())).thenReturn(Testdaten.angebot8());
assertThat(mvc.post().uri("/v1/angebote").contentType(MediaType.APPLICATION_JSON).content(NEUES_ANGEBOT))
.hasStatus(HttpStatus.CREATED);
verify(angebotService).legeAn(new NeuesAngebot(1L, 1L, Fahrtrichtung.ZUR_SCHULE,
LocalDate.of(2026, 11, 19), LocalTime.of(7, 0), 2, new BigDecimal("1.8")));
}Der Ablauf eines solchen Tests im Detail, hier für den Fehlerfall aus dem nächsten Abschnitt: Der Test legt fest, dass der Mock eine Ausnahme wirft, schickt die Anfrage über den MockMvcTester und prüft die Antwort, die DispatcherServlet und ProblemBehandlung daraus machen.
Fehlerfälle mit Problem Details absichern
Jeder Fehlerfall aus der Tabelle in Artikel 09 wird jetzt ein Test. Der Mock wirft dazu die passende Ausnahme; geprüft wird, was die Web-Schicht daraus macht:
@Test
void zuHoherKostenbeitragErgibt422MitProblemDetails() {
when(angebotService.legeAn(any()))
.thenThrow(new KostenbeitragZuHochException(new BigDecimal("5.00"), new BigDecimal("1.86")));
var antwort = mvc.post().uri("/v1/angebote")
.contentType(MediaType.APPLICATION_JSON)
.content(NEUES_ANGEBOT.replace("1.80", "5.00"));
assertThat(antwort).hasStatus(HttpStatus.UNPROCESSABLE_CONTENT)
.hasContentType(MediaType.APPLICATION_PROBLEM_JSON)
.bodyJson().isLenientlyEqualTo("""
{"type": "https://mitfahrlabor.example/probleme/kostenbeitrag-zu-hoch",
"title": "Fachregel verletzt", "status": 422, "instance": "/v1/angebote"}
""");
}Dass der Mock die Ausnahme wirft, obwohl die Kostenregel gar nicht läuft, ist gewollt: Der Test soll die Übersetzung in 422 prüfen, nicht die Regel. Ein Client verlässt sich auf type und status; genau diese Felder stehen deshalb im erwarteten JSON. Den Text in detail lässt der Test weg, damit eine neue Formulierung ihn nicht rot macht.
Vertragsverstöße erreichen den Service nicht
Bei 400 ist die wichtigste Aussage eine andere: Eine Anfrage, die den Vertrag verletzt, darf den Service gar nicht erreichen. Das prüft verifyNoInteractions aus Mockito:
@Test
void vertragsverstossErgibt400MitFeldlisteOhneServiceAufruf() {
String falsch = NEUES_ANGEBOT.replace("\"07:00\"", "\"7 Uhr\"").replace("\"freiePlaetze\": 2",
"\"freiePlaetze\": 12");
var antwort = mvc.post().uri("/v1/angebote").contentType(MediaType.APPLICATION_JSON).content(falsch);
assertThat(antwort).hasStatus(HttpStatus.BAD_REQUEST)
.hasContentType(MediaType.APPLICATION_PROBLEM_JSON)
.bodyJson().extractingPath("$.fehler").asArray().containsExactly(
"abfahrt: must match \"^([01][0-9]|2[0-3]):[0-5][0-9]$\"",
"freiePlaetze: must be less than or equal to 8");
verifyNoInteractions(angebotService);
}Nach demselben Muster prüfen die übrigen Tests die anderen Fehlerfälle. Eine Auswahl aus den vier Testklassen:
| Test | Anfrage | erwartet |
|---|---|---|
AngebotControllerTest.unbekanntesAngebotErgibt404 |
GET /v1/angebote/99 |
404, detail „Angebot 99 existiert nicht.“ |
AngebotControllerTest.leereAenderungErgibt400OhneServiceAufruf |
PATCH /v1/angebote/8 mit {} |
400, aendere nie aufgerufen |
BuchungControllerTest.volleFahrtErgibt409 |
POST /v1/angebote/3/buchungen |
409, …/angebot-nicht-buchbar |
BuchungControllerTest.unbekannterStatusErgibt400 |
PATCH /v1/buchungen/6 mit "gebucht" |
400, Service nie aufgerufen |
BuchungControllerTest.anfrageErgibt201MitLocationUndZeitMitZeitzone |
POST /v1/angebote/8/buchungen |
201, angefragtAm mit +01:00 |
MonitorControllerTest.maxUeberDerGrenzeErgibt400MitFehlerliste |
GET /v1/monitor/eintraege?max=100 |
400, fehler mit listeMonitorEintraege.max |
MonitorControllerTest.ohneParameterGiltDerStandardwertAusDemVertrag |
GET /v1/monitor/eintraege |
200, Service mit max = 10 aufgerufen |
HaltepunktControllerTest.textStattZahlAlsIdErgibt400 |
GET /v1/haltepunkte/eins |
400, „Failed to convert ‘haltepunktId’ …“ |
Auch die Methodenvalidierung aus Artikel 09 (@Validated an der erzeugten Schnittstelle) läuft im Testausschnitt: Der Test für max=100 wird nur grün, weil der Handler für ConstraintViolationException greift.
Was der Test meldet, wenn die Fehlerbehandlung fehlt
Wie wertvoll diese Tests sind, zeigt ein Versuch: In einer Kopie des Projekts fehlt an parameterUngueltig in der ProblemBehandlung die Annotation @ExceptionHandler, so wie vor dem Fund in Artikel 09. Der Test für max=100 schlägt dann fehl, allerdings nicht mit 500:
java.lang.AssertionError:
Request failed unexpectedly:
jakarta.servlet.ServletException: Request processing failed: jakarta.validation.ConstraintViolationException: listeMonitorEintraege.max: must be less than or equal to 50
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1008)
...
Die 500-Antwort mit timestamp, status, error und path erzeugt im laufenden Betrieb erst der Servlet-Container, der die Ausnahme auf die Fehlerseite von Spring Boot umleitet. Diesen Container gibt es im Testausschnitt nicht. Eine Ausnahme, die keine @ExceptionHandler-Methode behandelt, kommt deshalb beim MockMvcTester als gescheiterte Anfrage an. Für die Praxis heißt das: Jede Request failed unexpectedly-Meldung in einem Controller-Test zeigt eine Lücke in der Fehlerbehandlung, die im Betrieb eine 500 wäre.
Ein Stolperstein: Konverter für Query-Parameter
Beim ersten Lauf von AngebotControllerTest ohne die Zeile @Import(...) war genau ein Test rot, die Suche mit ?richtung=zur-schule:
org.opentest4j.AssertionFailedError: [HTTP status code]
expected: 200
but was: 400
Bei einem fehlgeschlagenen Test schreibt Spring Boot die ganze Anfrage und Antwort ins Testprotokoll. Dort steht unter anderem:
Resolved Exception:
Type = org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
...
MockHttpServletResponse:
Status = 400
Body = {"detail":"Failed to convert 'richtung' with value: 'zur-schule'","instance":"/v1/angebote","status":400,"title":"Bad Request"}
Gegen die laufende Anwendung funktioniert dieselbe Anfrage. Den Grund kennst Du aus Artikel 03: Für Query-Parameter wandelt Spring Text über Converter in Enums um. Ohne Hilfe nutzt es Enum.valueOf, und das kennt nur ZUR_SCHULE, nicht zur-schule. Der Generator legt deshalb eine Klasse EnumConverterConfiguration an, die mit @Configuration und @Bean je Enum einen passenden Konverter bereitstellt. @WebMvcTest sucht zwar nach Beans vom Typ Converter, aber nicht nach @Configuration-Klassen; deren @Bean-Methoden laufen im Testausschnitt also nie. Im JSON-Body tritt das Problem nicht auf, weil Jackson die Enums über ihre eigene fromValue-Methode liest.
Die Lösung ist eine Zeile: @Import(EnumConverterConfiguration.class) an der Testklasse lädt genau diese Konfiguration zusätzlich. Die Tests für Angebote, Buchungen und Monitor tragen sie; der HaltepunktControllerTest braucht sie nicht, weil seine Endpunkte keine Enum-Parameter haben. Allgemein gilt: Läuft eine Anfrage in der Anwendung, aber nicht im Testausschnitt, fehlt dem Ausschnitt meist eine Konfiguration.
Tests ausführen und Abdeckungs-Gate anheben
Vier Testklassen mit zusammen 22 Tests kommen dazu: AngebotControllerTest (8), BuchungControllerTest (8), MonitorControllerTest (3) und HaltepunktControllerTest (3). Nur die Controller-Tests lassen sich so starten:
.\gradlew.bat test --tests "*ControllerTest"Jede Testklasse bekommt ihren eigenen kleinen Spring-Kontext, weil sich die Ausschnitte unterscheiden. Spring legt Kontexte im Cache ab und nutzt sie wieder, wenn eine weitere Testklasse dieselbe Konfiguration hat. Im Testlauf für diesen Artikel brauchte der erste Controller-Test gut zwei Sekunden für den Start, die übrigen jeweils weniger als eine Sekunde, der Smoke-Test contextLoads mit der ganzen Anwendung samt Datenbank dagegen rund 14 Sekunden.
Mit den neuen Tests laufen im ganzen Build 108 Tests grün. Die Anweisungsabdeckung steigt von 76 % auf 93 % (1283 von 1373 Anweisungen), im Paket web auf 92 %. Weil die Grenze im Abdeckungs-Gate aus Artikel 08 nur angehoben, nie gesenkt werden soll, steigt sie für das ganze Projekt von 70 % auf 85 %:
In build.gradle.kts:
tasks.jacocoTestCoverageVerification {
// ...
violationRules {
rule {
limit {
counter = "INSTRUCTION"
minimum = "0.85".toBigDecimal() // bisher 0.70
}
}
// Regel für domain und service (Zweige ≥ 0.90) unverändert
}
}Danach prüft der gewohnte Befehl alles zusammen, auch in der Pipeline aus Artikel 05:
.\gradlew.bat checkGradle meldet BUILD SUCCESSFUL, Maven Tests run: 108, Failures: 0 und „All coverage checks have been met.“ Die Pipeline ändert sich nicht: Der Job test ruft das Gate schon auf, und die Controller-Tests brauchen weder Datenbank noch Server.
Nächster Artikel
Die Controller-Tests prüfen die Web-Schicht mit Mocks statt Services. Ob alle Schichten zusammen den Vertrag erfüllen, zeigt erst ein Test gegen die gestartete Anwendung. Im nächsten Artikel geht es im V-Modell auf die Ebenen Systemtest und Abnahmetest: Mit RestTestClient laufen echte HTTP-Anfragen gegen die ganze Anwendung mit Datenbank, geprüft gegen den Vertrag aus Artikel 01 und gegen die Abnahmekriterien der User Stories.
Leitfragen
- Was prüft ein Integrationstest der Web-Schicht, und was prüft er bewusst nicht?
Er prüft das Zusammenspiel von HTTP-Pfad und -Methode, JSON-Umwandlung, Bean Validation, Controller, Mapper und Fehlerbehandlung: Statuscodes, Header, Content-Type und Body. Die Fachlogik im Service, die Datenbank und den Server prüft er nicht; der Service ist ein Mock. Fachregeln sichern Komponententests ab, das Zusammenspiel aller Schichten der Systemtest. siehe Abschnitt „Was ein Test der Web-Schicht prüfen soll“
- Welche Teile der Anwendung startet
@WebMvcTest(AngebotController.class), und welche nicht?
Es startet die Infrastruktur von Spring MVC (DispatcherServlet, Jackson, Bean Validation), den genannten Controller und Beans der Web-Schicht wie @ControllerAdvice (hier die ProblemBehandlung), Converter, Filter und WebMvcConfigurer. Services, Repositories, gewöhnliche @Component- und @Configuration-Klassen, die Datenbank und den Webserver startet es nicht. siehe Abschnitt „@WebMvcTest: nur die Web-Schicht starten“
- Worin unterscheidet sich
@MockitoBeanvon@Mockaus Artikel 08?
@Mock erzeugt nur ein Mock-Objekt für den Test; man reicht es selbst weiter oder lässt es mit @InjectMocks einsetzen. @MockitoBean legt den Mock als Bean in den Spring-Kontext, sodass Spring ihn per Konstruktorinjektion in den Controller einsetzt, und setzt ihn nach jedem Test zurück. Fehlt er, scheitert der Kontext mit NoSuchBeanDefinitionException. siehe Abschnitt „Der erste Test: Haltepunkte“
- Wie prüfst Du mit
MockMvcTesterStatuscode,Location-Header und JSON-Body einer Antwort?
Die Anfrage mit mvc.post().uri(…).contentType(…).content(…) bauen und in assertThat(…) geben; dann hasStatus(HttpStatus.CREATED), hasHeader("Location", "/v1/angebote/8") und bodyJson().isLenientlyEqualTo("""…""") oder bodyJson().extractingPath("$.feld").isEqualTo(…) verketten. siehe Abschnitt „Anfragen schicken und Antworten prüfen“
- Warum gehört in einen Test für
400einverifyNoInteractionsauf den Service?
Eine Anfrage, die den Vertrag verletzt, soll schon an der Web-Schicht scheitern. Der Statuscode allein zeigt nicht, ob der Service trotzdem aufgerufen wurde und vielleicht schon Daten geändert hat. verifyNoInteractions macht diese Erwartung sichtbar. siehe Abschnitt „Vertragsverstöße erreichen den Service nicht“
- Warum zeigt ein Controller-Test bei einer unbehandelten Ausnahme
Request failed unexpectedlystatt einer500-Antwort?
Die 500-Antwort entsteht im Betrieb erst im Servlet-Container, der die Ausnahme an die Fehlerseite von Spring Boot weitergibt. Im Testausschnitt gibt es keinen Container; die Ausnahme kommt deshalb als gescheiterte Anfrage beim MockMvcTester an. Die Meldung zeigt eine Lücke in der Fehlerbehandlung. siehe Abschnitt „Was der Test meldet, wenn die Fehlerbehandlung fehlt“
- Warum antwortet
GET /v1/angebote?richtung=zur-schuleim Test mit400, obwohl es in der Anwendung funktioniert, und wie behebst Du das?
Die Konverter für die Enum-Werte des Vertrags stellt die erzeugte @Configuration-Klasse EnumConverterConfiguration bereit. @WebMvcTest lädt keine @Configuration-Klassen aus dem Komponentenscan; ohne Konverter kennt Spring nur ZUR_SCHULE. @Import(EnumConverterConfiguration.class) an der Testklasse ergänzt die Konfiguration. siehe Abschnitt „Ein Stolperstein: Konverter für Query-Parameter“
Links und weitere Informationen
zum Weiterlernen: Übungsaufgaben zum Controller-Integrationstest
als Grundlage: REST-Grundlagen, Repository-Integrationstest, REST-API und Fehlerbehandlung
als Nachschlagewerk: Spring Boot – Auto-configured Spring MVC Tests, Spring Framework – MockMvc mit AssertJ (MockMvcTester), Spring Framework – @MockitoBean und @MockitoSpyBean, Mockito – Dokumentation
Hinweis zur Nachnutzung als Open Educational Resource (OER)
Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Die Web-Schicht im Test: Controller-Integrationstests mit @WebMvcTest und MockMvcTester” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/spring-boot-controller-integrationstest veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/java-springboot/Backend. Erstellt mit KI-Assistenz; von mir umfassend redaktionell überarbeitet, geprüft und freigegeben. Stand: 11.11.2026.
[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]
