CodeGym /Kurse /Spring Test /Setup-Disziplin in JPA-Tests

Setup-Disziplin in JPA-Tests

Spring Test
Level 15, Lektion 4
Verfügbar

1. Wie JPA einen Test „grün“ macht

Die größte Falle bei JPA-Tests sind verwaltete (managed) Objekte innerhalb des Persistence Context. Solange das Objekt „verwaltet“ ist, kann JPA Ihnen dasselbe Objekt erneut zurückgeben, selbst wenn Sie find() aufrufen. Und Sie – sehr menschlich – fangen an zu denken: „Wir haben aus der Datenbank neu gelesen“ – obwohl Sie einfach nur wieder dasselbe Objekt im Speicher betrachtet haben.

Es ist nützlich, sich folgendes Bild zu merken: In einem @DataJpaTest leben Sie fast immer in einer einzigen Testtransaktion, JPA führt seinen eigenen internen Kontext, und die DB ist eine separate Welt, in die Änderungen stoßweise gelangen – meistens bei flush().

flowchart TD
    A["Java-Objekt (managed)"] -->|Änderungen| B["Persistence Context (1st level cache)"]
    B -->|"flush()"| C["Datenbank (Tabellen)"]
    C -->|select| B
    B -->|Rückgabe| A

Für sich genommen ist diese „Magie der Bequemlichkeit“ im Produktionscode eine gute Sache: weniger Abfragen, weniger unnötige Ladungen, schneller. Aber im Test verwandelt sie sich leicht in eine Illusion: Sie überprüfen nicht das, was tatsächlich in der DB gespeichert ist, sondern das, was in Ihrem Arbeitsspeicher liegt. Daher ist die Setup-Disziplin eine Möglichkeit, „den Test dumm zu machen“ – im guten Sinne: damit er Fakten prüft, nicht Annahmen.

2. Ein Test – eine Frage

In Datentests ist es besonders leicht, in den Modus zu verfallen: „Na, da ich ja sowieso den Kontext hochgefahren habe, überprüfe ich noch zehn Dinge.“ Und das endet meistens damit, dass der Test lang, unverständlich wird und „irgendwo da“ fehlschlägt, und Sie spielen dann eine Stunde lang Archäologe: Ausgrabungen, Tränen, Kaffee – und am Ende ist trotzdem flush() schuld. Es ist besser, zuerst ehrlich die Testfrage zu formulieren – und erst dann das Setup genau darauf auszurichten.

Disziplin beginnt mit einer einfachen Gewohnheit: im Namen und im Kopf eine einzige Aussage über das Verhalten von Persistenz (persistence behavior) zu halten. Nicht: „Artikel speichern, aktualisieren, Constraints prüfen und gleichzeitig count()“, sondern zum Beispiel: „Nach dem Speichern liegt slug wirklich in der Datenbank“ oder „Die DB schützt wirklich die Eindeutigkeit von slug“.

Unten folgt eine kleine „Spickzettel-Frageliste“. Es ist keine Checkliste, sondern nur eine Möglichkeit, sich selbst zu ertappen, wenn Sie anfangen, zu viel vorzubereiten:

Testfrage (was beweisen wir?) Minimaler Aktionssatz Typische Überprüfung
Feld wurde tatsächlich in die Tabelle gespeichert save → flush → clear → reread Assert auf das neu gelesene Objekt
DB erzwingt wirklich Unique 1 Eintrag flush, 2. Eintrag, dann flush Beim zweiten flush() erwarten wir eine Ausnahme
DB erzwingt wirklich Not-Null Objekt mit null im Pflichtfeld erstellen, persist, dann flush Erwarten einer Ausnahme bei flush()
Es gibt tatsächlich N Zeilen N Entitäten speichern, flush → clear count() oder separate Abfrage
Feld wurde tatsächlich aktualisiert laden/erstellen, ändern, flush → clear → reread Assert auf das neu gelesene Objekt

Beachten Sie: Überall tauchen flush() und oft clear() auf. Das liegt nicht daran, dass wir es lieben, überflüssige Zeilen zu schreiben, sondern weil diese Aufrufe die Grenze zwischen „im Speicher“ und „in der DB“ sichtbar machen.

Und noch ein psychologischer Punkt. Wenn Sie sich vorher darauf einstellen „Ein Test – eine Frage“, fällt es Ihnen leichter zu akzeptieren, dass es mehr Tests geben wird. Das ist normal. Data-Layer-Tests sind oft kürzer, als sie scheinen, wenn man sie nicht in ein „universelles Szenario für die gesamte Anwendung“ verwandelt.

3. Minimale gültige Fixture

Wenn ein Student zum ersten Mal einen @DataJpaTest schreibt, möchte man gerne eine „ideale Fixture“ erstellen: alle Felder des Artikels befüllen, alle Daten setzen, einen realistischen Text ausdenken, alles Mögliche hineinpacken und das dann in jedem Test verwenden. Das klingt nach Qualitätsbewusstsein, aber in der Praxis wird es zu einem Monster: In jedem Test ist die Hälfte der Felder irrelevant, behindert aber das Lesen des Szenarios. Und manchmal scheitert es aufgrund von Not-Null/Constraints so, dass Sie nicht verstehen – fällt der Test wegen dem, was Sie prüfen wollten, oder weil die Fixture zufällig ungültig ist?

Das richtige Ziel einer Fixture in Datentests ist es, gültig und minimal zu sein. Das heißt, sie sollte nur das enthalten, was nötig ist, damit der Eintrag in Ihrem Schema überhaupt gültig ist, und das, was für diesen Test wichtig ist. Alles andere ist Rauschen.

Nehmen wir an, in ContentHub haben wir einen Article mit den Pflichtfeldern title/summary/body/category/slug/authorUsername/status. Dann könnte eine minimale „Entwurfs“-Fixture so aussehen: Ja, Setters sind etwas ausführlich; dafür sind sie transparent.

import java.time.Instant;

private Article draftArticle(String slug, Category category) {
    // slug und category werden als Parameter übergeben, weil sie oft die "Variablen" des Tests sind:
    // wir wollen einfach verschiedene Datensätze erstellen und Unique/Suche/Filterung prüfen.
    Article a = new Article();

    // Minimal notwendige Felder für einen gültigen DB-Eintrag (gemäß unserem Schema).
    a.setTitle("Java Basics");
    a.setSlug(slug);
    a.setSummary("Short intro");
    a.setBody("""
            Longer text...
            """);
    a.setAuthorUsername("alice");
    a.setStatus(ArticleStatus.DRAFT);
    a.setCreatedAt(Instant.parse("2026-01-01T10:00:00Z"));

    // Die Beziehung (z. B. @ManyToOne) muss ebenfalls gesetzt werden, wenn sie obligatorisch ist.
    a.setCategory(category);

    return a;
}

Diese Form der Fixture behalten wir als Basis: eine explizite Category plus einen minimalen Satz an Pflichtfeldern für Article. Kurze Helper wie draftArticle(...) sind nur eine Abkürzung dieses Setups.

In diesem Ausschnitt sind zwei Dinge wichtig. Erstens sehen wir explizit, welche Felder obligatorisch sind: Das hilft dem Gehirn, das Datenmodell zu verstehen. Zweitens versuchen wir nicht, einen „echten Artikel über 3 Bildschirme“ zu simulieren – denn wir testen Persistenz, nicht literarisches Talent.

Nun zu Category. In einer idealen Welt kommen die Kategorien über Migrationen und Seed-Daten ins System. Aber in einem @DataJpaTest erstellen Sie sie oft lokal, und das ist für die Lernschicht in Ordnung, solange Sie nicht an fremde Artefakte stoßen. Die Hauptsache ist, dass im Test offensichtlich ist, woher die Kategorie stammt.

private Category category(String code) {
    Category c = new Category();

    // code ist der "technische Schlüssel" (oft unique), daher praktisch als Parameter.
    c.setCode(code);

    // Name machen wir simpel und lesbar: uns interessiert Gültigkeit, nicht Realitätsnähe.
    c.setName(code.toUpperCase());

    return c;
}

Wenn Sie sich dabei ertappen, dass Sie eine 30-zeilige Fixture erstellt haben, die einen Artikel, eine Kategorie, Anhänge erstellt, Daten setzt und zusätzlich noch zufällig einen Slug generiert … halten Sie inne. Das bedeutet fast sicher, dass Sie Daten „für alles Mögliche“ vorbereiten, und nicht für die konkrete Testfrage.

4. Helper-Methode ohne verstecktes flush() und clear()

Helper in Tests sind wie Gewürze: Ohne sie ist es fad, aber wenn man übertreibt, schmeckt man das Gericht nicht mehr. Im heutigen Thema ist es besonders kritisch, dass der Helper die Schlüsselschritte, die den Test ehrlich machen, nicht versteckt. Wenn Sie flush() und clear() so versteckt haben, dass im Test nicht sichtbar ist, wann Sie tatsächlich „mit der Datenbank gesprochen“ haben, kehren Sie wieder zu Magie und Illusionen zurück.

Der nützlichste Helper ist der, der die „Schreibgrenze“ kurz hält, aber offensichtlich bleibt. Er kann Boilerplate kapseln, darf aber die Bedeutung von flush()/clear() nicht durch einen neutralen Namen verbergen. Zum Beispiel:

import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;

private void persistFlushAndClear(TestEntityManager em, Article article) {
    // persist – Entität zum Persistence Context hinzugefügt (noch nicht notwendigerweise in der Tabelle)
    em.persist(article);

    // flush – Änderungen an die DB gesendet (INSERT/UPDATE auf SQL-Ebene ausgeführt)
    em.flush();

    // clear – Persistence Context geleert, damit das nächste Lesen "neu" ist und nicht aus dem 1st-Level-Cache
    em.clear();
}

Beachten Sie: Der Methodenname sagt die Wahrheit. Es ist nicht prepareArticle() und nicht givenArticle(), sondern das direkte persistFlushAndClear().

Häufige Frage: „Aber warum clear() innerhalb des Helpers? Vielleicht möchte ich clear nur vor dem finalen Reread machen?“ Die Antwort hängt vom Test ab, aber ein solcher Helper ist gerade deshalb gut, weil er einen „ehrlichen Punkt“ nach dem Setup bereitstellt. Wenn Sie persistFlushAndClear(...) schreiben, können Sie sicher sein: Nach dieser Zeile wird der nächste find()/repository.findById() nicht mehr „die alte Referenz zurückgeben“, weil der Kontext geleert wurde.

Wenn Sie repository.save() verwenden, hebt das flush/clear nicht auf. Es verschiebt nur die Stelle, an der Sie sie setzen:

Article saved = repository.save(article);
// flush – JPA zwingen, tatsächlich jetzt mit der DB zu synchronisieren
entityManager.flush();
// clear – managed-Entitäten entfernen, um nicht "den Speicher statt der DB zu prüfen"
entityManager.clear();

Die Bedeutung ist dieselbe: Sie legen den Zeitpunkt fest, an dem Sie einen DB-Effekt erwarten, und zwingen das nächste Lesen, „neu“ und nicht zwischengespeichert zu sein.

5. AAA in Datentests: Setup, Action, Verification

Selbst wenn Sie bereits an Arrange–Act–Assert in Unit-Tests gewöhnt sind, wird diese Regel in Datentests noch wichtiger. Der Grund ist einfach: Sie haben mehr „Weltzustand“ – den Persistenzkontext und die Datenbank. Wenn Sie Datenvorbereitung, Aktion und Überprüfung in eine lange Kette mischen, verlieren Sie die Kontrolle darüber, wo genau der DB-Effekt aufgetreten ist und was Sie eigentlich überprüfen.

Ein pragmatischer Stil für @DataJpaTest sieht so aus: Im Setup erstellen Sie nur das Notwendige, dann führen Sie die Aktion aus, danach setzen Sie die Grenze flush/clear und danach lesen Sie neu und prüfen. Wichtig ist, dass die Überprüfung auf dem neu gelesenen Objekt basiert, nicht auf dem, das Sie gerade bearbeitet haben.

Hier ist ein Beispiel für einen „ehrlichen“ Test zur Speicherung von slug. Beachten Sie die Leerzeilen: Das ist keine Ästhetik, sondern Navigation für das Gehirn.

import org.junit.jupiter.api.Test; 
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import static org.assertj.core.api.Assertions.assertThat;

@DataJpaTest
class ArticleRepositoryDataJpaTest {

    @Test
    void save_persistsSlug() {
        // Arrange: minimale gültige Daten vorbereitet
        Category java = category("java");
        entityManager.persist(java);

        Article a = draftArticle("java-basics", java);

        // Act: über Repository speichern (befinden uns immer noch "im Kontext")
        repository.save(a);

        // Grenze zur DB: Änderungen in den Tabellen festgeschrieben und managed-Objekte aus dem Persistence Context entfernt
        entityManager.flush();
        entityManager.clear();

        // Assert: neu lesen (das ist der Schlüssel, sonst prüft man leicht "das Objekt im Speicher")
        Article fromDb = entityManager.find(Article.class, a.getId());
        assertThat(fromDb.getSlug()).isEqualTo("java-basics");
    }
}

Ja, hier gibt es etwas mehr Zeilen als in der „schlechten“ Variante. Aber dafür beweist der Test, was im Namen behauptet wird: slug wurde tatsächlich geschrieben und tatsächlich neu gelesen.

Und nun die schlechte Variante – sehr kurz, sehr „grün“ und beweist fast nichts:

Article a = draftArticle("java-basics", java);
Article saved = repository.save(a);

// Hier prüfen wir im Grunde nur, dass die Zeichenkette bis zum Objekt gelangt ist (und dass setSlug nicht kaputt ist),
// aber wir beweisen nicht, dass der Wert tatsächlich in der DB gelandet ist.
assertThat(saved.getSlug()).isEqualTo("java-basics");

Dieser Test wird mit hoher Wahrscheinlichkeit bestehen, selbst wenn Sie irgendwo ein Problem mit Mapping/DDL haben, denn saved ist dasselbe Objekt, das Sie ohnehin im Speicher halten. Sie haben überprüft, dass der Setter funktioniert und dass Sie die Zeichenkette korrekt an das Objekt übergeben haben. Das ist nützlich … etwa eine Millisekunde lang.

Ein weiterer wichtiger Gedanke: Man muss nicht immer alles neu lesen. Wenn die Testfrage die Anzahl der Einträge ist, dann ist eine Überprüfung von count() nach einer ehrlichen Grenze sogar besser, weil sie die Frage einfacher beantwortet:

// Arrange + Act: zwei Entitäten gespeichert
repository.save(draftArticle("a-1", java));
repository.save(draftArticle("a-2", java));

// Grenze zur DB: sicherstellen, dass die INSERTs wirklich in die Tabelle gelangt sind, und den 1st-Level-Cache leeren
entityManager.flush();
entityManager.clear();

// Assert: Testfrage – ob tatsächlich 2 Zeilen vorhanden sind
assertThat(repository.count()).isEqualTo(2L);

Hier hat der Test eine Frage: ob tatsächlich zwei Zeilen gespeichert wurden. Und er beantwortet genau diese. Keine „zusätzliche Sicherheitsüberprüfung“ in Form eines Vergleichs von title, summary, status und des Nachnamens der Katze des Autors.

6. Nützliche Nuancen der Setup-Disziplin

„Zu schlaue“ Helper

Wenn man anfängt, Ordnung zu schaffen, ist die erste Gefahr, ein „Mini-Framework für Tests“ zu bauen. Alles wird in ein DSL gewickelt, die Fixture-Factory wird zu einer Fabrik von Fabriken, und der Helper macht intern persist, save, flush, clear und ändert „für alle Fälle“ noch ein paar Felder. Das Ergebnis: Der Test sieht aus wie eine Zeile, aber der Sinn ist völlig verborgen: man versteht nicht, wo die Daten tatsächlich in die DB gelangt sind, wo wir neu lesen und warum eine Ausnahme in einer seltsamen Zeile auftritt.

Zum Beispiel, hier ein Helper, der bequem aussieht, aber gefährlich ist:

private Article persistedDraft() {
    // Kategorie direkt im Helper erstellt – der Test sieht von außen einen wichtigen Teil des Setups nicht mehr.
    Category java = category("java");
    entityManager.persist(java);

    // Gibt eine managed-Entität zurück und macht kein flush/clear –
    // von außen kann man sehr leicht anfangen, "den Speicher zu prüfen", während man glaubt, die DB zu prüfen.
    Article a = draftArticle("java-basics", java);
    return repository.save(a);
}

Was ist hier schlecht? Die Methode gibt ein verwaltetes Objekt zurück und hebt in keiner Weise hervor, ob ein flush() und ein unabhängiges Lesen stattgefunden haben. Wenn der Test später Assertions auf das zurückgegebene Article macht, überprüft er erneut das „Objekt im Speicher“.

Richtiger ist es, entweder die ID zurückzugeben und den Test explizit neu lesen zu lassen, oder, wenn Sie genau einen „fertigen persisted Zustand“ wünschen, eine ehrliche Grenze in den Helper einzubauen und dies im Namen widerzuspiegeln:

private Long persistDraftAndClear(String slug) {
    // Setup der Abhängigkeiten: zuerst die obligatorische Kategorie speichern
    Category java = category("java");
    entityManager.persist(java);

    // Artikel speichern
    Article a = draftArticle(slug, java);
    entityManager.persist(a);

    // Grenze zur DB
    entityManager.flush();
    // managed-Objekte entfernen, damit das nächste find ein echtes "Neu-Lesen" ist
    entityManager.clear();

    // ID zurückgeben, damit der Test nicht versehentlich mit der managed-Entität weiterarbeitet
    return a.getId();
}

Ja, es klingt etwas weniger romantisch als givenPublishedArticle(), aber dafür ist es ehrlich. Der Name sagt: „Danach kannst du nicht versehentlich das managed-Objekt prüfen“. Und der Test sieht danach transparent aus: er liest anhand der ID neu und prüft.

Es gibt noch ein weiteres subtiles Problem mit „schlauen“ Helfern: Sie mischen überflüssige Daten bei und verletzen versehentlich Constraints. Sie wollten Not-Null auf title prüfen, aber der Helper macht zusätzlich in allen Tests denselben slug. Und dann schlägt Unique statt Not-Null fehl. Der Test wird nicht wegen JPA schlecht – er wird wegen einer ungenauen Setup-Disziplin schlecht.

Unabhängiges Lesen und „ehrliche“ Überprüfung

Es gibt eine Reihe von Aktionen, die man in einem Datentest besser maximal sichtbar hält. Sie müssen nicht 10 Zeilen lang sein, aber sie müssen lesbar sein. Das Paar flush/clear haben wir bereits ausführlich behandelt; hier geht es nicht um einen neuen JPA-Trick, sondern um eine operationale Regel: Die finale Prüfung muss auf einem Reread nach der Löschung des Kontexts basieren. Sonst wird selbst ein sorgfältiges Setup schnell zu einer grünen Selbsttäuschung.

Eine Mini-Demonstration, die man nützlicherweise manchmal parat hat, sieht so aus: Ohne clear() können Sie dieselbe Referenz zurückbekommen – das ist für JPA normal, aber für den Test gefährlich.

Article a = draftArticle("java-basics", java);
entityManager.persist(a);

// flush sendet Änderungen an die DB, löscht aber NICHT den Persistence Context
entityManager.flush();

// find kann dieselbe managed-Entität zurückgeben (dasselbe Objekt im Speicher)
Article same = entityManager.find(Article.class, a.getId());
assertThat(same).isSameAs(a);

Das ist kein „JPA-Bug“. Das ist normales Verhalten des Persistenzkontexts. Aber wenn Sie danach assertThat(same.getTitle())... machen, haben Sie immer noch nichts über den tatsächlichen Datenbankeintrag bewiesen.

Und hier die minimal ehrliche Version – fast dieselbe, aber mit dem kritischen clear():

Article a = draftArticle("java-basics", java);
entityManager.persist(a);
entityManager.flush();

// clear erzwingt, dass das nächste Lesen tatsächlich zur DB geht (und ein anderes Objekt zurückgibt)
entityManager.clear();

Article reloaded = entityManager.find(Article.class, a.getId());
assertThat(reloaded).isNotSameAs(a);

Hier prüft der Test immerhin die „Tatsache des erneuten Lesens“. Und das ist bereits ein guter Schutz gegen Selbsttäuschung. In echten Tests werden Sie natürlich nicht isNotSameAs prüfen, sondern inhaltliche Felder, aber die Technik bleibt dieselbe: Die finale Prüfung muss auf einem Reread nach clear() basieren, sonst verwandeln Sie den Datentest sehr leicht in eine Java-Objekt-Prüfung.

7. Typische Fehler bei der Setup-Disziplin in JPA-Tests

Fehler Nr. 1: Der Helper ist „zu schlau“ und versteckt den Moment des Schreibens.
Wenn im Test nur eine Zeile wie Article a = persistedDraft() übrig bleibt, sehen Sie nicht mehr, ob ein flush(), ein clear() oder ein erneutes Lesen stattgefunden haben. Der Test wird schön, verliert aber an Beweiskraft: Sie sind nicht mehr sicher, ob Sie die DB oder den „JPA-Speicher“ prüfen. Wenn ein Helper existiert, sollte er entweder explizit flush/clear enthalten oder die ID zurückgeben und den Test zum erneuten Lesen zwingen.

Fehler Nr. 2: Die Fixture ist zu groß und bringt unnötige Fehlerursachen mit sich.
Ein großer „universeller Article“ mit 25 Feldern sieht solide aus, bricht aber bei jeder Schemaänderung, bei jedem neuen Not-Null, bei jedem zusätzlichen Constraint. Infolgedessen fällt der Test nicht dort, wo Sie die Regel prüfen wollten, sondern weil die Fixture plötzlich ungültig geworden ist. Eine minimale gültige Fixture gewinnt fast immer: weniger Rauschen, weniger unerwartete Fehlerursachen, schnellere Diagnose.

Fehler Nr. 3: Ein Test prüft drei verschiedene Fragen, und Sie verstehen nicht, welche genau fehlgeschlagen ist.
Wenn der Test gleichzeitig „speichern, aktualisieren, Unique prüfen, Count prüfen“ beinhaltet, sieht ein Fehler aus wie „irgendetwas ist schiefgelaufen“. Selbst wenn Sie Stacktraces gut lesen können, verlieren Sie Zeit damit, herauszufinden, „welcher Teil des Szenarios das eigentliche Ziel war“. Besser drei kurze Tests, die jeweils eine Frage beantworten, als ein „epischer“ Test, der wie ein alter Drache abstürzt, aber unklar warum.

Fehler Nr. 4: Assertions werden auf demselben Objekt durchgeführt, das der Test gerade geändert hat.
Das ist die heimtückischste Variante eines „zufällig grünen“ Tests. Sie haben article.setTitle("Changed") aufgerufen und dann assertThat(article.getTitle())...isEqualTo("Changed") geprüft. Das ist zu 100% wahr … und zu 0% über die Datenbank. Ein Datentest sollte den Datenbankzustand prüfen: nach flush/clear neu lesen und das neu Gelesene prüfen – sonst testen Sie den Setter, nicht die Persistenz.

Fehler Nr. 5: Der Testname spricht von der DB, aber der Code im Inneren handelt vom Objekt im Speicher.
Wenn der Test shouldPersistSlugToDatabase heißt, aber weder flush() noch clear() noch ein Reread enthält, ist das nicht nur ein Stilproblem. Es ist ein Vertrauensproblem: Sie oder Ihr Kollege werden in einem Monat denken, dass der Test tatsächlich die Persistenz sicherstellt, aber er stellt nur sicher, dass Sie die Zeichenkette an setSlug() übergeben haben. Und das ist die unangenehmste Art von grünen Tests: solche, die mehr versprechen, als sie tatsächlich beweisen.

1
Aufgabe
Spring Test, Level 15, Lektion 4
Gesperrt
Minimale gültige Fixtur für `Article`
Minimale gültige Fixtur für `Article`
1
Aufgabe
Spring Test, Level 15, Lektion 4
Gesperrt
Expliziter Helper `flushAndClear()`
Expliziter Helper `flushAndClear()`
1
Umfrage/Quiz
JPA-Kontext, Level 15, Lektion 4
Nicht verfügbar
JPA-Kontext
Entitäten, flush und Constraints
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION