CodeGym /Kurse /Hibernate deep-dive /ORM-Bugs und Integrationstests

ORM-Bugs und Integrationstests

Hibernate deep-dive
Level 28, Lektion 0
Verfügbar

1. Was ist ein ORM-Bug

Wenn wir das Wort „Bug“ hören, malt das Gehirn normalerweise ein einfaches Bild: „Ich habe status = CANCELLED zugewiesen, aber im Objekt ist aus irgendeinem Grund status = NEW“. Das ist ein klassischer, fast gemütlicher Java-Fehler. ORM-Bugs verhalten sich anders: Sie zeigen sich oft nicht als „falscher Wert“, sondern als unerwartetes SQL, ein plötzliches UPDATE, eine Lawine von Abfragen oder eine Ausnahme, die nur bei tatsächlicher Interaktion mit der Datenbank und der Transaktion auftritt.

Im Hibernate Deep-Dive haben wir immer wieder die Formel wiederholt: „Code + Persistenzkontext + Transaktion + Datenbank = reales Verhalten“. Daher ähneln ORM-Bugs eher Problemen der Grenze als der internen Logik eines Objekts. Sie können beispielsweise Felder in einer managed-Entity perfekt ändern, aber trotzdem ein „warum wurde nicht gespeichert“ erhalten, weil der tatsächliche flush nicht dort stattfand, wo Sie es erwartet haben. Oder umgekehrt – Sie haben „nichts gespeichert“, aber es kommt trotzdem ein UPDATE, weil das Dirty Checking entschieden hat, dass sich der Zustand geändert hat.

Es ist hilfreich, einen einfachen Unterschied im Kopf zu behalten (ohne Religion und Dramatik): Ein normaler Bug in Java wird meistens durch Überprüfung des Objektzustands im Speicher gefunden, während ein ORM-Bug die Beobachtung dessen erfordert, was um das Objekt herum passiert – im Persistenzkontext, im SQL und in der Datenbank. Das ist genau das, was ein Unit-Test per Definition nicht anfassen will (und darin ist er gut), während ein Integrationstest im Gegenteil genau das einbeziehen muss (und dafür lieben wir ihn, auch wenn er langsamer ist).

Um den Unterschied zu spüren, stellen Sie sich einen Fallschirm vor. Ein Unit-Test prüft, ob der Stoff reißfest ist, die Leinen nicht reißen, der Karabiner klickt. Alles wichtig. Aber die Frage „Wird er sich im Sprung öffnen?“ bleibt trotzdem. Der Sprung ist der Integrationstest. ORM ist so ein „Sprung“: Solange Sie nicht zur Datenbank gegangen sind und eine Transaktion durchlebt haben, haben Sie das Interessanteste nicht geprüft.

2. Unit-Test und die Realität von Hibernate

Ein Unit-Test ist eine großartige Sache, besonders für Logik, die von der Infrastruktur getrennt werden kann: Berechnungen, DTO-Mapping, Validatoren, kleine Domänenregeln. Aber Hibernate ist ein Laufzeitsystem, das sich je nach Kontext unterschiedlich verhält: ob ein Persistenzkontext geöffnet ist, ob eine Transaktion existiert, welcher Flush-Mode, welche Datenbank, welche Schema-Einschränkungen, welche Indizes, welcher Ausführungsplan, welche ID-Strategie. In Mocks gibt es diese „Realität“ einfach nicht.

Die typischste Falle für Anfänger sieht so aus: „Ich mocke das Repository, und der Test prüft alles“. Technisch gesehen prüfen Sie, dass der Service repository.save(...) aufruft. Aber das ist keine Überprüfung des ORM-Verhaltens – es ist eine Überprüfung, dass Sie eine Methode aufrufen können. Hibernate ist zu diesem Zeitpunkt noch nicht einmal aufgewacht, es wurde kein SQL generiert, es gab keine Transaktion, das Dirty Checking hat keine Snapshots verglichen, flush wurde nicht gestartet. Sie haben den „Telefonanruf“ geprüft, nicht das „Gespräch“.

Hier ist ein kleines Beispiel, wie die „Mock-Realität“ aussieht. Der Code ist nicht schlecht als Unit-Test, er löst nur eine andere Aufgabe.

import org.junit.jupiter.api.Test;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

class MockedRepositoryExampleTest {

    @Test
    void mockedRepositoryReturnsPlainObject() {
        // Wichtig: Das ist genau ein Mock, Hibernate ist hier nicht beteiligt
        ProductRepository repo = mock(ProductRepository.class);

        // Wir geben ein normales Objekt zurück, keinen Hibernate-Proxy
        when(repo.getReferenceById(1L)).thenReturn(new Product()); // kein Proxy, einfach new

        Product product = repo.getReferenceById(1L);

        // Wichtig: Hier haben Sie nicht den Hibernate-Proxy, Lazy-Init und die Verbindung zum Persistenzkontext geprüft.
        // Dieser Test prüft nur das Verhalten des Mocks, nicht das ORM.
    }
}

In einer echten Anwendung ist getReferenceById() (oder EntityManager.getReference()) eine Geschichte über Proxy, über die Bindung an den Persistenzkontext und darüber, dass „der Zugriff auf ein Feld plötzlich zu SQL wird“. In Mocks wird dieser Sinn überhaupt nicht reproduziert. Sie können tausend when(...) thenReturn(...) schreiben – daraus wird kein Proxy, egal wie sehr Sie sich bemühen.

Genau deshalb werden ORM-Bugs extrem selten von Unit-Tests erfasst. Sie sind zu „flach“: Es gibt keine Datenbank, keine Transaktion, kein SQL, und damit auch keine Umgebung, in der unsere realen Probleme leben: N+1, Lazy Loading, unerwarteter flush, merge() mit Zustandskopierung, Optimistic-Lock-Konflikte, veralteter Persistenzkontext nach Bulk-Operationen.

Der normale technische Gedanke hier ist nicht „Unit-Tests sind schlecht“, sondern „wir müssen das richtige Werkzeug wählen“. So wie man mit einem Hammer unbequem Brot schneidet (aber gut einen Nagel einschlägt), so ist es mit Mocks unbequem, ORM-Bugs zu fangen (aber gut zu prüfen, dass der Service keine unnötigen Aufrufe macht und sich richtig verzweigt).

3. Zwei Tests – zwei Welten

Am besten wird dieses Thema durch den Kontrast deutlich: Ein Test „über ein Java-Objekt“, der zweite – „über einen echten JPA/Hibernate-Kontext“. Beide Tests können ehrlich und nützlich sein, sie beantworten nur unterschiedliche Fragen. Der erste beantwortet: „Weist mein Code überhaupt einen Wert zu?“. Der zweite: „Funktioniert mein Code in der Welt von Hibernate, Proxys, Transaktionen und SQL?“

Beginnen wir mit dem gemütlichen Unit-Test. Er prüft nur das Verhalten eines einfachen Objekts. Es ist nichts „Schlechtes“ daran – er ist einfach nicht für ORM da.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class PurchaseOrderPojoTest {

    @Test
    void changesStatusInMemory() {
        // Hier arbeiten wir nur mit POJO im Speicher – ohne DB und Hibernate
        PurchaseOrder order = new PurchaseOrder();

        // Wir prüfen die normale Zuweisungslogik
        order.setStatus(OrderStatus.CANCELLED);

        assertEquals(OrderStatus.CANCELLED, order.getStatus());
    }
}

Dieser Test wird auch dann bestanden, wenn PurchaseOrder überhaupt keine @Entity ist, wenn die Tabelle purchase_order nicht existiert, wenn der Status in der Datenbank schief gespeichert wird, wenn ein Mapping-Fehler vorliegt, wenn Sie eine Spalte vergessen haben, wenn der Enum nicht mit dem Wert übereinstimmt. Er prüft genau das, was er prüft: Das Feld ändert sich im Speicher.

Und jetzt – ein Test „aus einer anderen Welt“. Hier geht es nicht darum, „nur ein Objekt zu holen“, sondern darum, dass getReferenceById() einen Verweis zurückgibt und das tatsächliche Lesen nach den Regeln von Hibernate innerhalb des Kontexts erfolgt.

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

import static org.junit.jupiter.api.Assertions.assertEquals;

@DataJpaTest
class ProductReferenceTest {

    // Das Repository wird in einem echten Spring/JPA-Kontext hochgefahren
    @Autowired ProductRepository productRepository;

    @Test
    void getReferenceById_resolvesSkuInsideJpaContext() {
        // Wichtig: Hier wird in der Regel ein Proxy zurückgegeben, keine vollständig geladene Entität
        Product product = productRepository.getReferenceById(1L);

        // Wichtig: Der Zugriff auf das Feld kann SQL auslösen, daher sind JPA-Kontext und Transaktion erforderlich
        assertEquals("SKU-1", product.getSku()); // Zugriff auf Daten erfordert ORM-Umgebung
    }
}

Dieser Test lässt sich nicht „ehrlich“ ohne JPA-Kontext ausführen. Und genau solche Tests brauchen wir, wenn wir ORM-Verhalten prüfen. Nicht, weil wir schwere Tests lieben (wir sind keine Masochisten), sondern weil Hibernate keine Bibliothek „über Felder“ ist, sondern ein System „über die Interaktion mit der Datenbank“.

Kurz zusammengefasst: Der erste Test beweist, dass Sie einen Wert zuweisen können, der zweite Test beweist, dass Sie in der Realität von Hibernate leben können. Und im Deep-Dive-Kurs interessiert uns genau die zweite Realität – weil die erste normalerweise viel seltener kaputt geht (Java ist in dieser Hinsicht ziemlich ehrlich).

4. Beobachtbares ORM-Verhalten

Wenn wir sagen „Integrationstest für ORM“, ist es wichtig, nicht in die vage Aussage abzurutschen „na ja, er ist mit der Datenbank, also ist alles in Ordnung“. Mit der Datenbank kann alles „in Ordnung“ sein, aber das Fetching ist schlecht, flush ist unerwartet, Bulk-Operationen hinterlassen einen veralteten Zustand, merge() schleppt unnötige SELECT mit, Optimistic Locking wird nicht gefangen, Soft Delete zerstört die Sichtbarkeit von Listen. Daher sollte eine gute Integrationsprüfung nicht nur auf das „Ergebnis“ schauen, sondern auch auf die „Spuren im Schnee“ – also auf das beobachtbare Persistenzverhalten.

Es ist praktisch, sich das als zwei Vertragsebenen vorzustellen. Die erste Ebene ist funktional: „Die Bestellung wurde gespeichert“, „Das Produkt wurde gefunden“, „Der Bestand wurde verringert“. Die zweite Ebene ist persistenzorientiert: „Es gab kein N+1“, „Nach einer Bulk-Operation blieb der Zustand nicht veraltet“, „getReferenceById() ist beim Zugriff auf das Feld nicht explodiert“, „Nach clear() werden Daten neu aus der Datenbank gelesen“, „Ein konkurrierender Konflikt zeigt sich als erwartete Ausnahme“.

Unten ist eine Tabelle. Sie ersetzt keine Details, hilft aber zu verstehen, warum Unit-Tests gegenüber diesen Risiken „blind“ sind.

Risikotyp in ORM Wie zeigt er sich in der Realität Was im Integrationstest beobachten Warum ein Unit-Test das nicht fängt
Lazy-Probleme LazyInitializationException oder unerwartete Abfragen beim Zugriff auf ein Feld Ausnahme beim Zugriff außerhalb des Kontexts oder Tatsache von SQL beim Zugriff In Mocks gibt es keinen Persistenzkontext und Proxy
N+1 Viele SELECT statt einem Anzahl der Abfragen / SQL-Trace Im Unit-Test gibt es kein SQL und keine Statistik
flush-Semantik SQL geht früher raus, als Sie dachten DB-sichtbarer Zustand nach flush + erneutes Lesen Ohne DB und Transaktion existiert flush nicht
merge() Es wird eine andere managed-Instanz zurückgegeben, mögliche unnötige Lesezugriffe Unterschied source/result, Vorhandensein von SELECT Im Unit-Test ist merge – „nur eine Methode“, kein ORM-Verhalten
Bulk-Nebeneffekte Daten in der DB haben sich geändert, aber managed-Objekte sind alt geblieben Veralteter Zustand vor clear(), Korrektheit nach erneutem Lesen Im Unit-Test gibt es keinen Persistenzkontext, es gibt nichts zu „veralten“
Optimistic Locking Versionskonflikt bei UPDATE Erwartete Ausnahme bei gleichzeitigem Update Ohne echte DB und Version-Spalte gibt es keinen Konflikt
Schema-Constraints Eindeutigkeit/Foreign-Key wird von der DB abgefangen Ausnahme bei flush, nicht bei setSku() Unit-Test kennt keine Constraints

Beachten Sie, dass fast überall die Wörter „SQL“, „Kontext“, „Transaktion“, „Sichtbarkeit in der DB“ vorkommen. Das ist der Grund, warum wir nicht einfach „das Repository mocken“ und das als ORM-Testing betrachten können. Hibernate arbeitet nicht im Modus „Ein-Schauspieler-Theater“: Es braucht eine Bühne, Kulissen und Zuschauer. In unserem Fall ist die Bühne die Datenbank.

Übrigens hilft hier eine Gewohnheit aus früheren Modulen sehr: Wenn Sie nicht clear() gemacht und dann die Daten erneut gelesen haben, prüfen Sie oft nicht die Datenbank, sondern den First-Level-Cache. Das heißt, der Test kann „grün“ sein, obwohl in der DB alles anders aussieht. Im Integrationstesting ist das ein klassischer Stolperstein: Der Test lügt nicht, aber er beantwortet nicht die richtige Frage.

5. Echte Datenbank in Integrationstests

Es gibt eine sehr tückische Kategorie von Problemen: „Auf meiner In-Memory-Datenbank sind die Tests grün, aber auf PostgreSQL – Feuer“. Und das liegt nicht daran, dass PostgreSQL böswillig ist (obwohl es manchmal so scheint). Die Datenbank ist einfach ein Teil der Spezifikation Ihrer Persistenzschicht. Sie hat ihre eigenen Einschränkungen, Transaktionssemantik, Sperren, Indizes, Sequenzen, Besonderheiten von Datentypen und Ausführungsplänen.

Wenn Sie ORM-Verhalten in einer zu „glatten“ Umgebung testen, können Sie versehentlich ein falsches Sicherheitsgefühl bekommen. Das betrifft insbesondere den Deep-Dive-Kurs, in dem wir ständig auf reale SQL-Konsequenzen stoßen. Schema-Einschränkungen sind nicht „irgendwo in der Produktion fügt der DBA sie hinzu“, sie sind Teil des Vertrags. Genauso ist Locking nicht „naja, theoretisch könnte es sein“, sondern ein reproduzierbares Szenario.

Um nicht in Konfigurationen abzudriften, zeige ich nur die Idee: Die Eindeutigkeit von sku ist keine Java-Regel, sondern eine DB-Regel. Sie kann nicht ehrlich überprüft werden, ohne die Datenbank und flush einzubeziehen.

Zwei Produkte mit demselben sku überleben setSku() problemlos. Das Problem zeigt sich erst, wenn das SQL tatsächlich zur Datenbank gelangt – bei flush oder commit. Daher ist hier der Gedanke wichtig: Viele Regeln leben unterhalb des Java-Codes, und ein Unit-Test ohne DB sieht sie einfach nicht.

Wenn Sie eine kurze technische Formel möchten: Der Unit-Test sagt „mein Code ist logisch korrekt“, der Integrations-ORM-Test sagt „mein Code funktioniert physisch in dieser Datenbank mit diesen Regeln“.

6. Ein Szenario – ein Signal

Wenn Leute zum ersten Mal Integrationstests schreiben, gibt es die Versuchung, einen großen „Test für alles“ zu machen: die Bestellung speichern, sie laden, die Produkte überprüfen, und gleichzeitig sicherstellen, dass es kein N+1 gibt, und dann soll noch irgendwo in der Mitte ein flush passieren. Ein solcher Test wird heldenhaft aussehen … genau bis zum ersten Fehlschlag. Denn dann sitzt man da und rätselt: „Was ist eigentlich kaputtgegangen?“

Ein Integrationstest für ORM-Verhalten ist kein Roman „Krieg und Frieden“, sondern ein kurzer juristischer Vertrag über eine einzige konkrete Garantie. Ein guter Test beantwortet eine Frage und prüft ein einziges beobachtbares Signal. Wenn Sie die Semantik von getReferenceById() fixieren – prüfen Sie genau das. Wenn Sie die Tatsache fixieren, dass das Lesen einer „Bestellkarte“ nicht zu N+1 führt, dann sollte der Test das Query-Profil betreffen, nicht alles auf einmal.

Sehr hilfreich ist ein Stil, den man „Given–When–Then, aber ohne Fanatismus“ nennen könnte: Zuerst minimale Daten erstellen/vorbereiten, dann eine Aktion ausführen, dann sowohl das funktionale Ergebnis als auch das Persistenzsignal prüfen. Bei ORM-Tests sieht dieses „Signal“ oft aus wie „Kontext geleert – neu gelesen – geprüft“, oder „Ausnahme erwartet“, oder „ich schaue auf die Anzahl der SQL-Abfragen“. Wichtig ist, dass dies keine Verzierung ist, sondern ein Teil des Vertrags.

Und noch eine praktische Sache, die Tests wartbar macht: Der Testname sollte wie ein Bug-Titel im Tracker sein. Nicht testSave(), sondern etwas im Stil von getReferenceById_resolvesSkuInsideJpaContext. Das klingt lang, aber nach drei Monaten öffnen Sie den Test und verstehen sofort, was er schützt. ORM-Regressionen kehren gerne leise zurück, daher sollte der Test mit seinem Namen schreien: „Ich stehe hier, nicht umsonst“.

7. Typische Fehler beim ORM-Testen

Zu Beginn des Weges werden Integrationstests oft entweder zu oberflächlich oder zu monströs. Beide Varianten bringen nicht den Effekt, für den wir sie überhaupt schreiben: Reproduzierbarkeit von ORM-Problemen und Schutz vor Regressionen. Unten ist eine Reihe von Fehlern, die am häufigsten vorkommen (und ja, fast alle sind wir schon mal reingetappt, nur geben es nicht alle zu).

Fehler Nr. 1: Getter/Setter testen und es „Hibernate-Test“ nennen.
Ein solcher Test ist ungefähr so nützlich wie zu prüfen, ob eine Tasse einen Henkel hat. Ja, der Henkel ist da. Aber der Tee wird dadurch nicht heiß. ORM-Bugs leben nicht in der Feldzuweisung, sondern darin, wie diese Felder gemappt werden, wann und warum sie zu SQL werden, was beim Flush passiert und was im Persistenzkontext zurückbleibt.

Fehler Nr. 2: Repositories mocken und glauben, dass die „Integration geprüft“ ist.
Mocks sind gut, wenn Sie die Verzweigungslogik und die Interaktion zwischen Komponenten prüfen. Aber Hibernate zeigt sich nicht in Mocks, weil es dort weder Proxys noch Sessions noch SQL noch Transaktionen noch das Verhalten einer bestimmten Datenbank gibt. Das ist, als würde man Schwimmen testen, während man auf einer Yogamatte steht (die Matte ist toll, aber wo ist das Wasser?).

Fehler Nr. 3: Das Ergebnis über dasselbe managed-Objekt prüfen und clear()/erneutes Lesen vergessen.
Hibernate ist sehr fürsorglich: Es kann Ihnen Daten aus dem First-Level-Cache zeigen, und Sie denken „juhu, gespeichert“. Aber Sie haben nicht die Datenbank geprüft, sondern den Speicher. Sobald in der echten Anwendung eine andere Abfrage oder eine andere Transaktion auftaucht, stellt sich plötzlich heraus, dass in der DB ganz etwas anderes steht, als Sie im Test gesehen haben. Daher sollte ein Integrationstest fast immer in der Lage sein, auf die „Sicht der Datenbank“ umzuschalten, nicht auf die Sicht des Persistenzkontexts.

Fehler Nr. 4: In einem Test versuchen, Fetching, Flush und Locking gleichzeitig zu fangen.
Im Ergebnis wird der Test zu einem Krimi: „Wer hat die Performance ermordet?“. Und der Mörder kann jeder sein. Besser, man hat drei kleine Tests, von denen jeder ein Risiko fixiert, als einen riesigen, der schwer zu verstehen und noch schwerer zu reparieren ist.

Fehler Nr. 5: Testdaten sind nicht deterministisch und „leben ihr eigenes Leben“.
Wenn die Fixtur mit zufälligen Werten erstellt wird, wenn die Daten für das gesamte Modul gemeinsam sind, wenn die Ausführungsreihenfolge der Tests das Ergebnis beeinflusst – erhalten Sie ein flaky-Verhalten. Und ein flaky-ORM-Test ist die schlimmste Art von Test: Er schützt nicht, sondern nervt. Ein Test sollte langweilig-vorhersagbar sein wie eine Buchhaltungstabelle (ja, das klingt nicht romantisch, funktioniert aber).

1
Aufgabe
Hibernate deep-dive, Level 28, Lektion 0
Gesperrt
POJO-Test und JPA-Integrationstest für eine Domäne
POJO-Test und JPA-Integrationstest für eine Domäne
1
Aufgabe
Hibernate deep-dive, Level 28, Lektion 0
Gesperrt
Integrationstest auf SKU-Eindeutigkeit
Integrationstest auf SKU-Eindeutigkeit
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION