Systemtest und Abnahmetest: Die ganze Anwendung über HTTP prüfen mit RestTestClient
https://bildung.social/@oerinformatik/
https://oer-informatik.de/spring-boot-systemtest-abnahmetest
Systemtest, Abnahmetest, RestTestClient, SpringBootTest, Abnahmekriterien, Abnahmeprotokoll, CORS, API-Vertrag, TestBean, Testebenen
tl/dr; (ca. 25 min Lesezeit): Bisher hat jeder Test nur einen Ausschnitt der Anwendung gestartet. Jetzt läuft das Mitfahr-Backend vollständig mit Tomcat und Datenbank, und
RestTestClientschickt echte HTTP-Anfragen. Ein Systemtest prüft jede Antwort gegen den API-Vertragopenapi.yaml, ein Abnahmetest jedes Abnahmekriterium der User Stories aus Artikel 01. Beide finden je eine echte Lücke. Dazu kommen ein Abnahmeprotokoll, CORS für Browser-Frontends und eine eigene Stufe in der Pipeline [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 / 11 System- und Abnahmetest / Übungsaufgaben zu 11
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 Anforderungen und API-Vertrag, REST-API und Fehlerbehandlung und Controller-Integrationstest auf.
Als Grundlagen zu System- und Abnahmetest kann ich nach Durchlesen des Artikels…
… Systemtest und Abnahmetest im V-Modell einordnen und ihre Prüfgrundlage nennen (Systementwurf bzw. Anforderungen).
… mit
@SpringBootTest(webEnvironment = RANDOM_PORT)die ganze Anwendung mit Server und Datenbank für einen Test starten.… mit
RestTestClientHTTP-Anfragen schicken und Status, Header und JSON-Body der Antwort prüfen.… ein Abnahmekriterium in der Form Angenommen – Wenn – Dann in einen automatisierten Test übersetzen.
… ein Abnahmeprotokoll lesen und ausfüllen.
… erklären, warum ein Browser-Frontend von einer anderen Herkunft CORS braucht, und es in Spring MVC einrichten.
Darüber hinaus kann ich als Expertenwissen zu System- und Abnahmetests…
… eine Uhr mit
@TestBeanfestsetzen, damit zeitabhängige Tests nicht altern.… begründen, warum Systemtests ihre Testdaten selbst anlegen, statt sich auf ein Zurückrollen der Transaktion zu verlassen.
… Antworten automatisiert gegen den API-Vertrag prüfen und undokumentierte Statuscodes finden.
… System- und Abnahmetests im Build (Gradle-Aufgabe bzw. Failsafe) und in der Pipeline von den schnellen Tests trennen.
Nach Artikel 10 laufen 108 Tests, und die Anweisungsabdeckung liegt bei 93 %. Trotzdem hat noch kein Test die Anwendung so benutzt, wie es später ein Frontend tut: mit einem echten Server, echtem HTTP, allen Schichten und der Datenbank. Der Komponententest kennt nur eine Klasse, @DataJpaTest nur die Persistenz, @WebMvcTest nur die Web-Schicht mit Mocks. Ob alles zusammen das tut, was der Vertrag und die User Stories des Mitfahrlabors versprechen, ist bisher nur von Hand mit den .http-Dateien geprüft.
Im V-Modell erreicht die Reihe damit die beiden obersten Ebenen der rechten Seite: Der Systemtest prüft gegen den Systementwurf, also den API-Vertrag openapi.yaml aus Artikel 01. Der Abnahmetest prüft gegen die Anforderungen, also die User Stories mit ihren Abnahmekriterien.
Ausgangspunkt ist das Projekt aus Artikel 10. Das Ergebnis findest Du im Ordner src/11-systemtest-abnahmetest des Repositorys. Neu sind die Testklassen im Paket systemtest, eine CORS-Konfiguration, eine Gradle-Aufgabe systemTest und zwei Korrekturen, die die neuen Tests erzwungen haben.
Zwei Fragen an dieselbe Anwendung
System- und Abnahmetest benutzen dieselbe Technik, stellen aber verschiedene Fragen:
| Testebene | prüft gegen | Frage | im Projekt |
|---|---|---|---|
| Systemtest | Systementwurf: API-Vertrag openapi.yaml |
Antwortet jede Operation mit einem dokumentierten Statuscode, dem richtigen Medientyp und einem Body, der zum Schema passt? | VertragSystemTest, CorsSystemTest |
| Abnahmetest | Anforderungen: User Stories US-01 bis US-08 mit den Abnahmekriterien AK-xx.y | Kann eine Nutzerin das, was die Story verspricht? | AbnahmeTest, je Abnahmekriterium ein Test |
Der Systemtest ist die Sicht der Entwickler*innen: Hält das System, was der Entwurf festlegt? Der Abnahmetest ist die Sicht der Auftraggeber*innen: Erfüllt das System die vereinbarten Anforderungen? Die Abnahme selbst ist eine Entscheidung von Menschen; ein automatisierter Abnahmetest liefert dafür die Belege und läuft bei jeder Änderung erneut.
Die ganze Anwendung im Test starten
Alle System- und Abnahmetests erben von einer gemeinsamen Basisklasse:
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@Tag("system")
abstract class SystemtestBasis {
/** Dienstag, 20.10.2026, 06:00 Uhr in Deutschland; passt zu den Startdaten im Oktober 2026. */
static final Instant JETZT = Instant.parse("2026-10-20T04:00:00Z");
@TestBean
private Clock uhr;
@Autowired
protected RestTestClient client;
static Clock uhr() {
return Clock.fixed(JETZT, UhrKonfiguration.ZEITZONE);
}
// Hilfsmethoden bieteAn, frageAn, setzeStatus siehe Projekt
}@SpringBootTest startet den vollständigen Spring-Kontext, so wie MitfahrlaborApplication beim Start. Mit webEnvironment = RANDOM_PORT läuft zusätzlich ein echter Tomcat auf einem freien Port, damit sich zwei Testläufe oder eine laufende Anwendung auf Port 8080 nicht in die Quere kommen. Die Datenbank ist wie beim normalen Start die H2 im Arbeitsspeicher mit den Startdaten aus data.sql. Nichts ist durch Mocks ersetzt.
@AutoConfigureRestTestClient stellt einen RestTestClient bereit, der schon auf http://localhost:<port> zeigt. Das Modul dafür (spring-boot-resttestclient) bringt der Test-Starter spring-boot-starter-webmvc-test seit Artikel 02 mit. Die Annotation @Tag("system") aus JUnit markiert die Tests, damit der Build sie getrennt starten kann (Abschnitt „Im Build und in der Pipeline“).
Die Uhr anhalten
Die Startdaten in data.sql liegen im Oktober 2026. Mit der echten Uhr wären alle Angebote längst vergangen, und jedes Abnahmekriterium mit „heute“ oder „morgen“ hinge davon ab, an welchem Tag der Test läuft. Seit Artikel 08 holen sich die Services die Zeit aber aus einer Clock-Bean. Die Annotation @TestBean aus dem Spring Framework ersetzt genau diese Bean für die Tests: Sie sucht die Bean über den Typ des Feldes (Clock) und ruft die statische Methode mit dem Namen des Feldes (uhr()) auf. Für alle System- und Abnahmetests ist es damit immer Dienstag, der 20.10.2026, 06:00 Uhr; der nächste Schultag ist Mittwoch, der 21.10.2026.
Was nicht zurückgerollt wird
Bei @DataJpaTest in Artikel 07 lief jeder Test in einer Transaktion, die am Ende zurückgerollt wurde. Das geht hier nicht: Der Test und der Tomcat laufen in verschiedenen Threads, und die Transaktion im Server ist längst abgeschlossen, wenn der Test die Antwort prüft. Laut Spring-Boot-Dokumentation wird eine Transaktion im Server in diesem Fall nicht zurückgerollt, auch wenn der Test @Transactional trägt. Alle Testklassen mit derselben Konfiguration teilen sich außerdem einen Kontext und damit eine Datenbank.
Die Tests folgen deshalb einer einfachen Regel: Wer Daten ändert, legt sie vorher selbst über die API an. Ein Test für das Bestätigen einer Buchung bietet zuerst ein eigenes Angebot an und fragt einen Platz an, statt Buchung 5 aus data.sql zu verändern. Nur Tests, die lesen oder einen abgelehnten Versuch prüfen, nutzen die Startdaten direkt. So ist das Ergebnis unabhängig von der Reihenfolge der Tests.
Anfragen mit RestTestClient
RestTestClient ist neu im Spring Framework 7. Er baut auf dem RestClient auf und bringt eingebaute Prüfungen mit, ähnlich dem WebTestClient aus der reaktiven Welt. Der Test für das erste Abnahmekriterium:
@Test
@DisplayName("AK-01.1 Liste enthält alle sechs Haltepunkte mit Name, Ort und Entfernung")
void ak0101() {
client.get().uri("/v1/haltepunkte").exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.length()").isEqualTo(6)
.jsonPath("$[0].name").isEqualTo("Hauptbahnhof, ZOB Steig 3")
.jsonPath("$[0].ort").isEqualTo("Gütersloh")
.jsonPath("$[0].entfernungKm").isEqualTo(18.6);
}Die Anfrage beginnt mit der HTTP-Methode, exchange() schickt sie ab, danach folgen die Prüfungen. Die wichtigsten Bausteine:
| Baustein | Aufgabe |
|---|---|
client.post().uri("/v1/angebote/{id}/buchungen", 8) |
Methode und Pfad, Platzhalter werden ersetzt |
.contentType(MediaType.APPLICATION_JSON).body(json) |
Body und Medientyp der Anfrage |
.header(HttpHeaders.ORIGIN, "http://localhost:5173") |
beliebiger Header, z. B. für CORS |
.exchange() |
Anfrage schicken |
.expectStatus().isCreated(), .isEqualTo(422) |
Statuscode prüfen |
.expectHeader().valueEquals("Location", …) |
Header prüfen |
.expectBody().jsonPath("$.status").isEqualTo("offen") |
einzelne Werte im JSON prüfen |
.returnResult(Angebot.class).getResponseBody() |
Antwort als Objekt lesen, z. B. als erzeugtes Modell api.model.Angebot |
Mit returnResult liest der Test die Antwort in die Klassen, die der OpenAPI Generator in Artikel 03 erzeugt hat. Das ist praktisch für Folgeschritte: Die Hilfsmethode bieteAn legt ein Angebot an und gibt mit .returnResult(Angebot.class).getResponseBody().getId() dessen ID zurück.
Schlägt eine Prüfung fehl, schreibt RestTestClient die ganze Anfrage und Antwort ins Testprotokoll. Das hilft, wie der nächste Abschnitt zeigt.
Systemtest gegen den Vertrag
Der Vertrag beschreibt für jede Operation, welche Statuscodes es gibt und welches Schema der Body hat. Die Hilfsklasse Vertrag im Testordner liest openapi.yaml mit SnakeYAML (kommt mit Spring Boot) und prüft eine echte Antwort dagegen: Ist der Statuscode für diese Operation dokumentiert? Passt der Content-Type? Enthält der Body alle Pflichtfelder, und passen Typen, Aufzählungswerte, Muster wie HH:MM und Datumsformate? Sie deckt nur den Teil von OpenAPI ab, den der Vertrag nutzt, und kommt mit gut 150 Zeilen aus. Für größere Projekte gibt es fertige Validierungsbibliotheken.
/** z. B. pruefe(HttpMethod.GET, "/angebote/{angebotId}", ergebnis). */
static void pruefe(HttpMethod methode, String pfad, EntityExchangeResult<byte[]> ergebnis) {
String status = String.valueOf(ergebnis.getStatus().value());
Map<String, Object> operation = knoten(knoten(SPEZIFIKATION, "paths"), pfad);
operation = knoten(operation, methode.name().toLowerCase());
assertThat(operation).as("Operation %s %s im Vertrag", methode, pfad).isNotNull();
Map<String, Object> antwort = aufloesen(knoten(knoten(operation, "responses"), status));
assertThat(antwort).as("Status %s ist für %s %s nicht dokumentiert", status, methode, pfad).isNotNull();
// ... Content-Type und Body gegen das Schema prüfen, siehe Projekt
}Der VertragSystemTest ruft jede der elf Operationen mindestens einmal mit Erfolg und einmal mit einem typischen Fehler auf und gibt jede Antwort an Vertrag.pruefe:
@Test
void haltepunkte() {
Vertrag.pruefe(HttpMethod.GET, "/haltepunkte", hole("/v1/haltepunkte"));
Vertrag.pruefe(HttpMethod.GET, "/haltepunkte/{haltepunktId}", hole("/v1/haltepunkte/1"));
Vertrag.pruefe(HttpMethod.GET, "/haltepunkte/{haltepunktId}", hole("/v1/haltepunkte/99"));
Vertrag.pruefe(HttpMethod.GET, "/haltepunkte/{haltepunktId}", hole("/v1/haltepunkte/eins"));
}Fund 1: ein Statuscode, den der Vertrag nicht kennt
Beim ersten Lauf waren zwei der vier Vertragstests rot:
java.lang.AssertionError: [Status 400 ist für GET /haltepunkte/{haltepunktId} nicht dokumentiert]
Expecting actual not to be null
at de.dbh.mitfahrlabor.systemtest.Vertrag.pruefe(Vertrag.java:41)
at de.dbh.mitfahrlabor.systemtest.VertragSystemTest.haltepunkte(VertragSystemTest.java:25)
Dieselbe Meldung kam für GET /angebote/{angebotId}/buchungen. Die Anwendung antwortet auf /v1/haltepunkte/eins völlig vernünftig mit 400 und Problem Details („Failed to convert ‘haltepunktId’ …“), der Controller-Test aus Artikel 10 prüft das sogar. Im Vertrag stehen für die lesenden Operationen mit ID aber nur 200 und 404. Ein Frontend, das seinen Client aus dem Vertrag erzeugt, rechnet also nicht mit 400.
Weil der Vertrag führend ist (contract-first), wird er korrigiert, nicht die Anwendung: Die vier Operationen holeHaltepunkt, holeAngebot, listeBuchungenZuAngebot und holeBuchung bekommen die Antwort 400, die es schon für die anderen Operationen gibt:
responses:
'200':
# ... wie bisher
'400':
$ref: '#/components/responses/UngueltigeAnfrage'
'404':
$ref: '#/components/responses/NichtGefunden'Die Beschreibung von UngueltigeAnfrage nennt jetzt auch „Text statt Zahl als ID“. Am erzeugten Code ändert sich nichts, weil Antworten die Signaturen der Schnittstellen nicht beeinflussen. Danach sind alle Vertragstests grün. Dass der Body des Fehlers 400 die Erweiterung fehler enthält, die im Schema Problem nicht steht, meldet die Prüfung bewusst nicht: In OpenAPI sind zusätzliche Felder erlaubt, solange das Schema sie nicht verbietet.
Abnahmetest gegen die User Stories
Für den Abnahmetest wird jedes Abnahmekriterium aus src/01-anforderungen-api-entwurf/user-stories.md zu einem Test. Der Name in @DisplayName beginnt mit der Nummer des Kriteriums; @TestMethodOrder(MethodOrderer.DisplayName.class) sortiert die Tests danach, damit der Testbericht in der Reihenfolge der Stories erscheint. Aus „Angenommen – Wenn – Dann“ wird „Arrange – Act – Assert“:
@Test
@DisplayName("AK-07.1 Stornieren einer bestätigten Buchung: Angebot wieder offen mit 1 freien Platz")
void ak0701() {
// Angenommen: Buchung bestätigt, Angebot voll
long angebot = bieteAn(1, 1, MORGEN, "07:40", 1, "1.80");
long buchung = frageAn(angebot, 2);
setzeStatus(buchung, "bestaetigt").expectStatus().isOk();
// Wenn: Aylin storniert
setzeStatus(buchung, "storniert").expectStatus().isOk()
.expectBody().jsonPath("$.status").isEqualTo("storniert");
// Dann: Angebot wieder offen mit einem freien Platz
Angebot danach = angebot(angebot);
assertThat(danach.getStatus()).hasToString("offen");
assertThat(danach.getFreiePlaetze()).isEqualTo(1);
}Ben (Nutzer 1) bietet dazu morgen um 07:40 Uhr einen Platz am Hauptbahnhof Gütersloh an, Aylin (Nutzerin 2) fragt an. Jeder Test, der Daten ändert, wählt eine eigene Abfahrtszeit, damit sich die Tests nicht gegenseitig stören. Für die 24 Abnahmekriterien von US-01 bis US-08 entstehen so 24 Tests. Einige Kriterien prüfen mehrere Fälle in einem Test, zum Beispiel AK-05.4 eine volle und eine abgesagte Fahrt.
Fund 2: ein Kriterium, das niemand umgesetzt hat
Beim ersten Lauf war genau ein Abnahmetest rot. AK-02.4 lautet: „Wenn das Datum in der Vergangenheit liegt oder die Zahl der Plätze nicht zwischen 1 und 8 liegt, dann wird das Angebot abgelehnt.“ Die Plätze prüft Bean Validation aus dem Vertrag. Für das Datum aber kam:
java.lang.AssertionError: Status expected:<422 UNPROCESSABLE_CONTENT> but was:<201 CREATED>
...
> POST http://localhost:43163/v1/angebote
> Content-Type: [application/json]
{"fahrerId": 1, "haltepunktId": 1, "richtung": "zur-schule", "datum": "2026-10-19",
"abfahrt": "07:00", "freiePlaetze": 2, "kostenbeitragEur": 1.80}
< 201 CREATED Created
< content-type: [application/json]
< location: [/v1/angebote/9]
{"fahrerId":1,"haltepunktId":1,"richtung":"zur-schule","datum":"2026-10-19","abfahrt":"07:00","freiePlaetze":2,"kostenbeitragEur":1.8,"id":9,"status":"offen"}
Ein Angebot für gestern wird angelegt. Die Anforderung stand seit Artikel 01 in der User Story, aber weder der Vertrag (ein Datum „nicht in der Vergangenheit“ lässt sich in OpenAPI nicht ausdrücken) noch die Fachregeln 1 bis 6 haben sie aufgenommen. Alle 108 Tests der unteren Ebenen waren grün, weil sie nur prüfen, was jemand umgesetzt hat. Genau diese Lücke zwischen Anforderung und Umsetzung soll der Abnahmetest finden.
Die Korrektur gehört in den AngebotService, der dafür wie der BuchungService die Clock bekommt, und in eine neue Ausnahme, die die ProblemBehandlung ohne Änderung als 422 beantwortet:
public class FahrtdatumVergangenException extends FachregelVerletztException {
public FahrtdatumVergangenException(LocalDate fahrtdatum, LocalDate heute) {
super("Das Fahrtdatum " + fahrtdatum + " liegt vor dem heutigen Tag (" + heute + ").");
}
} @Transactional
public Angebot legeAn(NeuesAngebot neu) {
// ... Fahrer*in und Haltepunkt laden wie bisher
LocalDate heute = LocalDate.now(uhr);
if (neu.datum().isBefore(heute)) {
throw new FahrtdatumVergangenException(neu.datum(), heute);
}
// ... Führerschein, Einwilligung, Kostenregel wie bisher
}Ein Angebot für heute bleibt erlaubt. Der Komponententest AngebotServiceTest erzeugt den Service jetzt in einer @BeforeEach-Methode selbst mit einer festen Uhr, statt @InjectMocks zu nutzen, und prüft die neue Regel in einem zusätzlichen Test. Gegen die laufende Anwendung antwortet derselbe Aufruf jetzt so:
HTTP/1.1 422
Content-Type: application/problem+json
{"detail":"Das Fahrtdatum 2026-10-19 liegt vor dem heutigen Tag (2026-11-10).","instance":"/v1/angebote","status":422,"title":"Fachregel verletzt","type":"https://mitfahrlabor.example/probleme/fahrtdatum-vergangen"}
Hier zeigt sich auch der Wert der festen Uhr: Die Anwendung selbst läuft mit der echten Uhr (10.11.2026), im Test ist immer der 20.10.2026. Die Beispiele in den .http-Dateien dagegen altern: Sobald ihr Datum vorbei ist, antworten sie mit 422.
Das Abnahmeprotokoll
Die Abnahme ist ein formaler Schritt: Die Auftraggeber*innen erklären, dass das Ergebnis den Anforderungen entspricht, oder sie nennen die Mängel. Das Abnahmeprotokoll hält fest, was gegen welche Anforderungen geprüft wurde, mit welchem Ergebnis und welche Entscheidung daraus folgt. Der Testbericht des Abnahmetests liefert die Belege; Gradle schreibt ihn nach build/reports/tests/systemTest/index.html, Maven nach target/failsafe-reports/. Für den Stand nach diesem Artikel:
| Abnahmeprotokoll | |
|---|---|
| Gegenstand | Mitfahrlabor-Backend (MVP), Stand src/11-systemtest-abnahmetest |
| Grundlage | User Stories US-01 bis US-08 mit 24 Abnahmekriterien (user-stories.md), API-Vertrag openapi.yaml 1.0.0 |
| Durchführung | automatisiert, AbnahmeTest (24 Tests) und VertragSystemTest/CorsSystemTest (7 Tests), H2 mit Startdaten, Uhr 20.10.2026 06:00 |
| Ergebnis 1. Lauf | 23 von 24 Abnahmekriterien erfüllt; AK-02.4 nicht erfüllt (Angebot mit vergangenem Datum wird angelegt); Vertrag: 400 bei ungültiger ID nicht dokumentiert |
| Maßnahmen | Fachregel Fahrtdatum im AngebotService ergänzt; Vertrag um 400 für vier Operationen ergänzt |
| Ergebnis 2. Lauf | 24 von 24 Abnahmekriterien erfüllt, alle Vertragstests grün |
| Nicht geprüft | Betrieb mit MariaDB, Last und Antwortzeiten, Bedienung durch ein Frontend |
| Entscheidung | abgenommen (Datum, Unterschrift Auftraggeber*in, Unterschrift Entwicklung) |
Die Zeile „Nicht geprüft“ ist wichtig: Ein grüner Abnahmetest beweist nur, was die Kriterien verlangen. Was dort nicht steht, wie Antwortzeiten oder die Bedienbarkeit, muss die Abnahme anders klären oder ausdrücklich ausklammern.
CORS: Damit ein Browser-Frontend die API aufrufen darf
Die Frontends der nächsten Serien laufen während der Entwicklung auf einem eigenen Server, etwa Vite unter http://localhost:5173, die API unter http://localhost:8080. Für den Browser sind das zwei verschiedene Herkünfte (englisch origin: Schema, Host und Port). Nach der Same-Origin-Policy darf ein Skript der einen Herkunft die Antworten der anderen nur lesen, wenn der Server das mit CORS (Cross-Origin Resource Sharing) ausdrücklich erlaubt. Bei Anfragen wie PATCH oder JSON-Bodys fragt der Browser vorher mit einer Vorabanfrage (preflight, OPTIONS) nach:
CORS schützt also nicht den Server, sondern die Nutzer*innen im Browser: curl und die .http-Dateien fragen nicht vorab und bekommen jede Antwort. In Spring MVC erlaubt eine Konfigurationsklasse die Zugriffe für alle Pfade unter /v1:
@Configuration
public class CorsKonfiguration implements WebMvcConfigurer {
private final String[] erlaubteUrspruenge;
public CorsKonfiguration(@Value("${mitfahrlabor.cors.erlaubte-urspruenge}") String[] erlaubteUrspruenge) {
this.erlaubteUrspruenge = erlaubteUrspruenge;
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/v1/**")
.allowedOrigins(erlaubteUrspruenge)
.allowedMethods("GET", "POST", "PATCH")
.exposedHeaders(HttpHeaders.LOCATION);
}
}Die erlaubten Herkünfte stehen in application.properties, damit der Betrieb sie ohne neuen Build ändern kann (mehrere mit Komma getrennt):
mitfahrlabor.cors.erlaubte-urspruenge=http://localhost:5173
Zwei Einstellungen weichen bewusst vom Standard ab: Ohne allowedMethods erlaubt Spring nur GET, HEAD und POST; das PATCH zum Bestätigen einer Buchung würde scheitern. Und ohne exposedHeaders könnte das Skript den Location-Header einer 201-Antwort nicht lesen, obwohl er in der Antwort steht. Ein Stern * als Herkunft wäre bequem, ist aber nicht mehr erlaubt, sobald Anmeldedaten mitgeschickt werden (Artikel 12); Spring verlangt dann konkrete Herkünfte oder Muster.
Der CorsSystemTest prüft drei Fälle: Die Vorabanfrage des Frontends wird erlaubt, die einer fremden Seite abgelehnt, und eine echte POST-Antwort gibt den Location-Header frei. Vor der Konfiguration waren alle drei rot. Aufschlussreich ist der zweite: Ohne CORS-Konfiguration antwortete die Anwendung auch einer fremden Seite mit 200, nur eben ohne Access-Control-Allow-Origin. Abgelehnt hat dann erst der Browser. Mit Konfiguration lehnt Spring selbst ab:
HTTP/1.1 403
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Invalid CORS request
Im Build und in der Pipeline
Die 31 neuen Tests starten die ganze Anwendung. Sie sollen nicht bei jedem schnellen Testlauf mitlaufen, aber vor jeder Auslieferung. Deshalb schließt die normale Testaufgabe sie über den Tag system aus, und eine eigene Aufgabe startet nur sie:
In build.gradle.kts:
// System- und Abnahmetests (Tag "system") starten die ganze Anwendung und laufen in einer eigenen Aufgabe
tasks.test {
useJUnitPlatform { excludeTags("system") }
}
val systemTest by tasks.registering(Test::class) {
description = "Startet die ganze Anwendung und prüft sie über HTTP (System- und Abnahmetest)."
group = "verification"
testClassesDirs = sourceSets["test"].output.classesDirs
classpath = sourceSets["test"].runtimeClasspath
useJUnitPlatform { includeTags("system") }
shouldRunAfter(tasks.test)
}
tasks.check { dependsOn(systemTest) }Eine eigene Test-Aufgabe braucht in Gradle 9 ausdrücklich die Testklassen und den Klassenpfad.
Starten lassen sich die System- und Abnahmetests allein so:
.\gradlew.bat systemTestBei Gradle läuft systemTest auch mit check, bei Maven mit verify (nach den übrigen Tests). Im Testlauf für diesen Artikel liefen 109 Tests in test (einer mehr als bisher, für die neue Regel) und 31 in systemTest, alle grün. Der Abnahmetest brauchte rund 15 Sekunden, davon das meiste für den Start der Anwendung; die anderen Klassen nutzen den Kontext danach wieder. Die Abdeckung aus der Testaufgabe test liegt bei 94 % der Anweisungen (1346 von 1436); das Gate bleibt bei 85 %, weil die Systemtests bewusst nicht mitzählen.
In der Pipeline aus Artikel 05 bekommen die Tests eine eigene Stufe systemtest nach test. Ihr Testbericht bleibt ein Jahr als Artefakt erhalten und dient als Beleg für das Abnahmeprotokoll:
In .gitlab-ci.yml:
stages:
- build
- test
- systemtest
- quality
- package
systemtest:
stage: systemtest
script:
- ./gradlew systemTest
artifacts:
when: always
paths:
- build/reports/tests/systemTest/
expire_in: 1 year
reports:
junit: build/test-results/systemTest/TEST-*.xmlLokal nachgestellt laufen beide Befehle grün; auf GitLab ist die Pipeline wie in den Artikeln zuvor noch nicht ausgeführt.
Nächster Artikel
Das Backend ist abgenommen, und jede Testebene des V-Modells hat ihre Tests. Im letzten Artikel der Reihe kommt eine neue Anforderung dazu: Bisher nennt jede Anfrage die Nutzer-ID selbst. Ein Change Request verlangt eine Anmeldung. Mit einer Auswirkungsanalyse, securitySchemes im Vertrag und Spring Security mit HTTP Basic und Rollen zeigt sich, was ein gutes Testnetz wert ist: Alle Tests aller Ebenen laufen als Regressionstest erneut.
Leitfragen
- Gegen welche Grundlage prüft der Systemtest und gegen welche der Abnahmetest?
Der Systemtest prüft gegen den Systementwurf, hier den API-Vertrag openapi.yaml (Statuscodes, Medientypen, Schemas). Der Abnahmetest prüft gegen die Anforderungen, hier die Abnahmekriterien der User Stories US-01 bis US-08. siehe Abschnitt „Zwei Fragen an dieselbe Anwendung“
- Was startet
@SpringBootTest(webEnvironment = RANDOM_PORT)im Unterschied zu@WebMvcTest?
Den ganzen Spring-Kontext mit allen Schichten, Services, Repositories und Datenbank, dazu einen echten Tomcat auf einem freien Port. @WebMvcTest startet nur die Web-Schicht ohne Server, die Services sind Mocks. siehe Abschnitt „Die ganze Anwendung im Test starten“
- Warum setzen die System- und Abnahmetests die Uhr mit
@TestBeanfest?
Die Startdaten liegen im Oktober 2026, und Kriterien wie „Datum in der Vergangenheit“ oder „der nächste Schultag“ hängen vom heutigen Tag ab. Mit der echten Uhr würden die Tests mit der Zeit rot. @TestBean ersetzt die Clock-Bean durch eine feste Uhr, die die Services statt der Systemzeit verwenden. siehe Abschnitt „Die Uhr anhalten“
- Warum legt ein Abnahmetest seine Angebote und Buchungen selbst über die API an, statt sich auf ein Zurückrollen nach dem Test zu verlassen?
Test und Server laufen in verschiedenen Threads; die Transaktion im Server ist abgeschlossen, bevor der Test prüft, und wird nicht zurückgerollt. Alle Testklassen teilen sich außerdem Kontext und Datenbank. Eigene Daten je Test machen das Ergebnis unabhängig von der Reihenfolge. siehe Abschnitt „Was nicht zurückgerollt wird“
- Wie wird aus einem Abnahmekriterium „Angenommen – Wenn – Dann“ ein Test, und woran erkennt man im Testbericht, welches Kriterium er prüft?
„Angenommen“ wird zum Arrange (Daten über die API anlegen), „Wenn“ zum Act (die Anfrage der Nutzerin), „Dann“ zum Assert (Status, Body, Folgezustand per GET). @DisplayName beginnt mit der Nummer des Kriteriums, @TestMethodOrder(MethodOrderer.DisplayName.class) sortiert danach. siehe Abschnitt „Abnahmetest gegen die User Stories“
- Warum hat erst der Abnahmetest entdeckt, dass Angebote mit vergangenem Datum angelegt werden?
Die Anforderung stand nur in der User Story; weder der Vertrag noch die Fachregeln hatten sie aufgenommen. Tests der unteren Ebenen prüfen, was umgesetzt ist; nur der Abnahmetest geht direkt von den Anforderungen aus. siehe Abschnitt „Fund 2: ein Kriterium, das niemand umgesetzt hat“
- Wozu braucht die API eine CORS-Konfiguration, obwohl
curlund die.http-Dateien ohne sie funktionieren?
Nur Browser setzen die Same-Origin-Policy durch: Ein Skript von http://localhost:5173 darf Antworten von http://localhost:8080 nur lesen, wenn der Server sie mit Access-Control-Allow-Origin freigibt; vor PATCH fragt der Browser per OPTIONS vorab. allowedMethods muss PATCH enthalten, exposedHeaders den Location-Header. siehe Abschnitt „CORS: Damit ein Browser-Frontend die API aufrufen darf“
- Wie trennt der Build die System- und Abnahmetests von den übrigen Tests?
Die Testklassen tragen @Tag("system"). Gradle schließt den Tag in test aus und startet ihn in der eigenen Aufgabe systemTest; Maven schließt ihn in Surefire aus und startet ihn mit Failsafe in der Phase integration-test. In der Pipeline laufen sie in einer eigenen Stufe systemtest. siehe Abschnitt „Im Build und in der Pipeline“
Links und weitere Informationen
zum Weiterlernen: Übungsaufgaben zu System- und Abnahmetest
als Grundlage: Anforderungen und API-Vertrag, Controller-Integrationstest, REST-Grundlagen
als Nachschlagewerk: Spring Framework – RestTestClient, Spring Boot – Testing With a Running Server, Spring Framework – @TestBean, Spring Framework – CORS in Spring MVC, MDN – Cross-Origin Resource Sharing (CORS)
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: “Systemtest und Abnahmetest: Die ganze Anwendung über HTTP prüfen mit RestTestClient” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/spring-boot-systemtest-abnahmetest 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]
