1. Vier heikle Zonen des JSON-Vertrags
Wenn Sie jemals ein Feld in einem DTO „nur aus Schönheitsgründen“ umbenannt haben und später eine Nachricht vom Frontend bekamen wie „bei uns ist alles abgestürzt, aber wir wissen nicht warum“ – herzlichen Glückwunsch, Sie sind der Realität von Verträgen begegnet. In diesem Abschnitt schlüsseln wir die vier Quellen der häufigsten Fehler auf: Datumsangaben, Enum, null und Feldnamen. Das ist keine Theorie um der Theorie willen – das sind typische Ursachen für Regressionen in APIs.
Stellen wir uns das als kurze „Risikokarte“ für ContentHub vor. Die Felder unten stammen aus typischen DTOs (z.B. ArticleDetailsResponse), die der Client maschinell und nicht mit den Augen liest:
| Vertragszone | Beispiel-Feld in ContentHub | Wie es bricht | Was wir im JSON-Test festhalten |
|---|---|---|---|
| Datumsangaben/Zeit | publishedAt, createdAt | Format geändert, Zeitzone geändert, Millisekunden hinzugekommen | konkreten String im ISO-Format |
| Enum-Werte | status (DRAFT, PUBLISHED…) | Enum umbenannt, @JsonValue hinzugefügt, Text geändert | exakten String-Wert |
| null / optionale Felder | rejectionReason, publishedAt | Feld erscheint/verschwindet plötzlich, wird null statt nicht vorhanden | Vorhandensein/Fehlen des Feldes und Semantik von null |
| Feldnamen | authorUsername, errorCode (in anderen Payloads) | Umbenennung, Wechsel camelCase ↔ snake_case | JSON-Pfad unter erwartetem Namen |
Ein Bruch kommt fast nie aus dem Nichts. Normalerweise hat jede Zone einen technischen Hebel in Jackson/Boot, und es ist praktisch, sich daran zu erinnern, wenn der Test plötzlich rot wird:
| Risikozone | Was den Vertrag meistens bewegt | Wo man die Ursache normalerweise sucht |
|---|---|---|
| Datumsangaben/Zeit | Unterstützung von Java Time, Formatierung, zeitzonenbezogene Einstellungen | Feldtyp, Format/Serializer, globale Zeiteinstellungen |
| Enum | Serialisierung nach Name, @JsonValue, benutzerdefinierter Serializer/Deserializer | das Enum selbst und die Jackson-Annotationen darum herum |
| null / Abwesenheit | @JsonInclude und allgemeine Inclusion Policy | DTO-Annotationen und globale Aufnahmepolitik für Felder |
| Feldnamen | @JsonProperty und Naming Strategy | Feld/Getter, Annotationen und Namensstrategie des Mappers |
Genau deshalb ist @JsonTest nicht nur eine abstrakte „JSON-Prüfung“, sondern ein Test auf denselben Mapper, der tatsächlich den Vertrag der Anwendung bildet. Wenn er fehlschlägt, ist das normalerweise ein Hinweis auf eine konkrete Abweichung in der Produktions-Serialisierungskonfiguration, nicht nur auf ein „irgendein komisches JSON“ an sich.
Und hier kommt ein wichtiger philosophischer (aber praktischer) Punkt: Dem Client ist es in der Regel egal, wie Ihre Klasse heißt und welcher Getter dort steckt. Dem Client ist wichtig, dass das JSON so aussieht, wie er es erwartet. Und wenn wir wollen, dass Änderungen bewusst sind, fixieren wir die verletzlichsten Stellen mit Tests.
Eine kleine Übersicht, „wie ein Fehler zum Client gelangt“, sieht so aus:
flowchart LR
A[DTO / ObjectMapper geändert] --> B[Jackson serialisiert neu]
B --> C[JSON-Vertrag geändert]
C --> D[Client fällt aus]
B --> T["@JsonTest erfasst Änderung schnell"]
Der Test ist hier keine „Jackson-Prüfung“. Der Test ist Ihre Alarmanlage: „Der Vertrag hat sich geändert, halten Sie an und entscheiden Sie, ob Sie das wollten oder ob es ein Versehen ist.“
2. Datum und Zeit in JSON: Format und Zeitzone
Datum und Zeit in Java sind wie eine Katze: Sie sehen niedlich und verständlich aus, bis man sie anfasst. Und wenn man sie anfasst, stellt man plötzlich fest, dass es Instant, LocalDateTime, Zeitzonen, Offsets, Nanosekunden und „warum habe ich im JSON 1712345678000“ gibt. In diesem Abschnitt besprechen wir, was genau in JSON-Tests überprüft werden muss, damit Datum/Zeit keine Überraschung für Clients werden.
Was genau „schiefgehen“ kann
In APIs kommt am häufigsten diese Geschichte vor: Gestern war das Feld publishedAt ein String wie 2026-03-18T10:15:30Z, und heute ist es 18.03.2026 10:15:30 oder gleich eine Zahl als Zeitstempel. Der Geschäftssinn „Veröffentlichungszeitpunkt“ ist zwar derselbe, aber für den Client ist das bereits ein anderer Vertrag. Typischerweise erwartet das Frontend/Mobile/Integration ein bestimmtes Format, parst manchmal streng, baut die Anzeige auf oder vergleicht.
Daher ist die Regel für den Test einfach: Wir prüfen nicht „nicht leer“, wir prüfen die exakte String-Repräsentation (oder zumindest die Übereinstimmung mit dem Format), die Teil des Vertrags ist.
Minikontext: Welcher Zeittyp sollte verwendet werden
In den meisten Backend-APIs ist es praktisch, für einen „Zeitpunkt“ Instant (UTC-Moment) oder OffsetDateTime (Moment + Offset) zu verwenden. In ContentHub kommt es bei Feldern wie publishedAt auf den Moment an, nicht auf die lokale Zeit des Autors, daher ist Instant normalerweise die unkomplizierteste Wahl. Aber unabhängig vom Typ wird der Vertrag durch den JSON-Test fixiert.
Für die Beispiele unten werde ich Instant.parse("...") verwenden, weil das den Test deterministisch macht: kein Instant.now(), kein „bei mir am Laptop ist eine andere Zeit“.
Beispiel-DTO
import java.time.Instant;
// Antwort-DTO: Der Client liest genau die JSON-Form dieses Objekts.
public record ArticleDetailsResponse(
String title, // Titel des Artikels (String wie er ist)
String slug, // SEO-URL/Identifikator (stabil für Links)
ArticleStatus status, // Enum -> im JSON wird es fast immer ein String (das ist Teil des Vertrags)
Instant publishedAt // Zeitpunkt der Veröffentlichung (Vertrag: Format/Zeitzone im JSON)
) {}
Test: Serialisierung des Datums in ein stabiles Format
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void writesPublishedAtInIsoInstantFormat() throws Exception {
// Wichtig: Wir verwenden eine feste Zeit, nicht now(), damit der Test deterministisch ist.
var dto = new ArticleDetailsResponse(
"Spring Testing Basics",
"spring-testing-basics",
ArticleStatus.PUBLISHED,
Instant.parse("2026-03-18T10:15:30Z")
);
// Überprüfen den Vertrag: publishedAt muss ein String im erwarteten ISO-Format sein.
assertThat(json.write(dto))
.extractingJsonPathStringValue("@.publishedAt") // JSONPath: genau den String-Wert des Feldes nehmen
.isEqualTo("2026-03-18T10:15:30Z");
}
Hier sind zwei Details wichtig. Erstens prüfen wir den gesamten String, nicht „enthält 2026“ und nicht „ist nicht null“. Zweitens übergeben wir einen festen Instant an das DTO, damit der Test nicht von der aktuellen Zeit abhängt.
Test: Deserialisierung des Datums aus JSON zurück in Instant
Wenn Sie Szenarien haben, in Sie die Zeit aus JSON lesen (das ist häufiger für Request-DTOs als für Response), dann sollte auch die Richtung JSON → Java fixiert werden. Auch wenn publishedAt bei uns heute nur ein Response-Feld ist, ist das Beispiel als Technik nützlich.
import org.junit.jupiter.api.Test;
import java.time.Instant;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void parsesPublishedAtFromJson() throws Exception {
// Bequeme Möglichkeit, das gesamte JSON festzuhalten: kompakter Text-Block.
String content = """
{"title":"X","slug":"x","status":"PUBLISHED","publishedAt":"2026-03-18T10:15:30Z"}
""";
// Parsen JSON zurück in DTO: Das fixiert die Erwartungen bezüglich des Datumsformats im Vertrag.
ArticleDetailsResponse dto = json.parseObject(content);
// Vergleichen mit dem gleichen festen Instant.
assertThat(dto.publishedAt()).isEqualTo(Instant.parse("2026-03-18T10:15:30Z"));
}
Ja, der JSON-String sieht etwas beängstigend aus, aber er ist klein und eignet sich hervorragend für einen gezielten Test genau des Formats.
Nuance zu Millisekunden und „zusätzlicher Genauigkeit“
Manchmal „bricht“ das Format nicht radikal, sondern nur ein wenig: Gestern war es 2026-03-18T10:15:30Z, heute ist es 2026-03-18T10:15:30.000Z. Für einen Menschen ist das fast dasselbe, für den Client kann es eine unangenehme Überraschung sein (z.B. String-Vergleiche, Snapshots, Dokumentation, strikte Parser). Entweder akzeptieren Sie beide Varianten bewusst und testen flexibler, oder Sie legen eine fest und sorgen dafür, dass Jackson stabil schreibt.
3. Enum-Werte: Wenn der String wichtiger ist als die Bedeutung
Enum wird in Java oft als „sichere Menge von Werten“ betrachtet. Und das stimmt auch innerhalb Ihres Codes. Aber im JSON wird Enum fast immer zu einem String. Und hier beginnt die Magie: Der String wird Teil des öffentlichen Vertrags, und die Umbenennung von IN_REVIEW in ON_REVIEW ist kein Refactoring mehr, sondern ein Breaking Change. In diesem Abschnitt lernen wir, den Enum-Vertrag so zu testen, dass wir uns nicht auf Glück und Gedächtnis verlassen.
Minikontext: Enum für den Artikelstatus
// Wichtig: Die Namen der Konstanten sind das, was oft als String ins JSON übergeht.
public enum ArticleStatus {
DRAFT,
IN_REVIEW,
PUBLISHED,
REJECTED,
ARCHIVED
}
Test: Status serialisiert sich in einen exakten String-Wert
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void writesStatusAsExactContractValue() throws Exception {
var dto = samplePublishedArticle(); // Helper im Test (bereitet DTO im gewünschten Status vor)
// Vertrag: status muss exakt "PUBLISHED" sein, ohne "published"/"Published" oder andere Varianten.
assertThat(json.write(dto))
.extractingJsonPathStringValue("@.status")
.isEqualTo("PUBLISHED");
}
Beachten Sie: Wir prüfen genau "PUBLISHED", nicht „irgendein Wert ist vorhanden“. Das ist der Vertrag. Wenn morgen jemand @JsonValue hinzufügt und "published" ausgibt, schlägt der Test fehl. Und das ist gut: Sie wollen das nicht erst vom Client erfahren.
Test: Status wird aus JSON zurückgelesen
Das betrifft eher Request-DTOs, aber manchmal wird auch das Response-DTO gelesen (z.B. in Test-Tools, Migrationen, Hilfsprogrammen). Und selbst wenn Sie ArticleDetailsResponse nicht parsen, ist die Technik für CreateArticleRequest nützlich (z.B. wenn es dort Enum-Felder gibt).
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void parsesStatusFromJson() throws Exception {
// Vertrag für den Input: Der Client sendet das Enum als String.
String content = """
{"title":"X","slug":"x","status":"IN_REVIEW","publishedAt":"2026-03-18T10:15:30Z"}
""";
ArticleDetailsResponse dto = json.parseObject(content);
// Prüfen, ob der String korrekt auf die erwartete Konstante gemappt wird.
assertThat(dto.status()).isEqualTo(ArticleStatus.IN_REVIEW);
}
Negativszenario: Unbekannter Enum-Wert
Hier machen viele es „weich“, weil es bequemer erscheint. Aber Weichheit im Enum-Vertrag verwandelt sich manchmal in einen stillen Fehler: Der Client sendet "PUBLUSHED" (Tippfehler), Jackson liest das als null (wenn jemand die entsprechende Einstellung aktiviert hat), und dann fliegen NullPointerException oder schlimmer noch falsche Geschäftslogik-Entscheidungen.
Für den Vertrag ist es normalerweise nützlicher, explizit zu scheitern. Beispiel für einen Test, der die Strenge fixiert:
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@Test
void failsOnUnknownStatusValue() {
// Der Tippfehler "PUBLUSHED" muss zu einem Fehler führen, nicht stillschweigend zu null werden.
String content = """
{"title":"X","slug":"x","status":"PUBLUSHED","publishedAt":"2026-03-18T10:15:30Z"}
""";
assertThatThrownBy(() -> json.parseObject(content))
// Wichtig ist der Fehler selbst: "Unbekannter Wert wird nicht durchgelassen."
.isInstanceOf(Exception.class);
}
Ja, wir fangen das allgemeine Exception ab, weil der genaue Typ davon abhängt, wie Jackson Fehler verpackt. An dieser Stelle ist uns nicht der Name der Exception wichtig, sondern die Tatsache: „Unbekannter Wert wird nicht stillschweigend durchgelassen“. Wenn Sie es genauer fixieren möchten – können Sie, aber auf Junior-Niveau ist das oft schon zu viel Zerbrechlichkeit.
4. null und optionale Felder: Abwesend vs. null
null im JSON ist nicht einfach „nichts“. Es ist eine Vereinbarung. Der Client kann null anders interpretieren als das Fehlen eines Feldes. Und das Backend kann plötzlich ein Feld senden, das vorher fehlte, aufgrund einer Jackson-Einstellung oder eines hinzugefügten @JsonInclude. In diesem Abschnitt verwandeln wir das Verhalten von null in einen bewussten Teil des Vertrags und lernen, es mit kurzen Tests zu prüfen.
Zwei mögliche Verträge
Ein optionales Feld hat normalerweise zwei Strategien. Erstens: Das Feld ist immer vorhanden, manchmal aber gleich null. Zweitens: Wenn es keinen Wert gibt, fehlt das Feld im JSON ganz. Beide Strategien sind lebensfähig, aber es ist wichtig, dass das Projekt eine Strategie hat (oder zumindest eine klare und konsistente), sonst leben die Clients im Modus „rate mal, was gemeint war“.
Für ContentHub ist es praktisch, sich an den Ansatz „kein Wert = kein Feld“ zu halten, weil das JSON kompakter wird und der Client leichter unterscheiden kann, dass das Feld wirklich nicht anwendbar ist. Aber ich wiederhole: Wichtiger als die Wahl ist, dass diese Wahl durch Tests fixiert wird.
Felder, die oft optional sind
Für ArticleDetailsResponse sind das normalerweise publishedAt (bei einem Entwurf fehlt es) und rejectionReason (bei einem veröffentlichten Artikel fehlt es). Und das ist hervorragendes Material für einen JSON-Test: Wir prüfen nicht den Geschäftssinn, sondern die Form.
Test: Ein Entwurf hat kein publishedAt
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void draftDoesNotHavePublishedAt() throws Exception {
var dto = sampleDraftArticle();
// Vertrag: Wenn der Artikel nicht veröffentlicht ist, fehlt das Feld publishedAt im JSON.
assertThat(json.write(dto))
.doesNotHaveJsonPathValue("@.publishedAt");
}
Wenn morgen jemand beschließt „lass uns publishedAt: null publizieren“, wird der Test fehlschlagen. Dann muss das Team entscheiden: Ist das eine bewusste Vertragsänderung oder eine versehentliche Einstellung.
Test: Ein veröffentlichter Artikel hat kein rejectionReason
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void publishedDoesNotHaveRejectionReason() throws Exception {
var dto = samplePublishedArticle();
// Vertrag: rejectionReason bezieht sich auf die Ablehnung, nicht auf die Veröffentlichung – also fehlt das Feld.
assertThat(json.write(dto))
.doesNotHaveJsonPathValue("@.rejectionReason");
}
Beachten Sie die Feinheit: Wir schreiben nicht „rejectionReason muss null sein“. Wir sagen: „Es sollte im Vertrag dieses Szenarios nicht vorkommen.“ Das macht das Verhalten für den Client klarer.
Nuance: Ein leerer String ist auch ein Vertrag
Manchmal denkt ein Entwickler: „Um null zu vermeiden, setze ich einen leeren String.“ Das ist keine Lösung, sondern eine Sinnverschiebung. Der Client könnte einen leeren String als „abgelehnt ohne Grund“ anzeigen, einen Block ausblenden oder es als Datenfehler betrachten. Daher ist es besser, festzulegen, dass das Feld entweder fehlt oder vorhanden ist und einen sinnvollen Wert enthält (in den Szenarien, in denen es anwendbar ist). Die Prüfung ist hier sehr einfach: Wenn das Feld vorhanden ist, ist es nicht leer.
5. Namen von JSON-Feldern: Kleine Umbenennung – großer Schaden
Ein Java-Feld, eine Methode oder ein Parameter umzubenennen, ist normalerweise sicher: Die IDE aktualisiert Referenzen, der Compiler hilft, Tests laufen. Aber der JSON-Feldname lebt in einer anderen Welt: Er wird von einem Client gelesen, der nicht zusammen mit Ihrem Code compiliert wird. Daher ist jede Umbenennung im JSON eine Änderung des öffentlichen API. In diesem Abschnitt lernen wir, JSON-Feldnamen durch kurze Checks „festzunageln“.
Das häufigste Bruch-Szenario
Ein klassisches Beispiel: Es war authorUsername, wurde author. Oder es war publishedAt, wurde published_at, weil jemand die SnakeCaseStrategy aktiviert hat. Oder umgekehrt: Sie erwarteten snake_case, aber plötzlich ist alles camelCase. Jeder dieser Fälle ist ein potenzieller Breaking Change.
Test: Das Feld heißt genau authorUsername
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void keepsAuthorUsernameJsonNameStable() throws Exception {
var dto = samplePublishedArticleBy("alice");
// Vertrag: Das JSON-Feld muss authorUsername heißen (nicht author, author_name usw.).
assertThat(json.write(dto))
.extractingJsonPathStringValue("@.authorUsername")
.isEqualTo("alice");
}
Das ist eine einfache Prüfung, aber sie tut etwas Wichtiges: Sie fixiert den Feldnamen. Selbst wenn jemand die Java-Komponenten umbenennt, bleibt der JSON-Name geschützt.
Test: Es sollte kein „unerwartetes“ snake_case geben
Manchmal ist es nützlich, die Abwesenheit eines alternativen Namens explizit zu fixieren, damit ein versehentlicher Wechsel der Naming Strategy sofort sichtbar wird.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@Test
void doesNotUseSnakeCaseForAuthorUsername() throws Exception {
var dto = samplePublishedArticleBy("alice");
// Schutzkontrolle: Wenn plötzlich die snake_case-Strategie aktiviert wird, schlägt der Test sofort fehl.
assertThat(json.write(dto))
.doesNotHaveJsonPathValue("@.author_username");
}
Ja, das klingt fast nach Paranoia. Aber Paranoia bei Verträgen ist keine Diagnose, sondern eine berufliche Gewohnheit. Besonders wenn Sie mehrere Clients haben (Web, Mobile, externe Integrationen).
Deserialisierung: Feldname im Request-DTO
Auch wenn wir heute hauptsächlich über Response sprechen, ist es wichtig zu bedenken: Im Request-DTO ist der Vertrag der Feldnamen nicht weniger kritisch. Wenn der Client "category":"JAVA" sendet, Sie das Feld aber in categoryCode umbenannt haben ohne @JsonProperty("category"), erhalten Sie ein „plötzlich leeres Feld“ und Validierungsfehler. Und der Client wird sicher sein, dass er alles richtig gesendet hat.
6. Typische Fehler bei JSON-Verträgen
Fehler Nr. 1: Datum als „nicht leer“ testen.
Eine Prüfung wie „publishedAt ist nicht null“ beweist so gut wie nichts. Sie schützt nicht vor Formatwechsel, Zeitzonenwechsel, Auftauchen von Millisekunden oder Umstellung auf Timestamp. Wenn das Feld Teil des Vertrags ist, sollte der Test ein konkretes Format fixieren (zumindest mit einem wichtigsten Beispiel).
Fehler Nr. 2: now() im DTO bei JSON-Überprüfungen verwenden.
Wenn im Test Instant.now() auftaucht, unterschreiben Sie ewige Instabilität: Die Zeitzeichenkette ist bei jedem Durchlauf anders, manchmal auch mit unterschiedlicher Genauigkeit. In JSON-Vertragstests muss die Zeit fest sein (Instant.parse("...")), sonst testen Sie nicht den Vertrag, sondern die Mondphase.
Fehler Nr. 3: Die Umbenennung einer Enum-Konstante als „internes Refactoring“ betrachten.
Solange das Enum nur in Java lebt – ja, das ist intern. Sobald das Enum als String im JSON erscheint, ist es ein externer Vertrag. Wenn Sie IN_REVIEW umbenannt haben, seien Sie auf eine bewusste API-Änderung gefasst. Ein JSON-Test hindert nicht, sondern rettet: Er zwingt Sie zu einer Entscheidung, anstatt „allen Clients das Leben zufällig zu erschweren“.
Fehler Nr. 4: Keine Strategie für das Verhalten von null festlegen.
Wenn Sie nicht entscheiden, was mit optionalen Feldern passieren soll, beginnt das Projekt zu „driften“: Mal fehlt das Feld, mal ist es null, mal ist es ein leerer String. Das tut den Clients weh, weil sie mehr Bedingungen schreiben und über die Bedeutung rätseln müssen. Wählen Sie einmal eine Strategie und fixieren Sie sie mit Tests.
Fehler Nr. 5: JSON-Feldnamen als „Kosmetik“ betrachten.
Aus Sicht des Clients sind authorUsername und author zwei verschiedene Felder. Und selbst wenn Sie überzeugt sind, dass „das Frontend das schnell korrigieren kann“, ist die Realität meist komplizierter: Mobile Apps brauchen Wochen für Updates, externe Integrationen Monate, und einen Teil der Clients kontrollieren Sie gar nicht. Daher ist der Feldname ein Teil des öffentlichen API, und Tests sollten ihn genauso streng behandeln wie Statuscodes oder Pflichtfelder.
GO TO FULL VERSION