Übungsaufgaben zum Controller-Integrationstest: Die Gesuch-Endpunkte absichern

https://bildung.social/@oerinformatik/

https://oer-informatik.de/spring-boot-controller-integrationstest-uebungsaufgaben

Integrationstest, Web-Schicht, WebMvcTest, MockMvcTester, MockitoBean, Controller-Test, Problem Details, Testslice, Übungsaufgaben

tl/dr; (ca. 34 min Lese- und Bearbeitungszeit): Übungen zum Integrationstest der Web-Schicht im Mitfahr-Backend: Du ordnest zu, was @WebMvcTest startet, liest einen Controller-Test, schreibst Tests für das Einstellen von Mitfahrgesuchen und ihre Fehlerfälle, findest heraus, warum ein Test ohne @WebMvcTest mit „Request failed unexpectedly“ scheitert, und bewertest, was ein Test mit Mocks wirklich beweist [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

Diese Übungsaufgaben gehören zum Artikel Die Web-Schicht im Test: Controller-Integrationstests mit @WebMvcTest und MockMvcTester und setzen den Übungsstrang zu den Mitfahrgesuchen fort. Ausgangspunkt ist das Projekt im Ordner src/10-controller-integrationstest des Repositorys, ergänzt um die Klassen aus den bisherigen Übungen: Zeitfensterregel, Gesuchstatus, die Entity Gesuch, GesuchRepository, NeuesGesuch, GesuchService (mit stelleEin, zieheZurueck und finde) sowie GesuchMapper und GesuchController aus Übung 09 mit dem erweiterten Vertrag. Der GesuchController hängt nur vom GesuchService ab. Für die Gesuch-Endpunkte gibt es keine Enum-Werte in Query-Parametern; ein @Import der Konverter ist deshalb nicht nötig.

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

Hinweise zur Bearbeitung: Bis zur Teil-1-Prüfung der IT-Berufe solltest Du die Basiskompetenz beherrschen, also Aufgabe 1.1 und Aufgabe 1.2. Als FIA, FIDP oder FIDV musst Du zur Teil-2-Prüfung Aufgaben auf Kernkompetenz-Ebene lösen können, das sind Aufgabe 2.1 und Aufgabe 2.2. Von FIA wird zusätzlich erwartet, dass ihnen Aufgaben auf Profilkompetenz-Ebene (Aufgabe 3.1 und Aufgabe 3.2) keine Probleme bereiten. Notiere Deine Antworten zuerst vollständig, bevor Du die Beispiellösung aufklappst. Code und Tests kannst Du anschließend im Projekt aus dem Repository mit dem Build ausführen; andere fachlich richtige Lösungen sind ebenso richtig.

Du möchtest eigene Aufgaben zum Üben von einem KI-Sprachmodell generieren lassen?

Kopiere den Text in ein KI-Sprachmodell Deiner Wahl und passe Kompetenzstufe, Anzahl, Bearbeitungszeit und Szenario bei Bedarf an. Wie die Aufgaben aufgebaut sind, beschreibt der Artikel Wie sind die Aufgaben aufgebaut?. Prüfe die Ergebnisse mit dem Lehrartikel: KI-Sprachmodelle machen Fehler.

Erstelle neue Übungsaufgaben zum Thema „Controller-Integrationstest: Die
Gesuch-Endpunkte absichern“.

Bauplan: Halte Dich an Aufbau, Kompetenzstufen, Operatoren,
Anforderungsbereiche, Punkte und Zeitschätzung, die im Artikel
https://oer-informatik.de/aufgaben-aufbau beschrieben sind.

Fachliche Grundlage:
- https://oer-informatik.de/spring-boot-controller-integrationstest
- https://oer-informatik.de/spring-boot-rest-api-fehlerbehandlung
- https://oer-informatik.de/spring-boot-service-dependency-injection
- Stilbeispiel: https://oer-informatik.de/spring-boot-controller-integrationstest-uebungsaufgaben

Die Aufgaben sollen Inhalte zu diesen Schlagwörtern abdecken:
- Integrationstest
- Web-Schicht
- WebMvcTest
- MockMvcTester
- MockitoBean
- Controller-Test
- Problem Details
- Testslice

Kompetenzstufe: Basiskompetenz (alternativ: Kernkompetenz, Profilkompetenz, Expertise)
Anzahl: 1 Aufgabe mit Teilaufgaben
Bearbeitungszeit in Minuten ca.: <5 bis 30 min>
Szenario: eines der Szenarien aus dem Bauplan

Vorgehen:
1. Lies zuerst die verlinkten Artikel. Kannst Du keine Webseiten
   abrufen, sag mir das, statt zu raten.
2. Steht bei einer Angabe noch ein Platzhalter in <…>, frag mich
   zuerst danach. Richte Punkte und Zahl der Teilaufgaben nach der
   Bearbeitungszeit (ca. 0,9 min je Punkt).
3. Gib je Aufgabe Titel mit Kompetenzstufe, Ausgangssituation und
   Teilaufgaben mit Operator und Punktzahl aus, dazu die geschätzte
   Bearbeitungszeit.
4. Gib die Beispiellösungen mit Punkteverteilung erst aus, wenn ich
   danach frage.

Aufgabe 1.1: Was startet @WebMvcTest? (Basiskompetenz)

Ein Test der Gesuch-Endpunkte beginnt mit @WebMvcTest(GesuchController.class). Ordne jedem Bestandteil zu, ob er im Test echt geladen, als Mock (nur wenn der Test ihn mit @MockitoBean anlegt) oder nicht geladen wird. (6 Punkte)

Bestandteile der Anwendung im Test mit @WebMvcTest(GesuchController.class)
Nr. Bestandteil echt / Mock / nicht geladen
1 GesuchController
2 ProblemBehandlung (@RestControllerAdvice)
3 GesuchService
4 GesuchRepository
5 AngebotController
6 H2-Datenbank mit den Startdaten aus data.sql

Je Zeile 1 Punkt:

Zuordnung der Bestandteile
Nr. Bestandteil Zuordnung
1 GesuchController echt: Er ist der Controller, den der Test nennt.
2 ProblemBehandlung echt: @ControllerAdvice gehört zur Web-Schicht und wird immer mitgeladen.
3 GesuchService Mock: Der Controller braucht ihn; ohne @MockitoBean scheitert der Kontext mit NoSuchBeanDefinitionException.
4 GesuchRepository nicht geladen: Nur der Service bräuchte es, und der ist ein Mock.
5 AngebotController nicht geladen: @WebMvcTest(GesuchController.class) beschränkt den Ausschnitt auf einen Controller.
6 H2-Datenbank nicht geladen: Der Ausschnitt startet keine Persistenz.

Aufgabe 1.2: Einen Controller-Test lesen (Basiskompetenz)

Gegeben ist dieser Test aus der Klasse GesuchControllerTest:

    @Test
    void unbekanntesGesuchErgibt404() {
        when(gesuchService.finde(99L)).thenReturn(Optional.empty());

        assertThat(mvc.get().uri("/v1/gesuche/99"))
                .hasStatus(HttpStatus.NOT_FOUND)
                .bodyJson().extractingPath("$.detail").isEqualTo("Gesuch 99 existiert nicht.");
    }
  1. Gib an, welcher Teil des Tests zu Arrange, welcher zu Act und welcher zu Assert gehört. (1 Punkt)

  2. Erkläre, woher der Text „Gesuch 99 existiert nicht.“ kommt, obwohl der Mock nur ein leeres Optional liefert. (2 Punkte)

  3. Ein Teammitglied löscht die Zeile mit when(...). Der Test bleibt grün. Erkläre, warum. (2 Punkte)

  1. Arrange: when(gesuchService.finde(99L)).thenReturn(Optional.empty()); Act: mvc.get().uri("/v1/gesuche/99"); Assert: die Prüfungen ab hasStatus (1 Punkt; Act und Assert stehen hier in einer Anweisung).

  2. Der GesuchController wirft bei leerem Optional eine NichtGefundenException("Gesuch", 99L) (1 Punkt). Die ProblemBehandlung ist im Testausschnitt geladen und macht daraus 404 mit der Meldung der Ausnahme in detail (1 Punkt).

  3. Ein Mockito-Mock liefert für nicht festgelegte Methoden einen Standardwert: null, 0, false, leere Listen und für Optional ein leeres Optional (1 Punkt). Der Test bekommt also auch ohne when(...) dasselbe Ergebnis; ausprobiert im Projekt, der Test ist weiterhin grün. Die Zeile bleibt trotzdem sinnvoll, weil sie die Annahme des Tests sichtbar macht (1 Punkt).

Aufgabe 2.1: Einen Test für das Einstellen eines Gesuchs schreiben (Kernkompetenz)

Oksana Melnyk (Nutzer 3) sucht für Dienstag, den 24.11.2026, zwischen 06:45 und 07:15 Uhr eine Mitfahrt am Haltepunkt 1 zur Schule. Der Service legt das Gesuch mit der ID 4 an. Die Klasse GesuchControllerTest hat schon diesen Rahmen:

@WebMvcTest(GesuchController.class)
class GesuchControllerTest {

    private static final String NEUES_GESUCH = """
            {"mitfahrerId": 3, "haltepunktId": 1, "richtung": "zur-schule", "datum": "2026-11-24",
             "zeitVon": "06:45", "zeitBis": "07:15"}
            """;

    @Autowired
    private MockMvcTester mvc;

    @MockitoBean
    private GesuchService gesuchService;

    /** Gesuch 4 von Oksana mit den Daten aus NEUES_GESUCH, Status OFFEN. */
    private static Gesuch gesuch4() {
        // ... siehe Projekt
    }
}

Schreibe die Testmethode neuesGesuchErgibt201. Sie soll prüfen, dass die Antwort den Status 201, den Location-Header /v1/gesuche/4 und im Body die ID 4 und den Status offen enthält, und dass der Controller dem Service die richtigen Werte übergibt. Importe musst Du nicht notieren. (7 Punkte)

    @Test
    void neuesGesuchErgibt201() {
        when(gesuchService.stelleEin(any())).thenReturn(gesuch4());

        var antwort = mvc.post().uri("/v1/gesuche").contentType(MediaType.APPLICATION_JSON).content(NEUES_GESUCH);

        assertThat(antwort).hasStatus(HttpStatus.CREATED)
                .hasHeader("Location", "/v1/gesuche/4")
                .bodyJson().isLenientlyEqualTo("""
                        {"id": 4, "mitfahrerId": 3, "haltepunktId": 1, "richtung": "zur-schule",
                         "datum": "2026-11-24", "zeitVon": "06:45", "zeitBis": "07:15", "status": "offen"}
                        """);
        verify(gesuchService).stelleEin(new NeuesGesuch(3L, 1L, Fahrtrichtung.ZUR_SCHULE,
                LocalDate.of(2026, 11, 24), LocalTime.of(6, 45), LocalTime.of(7, 15)));
    }

Punkte: Mock mit when(…stelleEin(any())).thenReturn(…) festlegen (1), Anfrage mit post().uri(…), contentType und content (2), Status und Location-Header prüfen (2), Body prüfen, z. B. mit isLenientlyEqualTo oder zwei extractingPath-Prüfungen für $.id und $.status (1), verify mit dem erwarteten NeuesGesuch (1). Weil NeuesGesuch ein Record ist, vergleicht verify alle Felder über equals; eine Lösung mit ArgumentCaptor ist ebenso richtig. Im Projekt ausgeführt ist der Test grün.

Aufgabe 2.2: Fehlerfälle der Gesuch-Endpunkte testen (Kernkompetenz)

Ergänze GesuchControllerTest um drei Testmethoden. Importe musst Du nicht notieren.

  1. Der Service lehnt ein Gesuch von 07:30 bis 07:15 Uhr mit einer ZeitfensterUngueltigException ab. Prüfe Status, Content-Type und type der Antwort. (3 Punkte)

  2. Die Uhrzeit "6:45" (ohne führende Null) passt nicht zum Vertrag. Prüfe den Status, die Erweiterung fehler und dass der Service nicht aufgerufen wird. (3 Punkte)

  3. Gesuch 1 wurde schon zurückgezogen; ein erneutes PATCH /v1/gesuche/1 mit {"status": "zurueckgezogen"} führt im Service zu einer GesuchNichtOffenException. Prüfe Status und type. (2 Punkte)

  1. (3 Punkte: Mock wirft Ausnahme 1, Anfrage 1, Prüfungen 1)
    @Test
    void ungueltigesZeitfensterErgibt422() {
        when(gesuchService.stelleEin(any()))
                .thenThrow(new ZeitfensterUngueltigException("Das Zeitfenster muss vor seinem Ende beginnen."));

        assertThat(mvc.post().uri("/v1/gesuche").contentType(MediaType.APPLICATION_JSON)
                .content(NEUES_GESUCH.replace("\"06:45\"", "\"07:30\"")))
                .hasStatus(HttpStatus.UNPROCESSABLE_CONTENT)
                .hasContentType(MediaType.APPLICATION_PROBLEM_JSON)
                .bodyJson().extractingPath("$.type")
                .isEqualTo("https://mitfahrlabor.example/probleme/zeitfenster-ungueltig");
    }
  1. (3 Punkte: Anfrage ohne Mock-Festlegung 1, Status und fehler 1, verifyNoInteractions 1)
    @Test
    void uhrzeitOhneFuehrendeNullErgibt400OhneServiceAufruf() {
        assertThat(mvc.post().uri("/v1/gesuche").contentType(MediaType.APPLICATION_JSON)
                .content(NEUES_GESUCH.replace("\"06:45\"", "\"6:45\"")))
                .hasStatus(HttpStatus.BAD_REQUEST)
                .bodyJson().extractingPath("$.fehler").asArray()
                .containsExactly("zeitVon: must match \"^([01][0-9]|2[0-3]):[0-5][0-9]$\"");
        verifyNoInteractions(gesuchService);
    }
  1. (2 Punkte: Mock wirft Ausnahme 1, PATCH mit Prüfung von Status und type 1)
    @Test
    void zurueckgezogenesGesuchNochmalZurueckziehenErgibt409() {
        when(gesuchService.zieheZurueck(1L)).thenThrow(new GesuchNichtOffenException(Gesuchstatus.ZURUECKGEZOGEN));

        assertThat(mvc.patch().uri("/v1/gesuche/1").contentType(MediaType.APPLICATION_JSON)
                .content("{\"status\": \"zurueckgezogen\"}"))
                .hasStatus(HttpStatus.CONFLICT)
                .bodyJson().extractingPath("$.type")
                .isEqualTo("https://mitfahrlabor.example/probleme/gesuch-nicht-offen");
    }

Alle drei Tests sind im Projekt grün. Statt extractingPath("$.type") ist auch isLenientlyEqualTo mit type und status richtig.

Aufgabe 3.1: Request failed unexpectedly (Profilkompetenz)

Ein Teammitglied findet den Spring-Kontext unnötig und schreibt den Test für das ungültige Zeitfenster ohne @WebMvcTest:

class GesuchControllerStandaloneTest {

    private final GesuchService gesuchService = mock(GesuchService.class);

    @Test
    void ungueltigesZeitfensterErgibt422() {
        when(gesuchService.stelleEin(any()))
                .thenThrow(new ZeitfensterUngueltigException("Das Zeitfenster muss vor seinem Ende beginnen."));
        MockMvcTester mvc = MockMvcTester.of(new GesuchController(gesuchService));

        assertThat(mvc.post().uri("/v1/gesuche").contentType(MediaType.APPLICATION_JSON).content(GESUCH))
                .hasStatus(HttpStatus.UNPROCESSABLE_CONTENT);
    }
}

MockMvcTester.of(...) baut die Infrastruktur von Spring MVC nur um die übergebenen Controller herum auf, ohne Spring-Kontext. Der Test scheitert mit:

java.lang.AssertionError:
Request failed unexpectedly:
 jakarta.servlet.ServletException: Request processing failed: de.dbh.mitfahrlabor.domain.ZeitfensterUngueltigException: Das Zeitfenster muss vor seinem Ende beginnen.
  1. Erkläre die Ursache. Gehe darauf ein, warum keine 500-Antwort entsteht. (3 Punkte)

  2. Nenne zwei Möglichkeiten, den Test zu reparieren, und begründe, welche Du für dieses Projekt wählst. (3 Punkte)

  1. Ohne Spring-Kontext gibt es nur den übergebenen Controller; die ProblemBehandlung mit @RestControllerAdvice ist nicht registriert (1 Punkt). Die ZeitfensterUngueltigException findet deshalb keine @ExceptionHandler-Methode und läuft aus dem DispatcherServlet heraus (1 Punkt). Eine 500-Antwort würde im Betrieb erst der Servlet-Container mit der Fehlerseite von Spring Boot erzeugen; den gibt es im Test nicht, deshalb meldet der MockMvcTester eine gescheiterte Anfrage (1 Punkt).

  2. Möglichkeiten (je 1 Punkt): @WebMvcTest(GesuchController.class) mit @MockitoBean verwenden, dann lädt Spring die ProblemBehandlung automatisch; oder im Standalone-Aufbau die Fehlerbehandlung von Hand ergänzen:

        MockMvcTester mvc = MockMvcTester.create(MockMvcBuilders.standaloneSetup(new GesuchController(gesuchService))
                .setControllerAdvice(new ProblemBehandlung())
                .build());

Beide Varianten sind im Projekt grün. Begründete Wahl (1 Punkt), z. B.: @WebMvcTest, weil der Test dann dieselbe Konfiguration wie die Anwendung nutzt (Advice, Jackson-Einstellungen, Validierung, weitere WebMvcConfigurer) und neue Bestandteile der Web-Schicht nicht in jedem Test von Hand nachgetragen werden müssen. Der Standalone-Aufbau startet etwas schneller, prüft aber nur, was man ihm ausdrücklich mitgibt.

Aufgabe 3.2: Was beweist dieser Test? (Profilkompetenz)

Ein Teammitglied legt diesen Test vor und sagt: „Damit ist die Zeitfensterregel für Gesuche jetzt auch im Web getestet.“

    @Test
    void zeitfensterLaengerAls90MinutenWirdAbgelehnt() {
        when(gesuchService.stelleEin(any()))
                .thenThrow(new ZeitfensterUngueltigException("Das Zeitfenster darf höchstens 90 Minuten lang sein."));

        assertThat(mvc.post().uri("/v1/gesuche").contentType(MediaType.APPLICATION_JSON)
                .content(NEUES_GESUCH.replace("\"07:15\"", "\"08:30\"")))
                .hasStatus(HttpStatus.UNPROCESSABLE_CONTENT);
    }
  1. Beurteile die Aussage des Teammitglieds. (2 Punkte)

  2. Ordne die folgenden Prüfungen jeweils der Testart zu, auf der sie im Projekt am besten aufgehoben sind (Komponententest, Integrationstest Web-Schicht, Systemtest), und begründe kurz: (1) Ein Zeitfenster von genau 90 Minuten ist erlaubt. (2) Ein Gesuch mit "zeitVon": "6:45" ergibt 400, ohne dass der Service aufgerufen wird. (3) Ein eingestelltes Gesuch lässt sich danach mit GET /v1/gesuche/{id} aus der Datenbank abrufen. (3 Punkte)

  1. Die Aussage ist falsch (1 Punkt): Der Mock wirft die Ausnahme bei jeder Anfrage, egal welches Zeitfenster im Body steht; der Test wäre mit "08:30" genauso grün wie mit "07:00". Er beweist nur, dass die Web-Schicht eine ZeitfensterUngueltigException in 422 übersetzt. Ob die Regel 90 Minuten richtig erkennt, prüft der Komponententest der Zeitfensterregel. Der Testname sollte deshalb die Übersetzung beschreiben, nicht die Regel (1 Punkt).

  2. Je Zeile 1 Punkt:

Zuordnung der Prüfungen zu Testarten
Nr. Testart Begründung
1 Komponententest reine Fachregel mit Grenzwert; schnell und ohne Spring prüfbar (Zeitfensterregel)
2 Integrationstest Web-Schicht Bean Validation aus dem Vertrag, ProblemBehandlung und verifyNoInteractions auf den Service-Mock
3 Systemtest braucht alle Schichten zusammen, einschließlich Service, Repository und Datenbank (Artikel 11)

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: “Übungsaufgaben zum Controller-Integrationstest: Die Gesuch-Endpunkte absichern” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/spring-boot-controller-integrationstest-uebungsaufgaben 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: