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: @WebMvcTest startet nur den Web-Teil von Spring, @MockitoBean ersetzt den Service durch einen Mock, und MockMvcTester schickt 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 @WebMvcTest startet und welche nicht.

  • … mit @MockitoBean einen Service im Spring-Kontext durch einen Mock ersetzen und sein Verhalten mit when(...) festlegen.

  • … mit MockMvcTester Anfragen formulieren und Statuscode, Header, Content-Type und JSON-Body der Antwort prüfen.

  • … Fehlerfälle mit Problem Details (type, detail, Erweiterung fehler) in Tests absichern.

Darüber hinaus kann ich als Expertenwissen zu Testausschnitten (Slices)…

  • … begründen, warum ein Test der Web-Schicht mit verifyNoInteractions sicherstellt, dass ungültige Anfragen den Service gar nicht erreichen.

  • … erkennen, wann ein Testausschnitt eine Konfiguration nicht lädt, und sie gezielt mit @Import ergänzen.

  • … erklären, warum eine unbehandelte Ausnahme im MockMvcTester nicht als 500, 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.

Fahrplan der Artikelreihe im V-Modell; auf der Ebene Integrationstest ist Artikel 10 hervorgehoben (mit KI erstellt)

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:

Testarten im Projekt und was sie jeweils ersetzen
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:

Schema: Der AngebotControllerTest schickt mit dem MockMvcTester POST /v1/angebote an den DispatcherServlet im Spring-Kontext von @WebMvcTest; darin laufen Jackson und Bean Validation, AngebotController mit AngebotMapper und die ProblemBehandlung echt, der AngebotService ist ein Mock; die Konverter für Query-Parameter kommen nur per @Import dazu; nicht gestartet werden Tomcat, der echte AngebotService, Repositories und die Datenbank (mit KI erstellt)

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:

Bausteine eines Controller-Integrationstests
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üfungen von MockMvcTester für HTTP-Antworten
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.

UML-Sequenzdiagramm: Der AngebotControllerTest legt am Mock des AngebotService fest, dass legeAn eine KostenbeitragZuHochException wirft, und schickt mit dem MockMvcTester POST /v1/angebote ohne Netzwerk an den DispatcherServlet; dieser ruft legeAngebotAn am AngebotController, der Controller legeAn am Mock, der die Ausnahme wirft; der DispatcherServlet ruft fachregelVerletzt an der ProblemBehandlung auf und gibt 422 mit application/problem+json zurück; der Test prüft Status, Content-Type und Body (mit KI erstellt)

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:

Auswahl der Fehlerfälle in den Controller-Tests
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 check

Gradle 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

  1. 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“

  1. 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“

  1. Worin unterscheidet sich @MockitoBean von @Mock aus 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“

  1. Wie prüfst Du mit MockMvcTester Statuscode, 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“

  1. Warum gehört in einen Test für 400 ein verifyNoInteractions auf 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“

  1. Warum zeigt ein Controller-Test bei einer unbehandelten Ausnahme Request failed unexpectedly statt einer 500-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“

  1. Warum antwortet GET /v1/angebote?richtung=zur-schule im Test mit 400, 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“


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]

Kommentare gerne per Mastodon, Verbesserungsvorschläge per gitlab issue (siehe oben). Beitrag teilen: