1. Relative URL im RestTestClient
Wenn der RestTestClient bereits für RANDOM_PORT konfiguriert ist, ist die häufigste überflüssige Angewohnheit, wieder manuell so etwas wie "http://localhost:8080/..." zu schreiben. Das Problem ist, dass RANDOM_PORT Ihnen bei jedem Start einen neuen Port geben kann, und ein Test, der auf 8080 festgenagelt ist, wird nicht zu einem Test, sondern zu einem anspruchsvollen Haustier. Daher die „richtige Gewohnheit des Tages“ – Anfragen mit relative URL zu schreiben.
Der Sinn ist einfach: @AutoConfigureRestTestClient konfiguriert den Client so, dass er die Basisadresse des gestarteten Servers kennt (quasi http://localhost:<randomPort>). Und Sie geben im Test nur den relativen Pfad an, beginnend mit /. Das macht die Tests kürzer, lesbarer und viel weniger abhängig von der Umgebung. Und ja, es bewahrt Sie auch vor dem endlosen Zusammenkleben von Strings wie "http://localhost:" + port + ..., was wie „Code, der am Freitagabend geschrieben wurde“ aussieht.
Eine kleine Tabelle, um den Unterschied festzuhalten:
| Was wir im Test schreiben | Beispiel | Warum das gut/schlecht ist |
|---|---|---|
| Relative URL | "/api/public/articles" | Gut: unabhängig vom Port, direkt als Endpunkt-Kontrakt lesbar |
| Absolute URL | "http://localhost:" + port + "/api/public/articles" | Als Fallback zulässig, rauscht aber im Test und zwingt einen, sich an den Port zu erinnern |
Das Klassengerüst ändert sich dabei nicht: Live-Server, fertiger Client und relative URIs in den Szenarien selbst.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class PublicArticleRunningServerTest {
@Autowired
RestTestClient restClient;
}
Beachten Sie: In allen folgenden Beispielen schreiben wir .uri("/api/..."), also eine relative URL, und damit vergessen wir praktisch, dass der Port überhaupt existiert. Der Port ist Sache der Infrastruktur, nicht der Sinn des Tests.
2. Query-Parameter im API-Kontrakt
Paginierung, Filter und Sortierung sind der Ort, an dem ein Backend-Entwickler am häufigsten „versehentlich den Client kaputt macht“, selbst wenn alles andere unverändert erscheint. Die heutige Moral: Query-Parameter sollten im Test sichtbar sein, da sie Teil des öffentlichen API-Kontrakts sind. Wenn Sie sie in einem cleveren Helper „irgendwo da drüben“ verstecken, erhalten Sie in einem Monat einen Test, der grün ist, aber niemand weiß mehr, was er eigentlich prüft.
In ContentHub ist die öffentliche Artikelliste (GET /api/public/articles) ein typischer Endpunkt mit Parametern wie page, size, möglicherweise sort und Filtern wie category. In Running-Server-Tests ist es wichtig, dass die Anfrage möglichst „wie echt“ aussieht und dennoch kurz bleibt. Daher ist es zu Beginn ein völlig normaler Stil, die Query-Zeichenfolge direkt in die URL zu schreiben: Ja, es ist nicht der „eleganteste Builder“, aber es ist der lesbarste Kontrakt.
import org.junit.jupiter.api.Test;
@Test
void returnsFirstPageOfPublishedArticles() {
restClient.get()
// Wir lassen Query-Parameter direkt in der URL, damit der Test wie ein API-Kontrakt lesbar ist
.uri("/api/public/articles?page=0&size=2")
.exchange() // Tatsächlicher HTTP-Aufruf an den gestarteten Server
.expectStatus().isOk(); // Minimale Prüfung: Endpunkt lebt und antwortet mit 200
}
Wenn man die Anfrage etwas „reicher“ gestalten und gleichzeitig demonstrieren möchte, dass Query-Parameter ein Kontrakt sind, kann man eine Sortierung hinzufügen. Selbst wenn Sie die Reihenfolge im Antwortkörper noch nicht prüfen, wird bereits festgehalten, dass der Endpunkt den Parameter sort akzeptiert und nicht abstürzt.
import org.junit.jupiter.api.Test;
@Test
void supportsSortingByPublishedAt() {
restClient.get()
// Wir stellen im Test fest, dass der Endpunkt die Sortierung nach publishedAt unterstützt
.uri("/api/public/articles?page=0&size=5&sort=publishedAt,desc")
.exchange()
.expectStatus().isOk(); // Hier ist uns wichtig, dass auf dem Kontraktparameter kein Fehler auftritt
}
Hier ist es wichtig, die Balance zu halten. Wenn Sie anfangen, die URL mit komplexen Buildern zu konstruieren, kann der Test „schöner“, aber weniger lesbar werden. Und wir brauchen jetzt, dass ein Junior-Entwickler beim Öffnen des Tests sofort sieht: „Aha, das ist Paging; aha, das ist Sorting; aha, hier sind die Werte.“ Das ist ungefähr wie eine gute Quittung im Geschäft: Sie ist kein Kunstwerk, aber alles ist klar ersichtlich.
3. Deterministische Daten und @Sql
Den Status 200 OK bei einer leeren Datenbank zu prüfen, ist möglich, aber es wird schnell zu leeren Tests: Sie sind grün, vermitteln aber kein Vertrauen. Sobald Sie den Antwortkörper prüfen wollen (und das wollen wir), müssen die Daten in der Datenbank vorhersagbar sein. Und hier gibt es eine großartige Nachricht: Alle Werkzeuge dafür haben Sie bereits aus den vorherigen Tagen — Flyway, Seed-Migrationen, @Sql und eine ordentliche Disziplin der Zustandsvorbereitung.
Ein psychologisch wichtiger Punkt: Ein Running-Server-Test ist bereits „fast wie Produktion“, daher mag er besonders keine „zufälligen Daten“. Wenn Sie den Zustand der Datenbank nicht kontrollieren, testen Sie nicht die API, Sie testen das Glück. Und Glück ist bekanntlich in keiner Abhängigkeit von spring-boot-starter-test enthalten.
In der Praxis machen wir es so: Vor dem Test bereiten wir einen kleinen Datensatz vor, zum Beispiel ein paar veröffentlichte Artikel mit bekannten Slugs. Der einfachste Lernweg ist @Sql mit einem Skript, das in src/test/resources liegt. Nehmen wir eine Beispieldatei published-articles.sql und halten wir uns an diese Vereinbarung:
import org.junit.jupiter.api.Test;
import org.springframework.test.context.jdbc.Sql;
@Sql("/db/testdata/published-articles.sql") // Vorbereitung deterministischer Daten vor dem Test
@Test
void returnsArticlesFromSeedData() {
restClient.get()
// Testen des Lesens realer Daten, nicht einer leeren Datenbank
.uri("/api/public/articles?page=0&size=10")
.exchange()
.expectStatus().isOk();
}
Wenn Sie sich vorstellen möchten, was normalerweise in einer solchen SQL-Datei steht, hier ein stark vereinfachtes (und „lehrreiches“) Beispiel. In Ihrem Scaffold können Tabellen-/Spaltennamen abweichen – das ist normal; Hauptsache, Sie verstehen die Idee: einen festen Zustand mit wiedererkennbaren Slugs zu schaffen.
-- db/testdata/published-articles.sql
delete from articles;
insert into articles(id, slug, title, status)
values (1001, 'existing-article', 'Existing article', 'PUBLISHED');
insert into articles(id, slug, title, status)
values (1002, 'another-article', 'Another article', 'PUBLISHED');
An dieser Stelle versuchen viele Anfänger, „das Leben zu verbessern“ und fügen @Transactional zur Testklasse hinzu, damit „alles automatisch zurückgerollt wird“. Und genau hier hält der Running-Server-Modus eine wichtige Überraschung bereit, die man besser jetzt sieht als später im nächtlichen Release.
Wenn die Testmethode mit @Transactional markiert ist, kann @Sql standardmäßig in derselben Transaktion ausgeführt werden, bis zum Ende des Tests nicht committen, und der Server (der die Anfrage in einem anderen Thread und normalerweise mit einer anderen JDBC-Sitzung verarbeitet) kann diese Daten einfach nicht sehen. Sie erhalten einen rätselhaften Effekt: „Das Skript hat die Artikel genau eingefügt, aber der Endpunkt gibt leer zurück.“ Das ist keine Magie, sondern Transaktionsisolation, und sie verhält sich genau so, wie sie soll.
Wenn Sie dennoch aus irgendeinem Grund möchten, dass @Sql immer in einer separaten Transaktion ausgeführt und unabhängig von der Testtransaktion committet wird, bietet Spring Test den Mechanismus @SqlConfig mit dem Modus ISOLATED. Es ist nützlich, dies als „Notausgang“ zu kennen, aber als tägliche Gewohnheit ist es einfacher, Running-Server-Tests gar nicht erst transaktional zu machen.
import org.junit.jupiter.api.Test;
import org.springframework.test.context.jdbc.Sql;
import org.springframework.test.context.jdbc.SqlConfig;
@Sql(
scripts = "/db/testdata/published-articles.sql",
config = @SqlConfig(transactionMode = SqlConfig.TransactionMode.ISOLATED) // So stellen wir sicher, dass die Daten committet und für den Server sichtbar sind
)
@Test
void serverCanSeeDataInsertedBySql() {
restClient.get()
.uri("/api/public/articles")
.exchange()
.expectStatus().isOk(); // Wenn der Server die Daten nicht sieht, zeigt sich das oft hier durch eine leere Antwort/falschen Status
}
Wir werden später in der Lektion auf die Transaktionsgrenzen zurückkommen, aber merken Sie sich jetzt den praktischen Gedanken: In Live-Server-Tests müssen Daten nicht nur „vorbereitet“ werden, sondern so vorbereitet sein, dass der Server sie tatsächlich sieht.
4. Überprüfungen des JSON-Antwortkörpers
Die Statusprüfung ist, wie ein Auto von weitem zu betrachten und zu sagen: „Na ja, Räder sind dran.“ Manchmal reicht das, aber öfter möchte man sichergehen, dass innen kein Fahrrad steckt. Sobald wir die Daten stabilisiert haben, ist der nächste Schritt, den Antwortkörper zu prüfen. Und dabei ist es wichtig, den Test nicht in eine fragile Geschichte über „zufällige Leerzeichen im JSON“ zu verwandeln.
Der einfachste Weg ist, die Antwort als Text zu nehmen und zu prüfen, ob sie das gewünschte Stück enthält. Das ist ein schneller Einstieg, besonders wenn Sie sich erst mit RestTestClient vertraut machen. Aber man muss die Grenze ehrlich verstehen: contains("\"slug\":\"existing-article\"") ist eher ein „Rauchtest des Inhalts“ als ein strikter Kontrakt.
import org.junit.jupiter.api.Test;
import org.springframework.test.context.jdbc.Sql;
import static org.assertj.core.api.Assertions.assertThat;
@Sql("/db/testdata/published-articles.sql") // Stellen sicher, dass der Slug definitiv in der Datenbank existiert
@Test
void responseContainsKnownSlug() {
var exchange = restClient.get()
.uri("/api/public/articles?page=0&size=10")
.exchange(); // Führe die Anfrage aus und erhalte das Exchange-Ergebnis
String body = exchange.expectBody().returnResult().getResponseBodyAsString();
assertThat(body).contains("existing-article"); // Schnelle „Rauch“-Prüfung des Inhalts
}
Dieser Test ist bereits viel nützlicher als nur isOk(), weil er prüft, dass der Server tatsächlich Daten zurückgegeben hat und nicht nur einen leeren Platzhalter. Damit die Prüfung aber „den Sinn betrifft“ und nicht die Formatierung, verwendet man besser JsonPath, den Sie bereits früher im Kurs gesehen haben. Der Vorteil ist, dass JsonPath es erlaubt, Werte über Pfade zu extrahieren, und es spielt keine Rolle mehr, in welcher Reihenfolge Felder serialisiert wurden und wie viele Leerzeichen vorhanden sind.
Ein sehr praktischer Trick für Page-Antworten, wenn Sie nicht von der spezifischen Struktur des Wrappers abhängig sein möchten: Verwenden Sie die rekursive Suche $..slug. Sie findet alle Felder slug überall im JSON-Baum, und Sie können prüfen, ob der gewünschte Slug vorhanden ist.
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import org.springframework.test.context.jdbc.Sql;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Sql("/db/testdata/published-articles.sql") // Erstellen einen festen Datensatz
@Test
void pageContainsExistingArticleSlug() {
var exchange = restClient.get()
.uri("/api/public/articles?page=0&size=10")
.exchange();
String body = exchange.expectBody().returnResult().getResponseBodyAsString();
// Durchsuchen rekursiv alle Slug-Felder in der Antwort (ohne an eine bestimmte JSON-Seitenhülle gebunden zu sein)
List<String> slugs = JsonPath.read(body, "$..slug");
assertThat(slugs).contains("existing-article"); // Prüfen genau die Daten, nicht das Format des JSON-Strings
}
Ebenso ist es nützlich, nicht nur den Happy Path zu prüfen. Wenn der Artikel zu einem Slug nicht existiert, erwarten wir 404 und möchten sicherstellen, dass unser stabiles ApiProblem zurückkommt, das ein errorCode enthält. Dies ist ein hervorragendes Running-Server-Szenario, da es auch ohne Daten deterministisch ist: Sie können immer einen garantiert nicht vorhandenen Slug anfragen.
import com.jayway.jsonpath.JsonPath;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void returnsApiProblemForMissingArticle() {
var exchange = restClient.get()
// Wir brauchen keine Daten in der Datenbank: der Slug ist garantiert nicht vorhanden
.uri("/api/public/articles/missing-article")
.exchange();
exchange.expectStatus().isNotFound();
String body = exchange.expectBody().returnResult().getResponseBodyAsString();
// Kontrakt: Im Body von ApiProblem gibt es einen stabilen errorCode
String errorCode = JsonPath.read(body, "$.errorCode");
assertThat(errorCode).isEqualTo("ARTICLE_NOT_FOUND");
}
Selbst wenn Ihnen die genauen Felder von ApiProblem aus JSON-Tests und MVC-Tests bekannt sind, gibt es hier eine wichtige Ergänzung: Jetzt prüfen wir dies auf einem Live-Server, also durch echten HTTP-Eingang, Filter und „echte“ Verarbeitung der Anfrage. Das ist eine andere Beweisebene und ergänzt die Slices-Tests gut.
5. Live-Server: Threads und Transaktionen
In dieser Phase passiert normalerweise einer der zeitaufwändigsten Denkfehler: Man sieht @SpringBootTest, liest das Wort „Integration“, fügt automatisch @Transactional hinzu und erwartet, dass sich alles wie in @DataJpaTest oder in einigen MockMvc-Szenarien verhält. Aber der Live-Server-Modus ist grundlegend anders: Sie haben tatsächlich einen Client und einen Server, die in unterschiedlichen Ausführungsthreads leben.
Das sieht ungefähr so aus (stark vereinfacht, aber sinngemäß korrekt):
sequenceDiagram
participant T as "Test (Client)"
participant S as "Server (Controller/Servlet)"
participant DB as Database
T->>S: HTTP GET /api/public/articles
S->>DB: SELECT ...
DB-->>S: rows
S-->>T: 200 + JSON body
Und hier ist, warum das speziell für Tests wichtig ist. Die Transaktion, die Sie in der Testmethode geöffnet haben (falls Sie überhaupt eine geöffnet haben), umfasst nicht die Verarbeitung des HTTP-Requests auf Serverseite. Der Server öffnet seine eigene Transaktion (oder auch nicht – abhängig von Ihrem Produktionscode), führt die Arbeit aus und committed die Änderungen unabhängig davon, wie die Testtransaktion lebt. Das bedeutet zwei Dinge, die zunächst unfair erscheinen, später aber zu Ihrer Superkraft werden.
Erstens: Wenn Sie versuchen, Datenbankänderungen über @Transactional im Test „zurückzurollen“, garantiert das nicht, dass die vom Server vorgenommenen Änderungen verschwinden. Die Testtransaktion lebt auf Client-Seite, während der Server die Datenbank in seiner eigenen Transaktion beschreibt. Daher sollte man den „magischen Rollback“ im Live-Server-Modus nicht als Aufräumwerkzeug betrachten.
Zweitens (noch praktischer): Wenn Sie Daten innerhalb einer nicht committeten Testtransaktion vorbereiten, kann der Server sie möglicherweise nicht sehen. Genau deshalb haben wir oben so sorgfältig über @Sql gesprochen und darüber, dass manchmal der ISOLATED-Modus oder einfach der Verzicht auf @Transactional in Running-Server-Tests nötig ist. Es ist sehr leicht, in die Situation zu geraten: „Ich habe Daten angelegt, aber der Endpunkt sieht sie nicht“, und einen halben Tag mit Verdächtigungen gegenüber Jackson, dem Controller und natürlich dem rückläufigen Merkur zu verbringen.
Um die Suite stabil zu halten, haben Running-Server-Tests normalerweise zwei ruhige Wege. Der erste Weg ist die Verwendung von Read-Only-Szenarien (wie wir es heute gemacht haben) und eines Datensatzes, den jeder Test über @Sql mit „Bereinigung+Einfügen“ neu setzt. Dann ist der Zustand immer vorhersagbar. Der zweite Weg: Wenn der Test doch Daten ändert (z.B. POST/PUT-Flow), muss man explizit ein Cleanup vereinbaren: ein separates @Sql bei AFTER_TEST_METHOD oder ein Skript, das bestimmte Tabellen bereinigt.
import org.junit.jupiter.api.Test;
import org.springframework.test.context.jdbc.Sql;
@Sql("/db/testdata/published-articles.sql") // Zustand „vorher“ vorbereitet
@Sql(value = "/db/testdata/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD) // Zustand „nachher“ explizit bereinigt
@Test
void usesRepeatableDatabaseState() {
restClient.get()
.uri("/api/public/articles?page=0&size=10")
.exchange()
.expectStatus().isOk(); // Prüfen nur den Kontrakt, nicht den „magischen“ Zustand der Datenbank
}
Ja, ein Cleanup-Skript zu schreiben klingt nach „unnötiger Arbeit“. Aber es ist der Fall, wo unnötige Arbeit heute Ihnen morgen Stunden Debugging erspart. Außerdem macht es Integrationstests sowohl lokal als auch in CI wiederholbar, wo der „Zustand der Maschine“ keine Rolle spielen sollte.
6. Typische Fehler in Live-Server-Tests
Fehler Nr. 1: Absolute URLs schreiben und localhost:8080 hartcodieren.
In Running-Server-Tests ist das besonders schmerzhaft, weil RANDOM_PORT genau dafür geschaffen wurde, dass der Port nicht Teil des Testkontrakts ist. Wenn Sie absolute URLs schreiben, wird der Test umgebungsabhängig und verliert den Sinn eines „eigenständigen“ Laufs. Behalten Sie standardmäßig den Stil uri("/api/...") bei und lassen Sie @LocalServerPort als Reservewerkzeug, wenn es wirklich nötig ist.
Fehler Nr. 2: Query-Parameter in Helfern verstecken, so dass der Test nicht mehr lesbar ist.
Manchmal möchte man eine Methode publicArticles(page, size, sort) erstellen und darin die URL bauen, um „Strings nicht zu duplizieren“. Nach einem Monat schauen Sie auf den Test und verstehen nicht, welche Parameter tatsächlich gesendet wurden und wo der Standard war und wo nicht. Für ein Lernprojekt und die meisten wichtigen Kontraktprüfungen ist es besser, dass die Query-Zeichenfolge direkt im Test sichtbar ist.
Fehler Nr. 3: Versuchen, einen Running-Server-Test „transaktional und selbstreinigend“ über @Transactional zu machen.
Im Live-Server-Modus leben Client und Server in unterschiedlichen Threads und normalerweise in unterschiedlichen Transaktionen, daher ist die Testtransaktion kein magischer Radiergummi für Serveränderungen. Darüber hinaus kann @Transactional die Datenvorbereitung behindern: Der Server sieht nicht committete Einfügungen nicht. Wenn eine Bereinigung nötig ist, machen Sie sie explizit (@Sql AFTER_TEST_METHOD) oder über wiederholbare Seed-Skripte.
Fehler Nr. 4: Der Antwortkörper zeichenweise oder zu „frontal“ prüfen.
Vergleich von JSON als String bricht schnell durch Umstellung von Feldern, Formatierung oder Hinzufügen eines neuen Feldes. Infolgedessen beginnt der Test aufgrund von Rauschen zu scheitern, nicht aufgrund einer echten Kontraktregression. Verwenden Sie für inhaltliche Prüfungen JsonPath (einschließlich des praktischen $..field für die Baumsuche) oder JSONassert in einem angemessenen Modus – so prüfen Sie Daten, nicht Kosmetik.
Fehler Nr. 5: Vergessen, dass ohne deterministisches Data-Setup die Prüfung des Antwortkörpers zur Wahrsagerei wird.
Wenn der Test davon abhängt, welche Artikel „zufällig“ in der Datenbank liegen, wird er instabil, und manchmal wird er sogar aus dem falschen Grund bestanden. In Running-Server-Tests ist es besonders wichtig: Entweder prüfen Sie einen deterministischen Negativfall (z.B. 404 für einen nicht vorhandenen Slug), oder Sie bereiten den Zustand über @Sql/Migrationen so vor, dass der Test immer die gleiche Welt sieht.
GO TO FULL VERSION