1. merge(), Bulk und Konkurrenz: Risikozonen
Wenn es in ORM drei Stellen gibt, an denen sich Entwickler am häufigsten verbrennen, dann sind es die, an denen die Realität von der Intuition abweicht. merge() sieht aus wie „Objekt speichern“, Bulk‑Operationen sehen aus wie „schnell viele Zeilen aktualisieren“, und Konkurrenz erscheint als „Exotik, die es nur bei großen Unternehmen gibt“. In der Realität treten alle drei Probleme bereits bei ganz normalen Backoffice‑Anwendungen mit relativ kleiner Last auf — nur sind sie ohne das richtige Mikroskop, also einen Integrationstest, schwer zu erkennen.
Es ist nützlich, eine sehr praktische Matrix im Kopf zu haben: welches Symptom, warum es auftritt und welches Signal der Test erfasst. In ORM‑Regressionstests überprüfen wir fast nie „abstrakte Qualität“. Wir fixieren ein konkretes Verhaltensversprechen: „merge() gibt eine verwaltete Kopie zurück“, „Bulk synchronisiert den Persistenzkontext nicht“, „Optimistic Locking erzeugt einen Konflikt, keinen stillen Datenverlust“.
| Szenario | Typisches Symptom in Produktion | „Warum so“ in Hibernate | Was wir im Regressionstest festhalten |
|---|---|---|---|
| merge() | unerwartete SELECTs, Objektduplikate, seltsames Graphverhalten | merge() kopiert den Zustand in eine andere verwaltete Instanz | assertNotSame, entityManager.contains, erneutes Lesen nach flush/clear |
| Bulk Update/Delete | „Produkte umbenannt, aber im selben Dienst sind noch die alten Namen“ | Bulk arbeitet am Persistenzkontext vorbei | vor clear() ist das Objekt „alt“, nach clear() erneutes Lesen „neu“ |
| Konkurrenz (Locking) | Lost Update, negative Bestände, Chaos in reservedQty | zwei Threads, zwei Transaktionen, Wettlauf der Aktualisierungen | deterministischer Konflikt/Timeout + Invariantenprüfung |
Diese Tabelle handelt nicht von der „Schönheit der Tests“. Es geht darum, dass Sie in einem Monat einen CI‑Fehler öffnen und sofort verstehen: „Aha, da ist das ORM‑Risiko zurück, das wir schon einmal erwischt haben.“
2. Regressionstest für merge(): detached und verwaltete Kopie
Wenn Leute zum ersten Mal das Wort merge() hören, übersetzen sie es mental als „na ja, so eine Art save, nur klüger“. Und dann macht Hibernate das, was es am besten kann: freundlich lächeln und leise die Erwartungen zerstören. Der eigentliche Vertrag von merge() ist, dass es nicht dasselbe Objekt wiederbelebt, sondern eine verwaltete Instanz erstellt/findet und den Zustand aus dem detached-Objekt hineinkopiert. Daher beginnt ein Test für merge() nicht mit merge(), sondern mit der Erstellung einer detached‑Entität.
Im Folgenden werden kurze Test‑Methoden‑Fragmente gezeigt: Die Fixture und das sich wiederholende Setup beschreibe ich nicht jedes Mal, sondern lasse nur den Teil, in dem die detached/managed Rollen und das Ergebnis nach flush/clear wichtig sind.
Detached‑Entity in @DataJpaTest
In @DataJpaTest wird jeder Test normalerweise in einer Transaktion ausgeführt. Das ist praktisch, aber es gibt einen Haken: Alles, was Sie über das Repository geladen haben, wird managed und bleibt es, bis Sie es abkoppeln. Wenn Sie also einen „ehrlichen detached‑Flow“ in einem Test wünschen, haben Sie zwei direkte Wege: entityManager.detach(entity) für ein einzelnes Objekt oder entityManager.clear() für den gesamten Persistenzkontext. Hier ist detach() in der Regel präziser — wir wollen nicht versehentlich alles abkoppeln, was wir bereits geladen haben.
Unten finden Sie das minimale Skelett des Tests. Beachten Sie: Wir versuchen nicht, merge „wie Magie“ zu testen, sondern überprüfen die Semantik der Rollen: source (detached) und result (managed).
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
@Test
void detach_makesEntityDetached(@Autowired EntityManager entityManager) {
// Angenommen, die Fixture hat bereits eine Bestellung mit id=1 erstellt
PurchaseOrder order = entityManager.find(PurchaseOrder.class, 1L);
// Explizit die Entität vom managed-Zustand in detached versetzen
entityManager.detach(order);
// contains(...) = true bedeutet "Entität wird vom Persistenzkontext verwaltet"
assertFalse(entityManager.contains(order)); // order ist bereits detached
}
Dieses Codefragment kann man als „Realitätscheck“ vor dem eigentlichen Test betrachten. Wenn contains() true zurückgibt, sind Sie noch in der managed‑Welt, und der weitere Test wird lügen.
Test: merge() — verwaltete Kopie
Jetzt erstellen wir einen vollständigen Regressionstest für PurchaseOrder. Wir verwenden die Domain‑Entität aus Commerce Persistence Lab, weil merge() auf Bestellungen ein klassischer Production‑Trigger ist (eine Bestellung wird oft über detached DTO‑Ströme bearbeitet).
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
@Test
void merge_returnsManagedCopy(@Autowired EntityManager entityManager) {
// Schritt 1: managed-Entität nehmen und in detached umwandeln
PurchaseOrder detached = entityManager.find(PurchaseOrder.class, 1L);
entityManager.detach(detached);
// Zustand des detached-Objekts ändern (außerhalb des Persistenzkontexts)
detached.setStatus(OrderStatus.CANCELLED);
// merge(...) gibt eine verwaltete Kopie zurück (nicht dasselbe Java-Objekt)
PurchaseOrder managed = entityManager.merge(detached);
// Schlüsselsemantik von merge(): es ist eine ANDERE Instanz
assertNotSame(detached, managed);
// Und genau diese wird jetzt vom EntityManager verwaltet
assertTrue(entityManager.contains(managed));
}
Beachten Sie die Art der Asserts. Wir prüfen nicht, „ob es in der Datenbank gespeichert wurde“ (das ist noch zu früh). Wir halten eine grundlegendere und wichtigere Sache fest: merge() hat eine andere Instanz zurückgegeben, und diese Instanz wird vom Persistenzkontext verwaltet. Diese Semantik wird am häufigsten vergessen — und dann schreibt man Code, der weiter mit dem detached‑Objekt arbeitet, in der Annahme, es sei „wiederbelebt“.
Test: detached nach merge() wird nicht verfolgt
Die nächste Falle ist sehr menschlich. Ein Entwickler macht managed = merge(detached), ändert dann aber weiterhin detached, weil „es ist doch dieselbe Bestellung…“. Hibernate ist natürlich nicht verpflichtet, dies zu verfolgen: Das detached‑Objekt ist außen vor. Ein guter Regressionstest sollte dies abfangen, sonst bekommen Sie nach ein paar Refactorings „Geister“‑Bugs, bei denen Änderungen mal gespeichert werden, mal nicht.
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
@Test
void changesInDetachedAfterMerge_areNotTracked(@Autowired EntityManager em) {
// Entität laden und sofort "abkoppeln", um eine detached-Instanz zu erhalten
PurchaseOrder detached = em.find(PurchaseOrder.class, 1L);
em.detach(detached);
// merge(...) gibt eine verwaltete Kopie zurück
em.merge(detached);
// Wichtig: jetzt ändern wir genau das detached-Objekt, also "NICHT das richtige Objekt"
detached.setStatus(OrderStatus.SHIPPED); // ändern NICHT das richtige Objekt
// flush() sendet SQL an die DB (wenn es Änderungen an managed-Objekten gibt)
em.flush();
// clear() leert den Persistenzkontext, damit das nachfolgende Lesen "wie von null" erfolgt
em.clear();
// Aus der DB erneut lesen und sicherstellen, dass die Änderung am detached-Objekt NICHT gespeichert wurde
assertNotEquals(OrderStatus.SHIPPED, em.find(PurchaseOrder.class, 1L).getStatus());
}
Dieser Test enthält bewusst flush() und clear(). Ohne clear() riskieren Sie, die managed‑Entität aus dem First‑Level‑Cache zu lesen und nicht zu verstehen, was wirklich in der Datenbank steht. Und uns ist wichtig, dass „nach merge detached nicht mehr wirkt“.
Rollen‑Diagramm in merge()
Manchmal ist es hilfreich, es nicht als Code, sondern als Bild zu sehen – das Gehirn hört auf, mit der Realität zu streiten.
flowchart LR
A["detached PurchaseOrder
(nicht im Persistenzkontext)"] -->|"merge()"| B["managed PurchaseOrder
(im Persistenzkontext)"]
B -->|dirty checking + flush| C[(PostgreSQL)]
A -. "Änderungen werden hier nicht verfolgt" .-x B
Wenn Sie sich nur eine Sache aus den merge()-Tests merken, dann soll es der Gedanke sein: Nach merge() muss mit dem Ergebnis gearbeitet werden.
3. Regressionstest für Bulk‑Update: Veralteter Persistenzkontext
Bulk‑Operationen sind wie ein Bagger in der Welt der Gartenwerkzeuge. Sie sind sehr effizient, aber wenn Sie versuchen, damit ein Veilchen umzutopfen, werden die Ergebnisse … denkwürdig sein. In Hibernate werden Bulk‑Update/Delete direkt in der DB ausgeführt und umgehen den Persistenzkontext, Snapshots und Dirty Checking. Das heißt, die Datenbank hat sich bereits geändert, aber Ihre verwalteten Objekte leben noch in der „alten Realität“. Und das Unangenehmste: Der Code kann dabei weiterlaufen, „als ob alles normal wäre“, bis er eine Geschäftsentscheidung auf Basis eines veralteten Werts trifft.
Repository für Bulk‑Operation
Angenommen, wir haben eine Bulk‑Operation „Alle Produkte in Archived umbenennen“ (künstliches Beispiel, zeigt aber gut die Mechanik des Stale State). Im Projekt lebt dies normalerweise neben dem Query‑Code des Katalogs, nicht im Haupt-ProductRepository.
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;
interface ProductBulkRepository extends Repository<Product, Long> {
// @Modifying teilt Spring Data mit, dass dies kein SELECT ist, sondern eine modifizierende Abfrage
@Modifying
// Wichtig: Dies ist ein Bulk-Update, es wird direkt in der DB ausgeführt
@Query("update Product p set p.name = :newName")
int bulkRenameAllTo(@Param("newName") String newName);
}
Hier ist nicht wichtig, dass die Abfrage schön ist. Wichtig ist, dass es sich um ein Bulk‑Update handelt, das heißt, es führt kein p.setName(...) auf managed‑Entitäten aus. Es ändert direkt Zeilen in der Tabelle.
Test: Bulk aktualisiert die DB, aber nicht das Objekt
Jetzt erstellen wir einen Test, der zuerst Product als managed lädt (es ist also im First‑Level‑Cache), dann ein Bulk‑Update ausführt und anschließend zwei Realitäten zeigt: „Im Speicher alt, in der Datenbank neu“. Das ist genau der stale Persistenzkontext, der später zu seltsamen Fehlern führt.
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
@Test
void bulkUpdate_leavesManagedEntityStale(@Autowired EntityManager em,
@Autowired ProductBulkRepository bulkRepository) {
// Managed-Entität laden: Product#1 lebt jetzt im Persistenzkontext
Product p = em.find(Product.class, 1L);
String oldName = p.getName();
// Bulk-Operation ändert Zeilen in der DB, aber aktualisiert NICHT bereits geladene managed-Objekte
bulkRepository.bulkRenameAllTo("Archived");
// p ist immer noch "alt", weil es dasselbe Java-Objekt aus dem First-Level-Cache ist
assertEquals(oldName, p.getName()); // managed-Objekt "weiß nichts davon"
}
An dieser Stelle sind viele zum ersten Mal aufrichtig überrascht: „Aber wir haben doch aktualisiert!“ Ja, aktualisiert. Aber nicht dieses Objekt. Hibernate ist kein Gedankenleser, und eine Bulk‑Operation ist nicht verpflichtet, den Persistenzkontext zu synchronisieren.
Test: clear() bringt uns zurück in die Realität
Jetzt fügen wir den finalen Teil hinzu — erneutes Lesen nach clear(). Das ist nicht mehr „Testmagie“, sondern das Protokoll der Arbeit nach Bulk: Wenn Sie nach Bulk im selben Persistenzkontext weiterarbeiten, müssen Sie entweder den Kontext leeren, die benötigten Entitäten erneut lesen oder den Bulk in eine separate Transaktion auslagern. Wie genau, das ist eine Entscheidung auf Service‑Ebene, aber der Test muss die Notwendigkeit festhalten.
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
@Test
void bulkUpdate_requiresClearBeforeReload(@Autowired EntityManager em,
@Autowired ProductBulkRepository bulkRepository) {
// Managed-Instanz aufwärmen: Product#1 ist jetzt definitiv im Persistenzkontext
em.find(Product.class, 1L);
// Direkt in der DB ändern
bulkRepository.bulkRenameAllTo("Archived");
// Persistenzkontext leeren, damit das nächste Lesen NICHT das alte managed-Objekt zurückgibt
em.clear();
// Nach clear() liest der EntityManager erneut aus der DB und sieht den aktuellen Wert
assertEquals("Archived", em.find(Product.class, 1L).getName());
}
Dieser Test fungiert gleichzeitig als Dokumentation: Wenn morgen jemand fragt „Warum ist da clear() nach der Bulk‑Operation?“, wird CI freundlich erklären, warum.
Ohne clear() gibt findById() stale zurück
Hier gibt es einen wichtigen Punkt, der leicht übersehen wird. Sie könnten denken: „Na gut, das Objekt p ist alt, aber ich mache ein neues findById() und bekomme das Aktuelle!“ Nicht unbedingt. Im Persistenzkontext arbeitet eine Identity Map: Für eine Zeile gibt es ein managed‑Objekt. Wenn Product#1 bereits geladen ist, kann findById(1) dasselbe Java‑Objekt zurückgeben, ohne in die DB zu gehen. Und das ist richtig: So funktioniert der First‑Level‑Cache. Nur haben Sie nach Bulk selbst eine Situation geschaffen, in der Cache und Datenbank auseinandergehen.
4. Deterministische Concurrency‑Tests ohne sleep()
Dies ist eine andere Klasse von Aufgaben. Für merge() und Bulk reichte ein einziger Persistenzkontext, aber ein Locking‑Konflikt tritt nur dann auf, wenn wirklich zwei unabhängige Transaktionen aufeinandertreffen. Daher taucht unten InventoryService und ein Helfer zur Koordination des parallelen Starts auf: Es ist wichtig, zwei konkurrierende Work Units zu reproduzieren, nicht nur einen JPA‑Slice.
Concurrency‑Tests erschrecken selbst erfahrene Entwickler, weil „Parallelität“ im Kopf wie „Chaos“ klingt. Und Chaos ist schlechtes Testmaterial: Flaky‑Tests will niemand. Die gute Nachricht ist, dass man einen Concurrency‑Test deterministisch machen kann, wenn man aufhört, auf zufälliges Timing zu hoffen, und beginnt, den Start der Operationen zu koordinieren. In unserem Projekt ist das ideale Ziel für einen solchen Test InventoryItem, weil Bestände und Reservierungen naturgemäß Konflikte auslösen.
Was genau wollen wir beim Optimistic Locking prüfen
Erinnern wir uns an die vergangenen Kurstage: Optimistic Locking über @Version soll vor Lost Update schützen. Auf Verhaltensebene bedeutet das, dass zwei parallele Transaktionen dieselbe Version einer Zeile lesen können, aber beim Versuch, sie mit beiden Transaktionen zu aktualisieren, muss eine verlieren und einen Versionskonflikt erhalten. Das ist ein normales Ergebnis, kein „Systemfehler“. Der Regressionstest muss sicherstellen, dass der Konflikt tatsächlich auftritt und sich nicht in eine stille Datenüberschreibung verwandelt.
Thread.sleep(...) als Quelle von Flaky‑Tests
Ein sehr beliebter „Plan“ sieht so aus: Zwei Threads starten, in einem ein sleep(200) machen, damit der zweite „genau rechtzeitig“ kommt. Das Problem ist, dass „genau“ von der Auslastung Ihres Laptops, der Datenbankgeschwindigkeit, den CI‑Einstellungen, dem Java‑Scheduler und der Mondphase abhängt. Solche Tests scheitern zuverlässig genau dann, wenn Ihnen Stabilität am wichtigsten ist (z.B. vor einem Release).
Der richtige Weg ist die Verwendung eines Synchronisationsprimitivs, das einen kontrollierten Punkt „gemeinsam starten“ bietet. Der einfachste Kandidat ist CountDownLatch.
Minivorlage: Gleichzeitiger Start über CountDownLatch
Unten finden Sie ein kompaktes Beispiel für eine „Startbarriere“. Es ist nicht direkt an Hibernate gebunden, sondern eine Disziplin des parallelen Starts. In einem echten Projekt würden Sie dies normalerweise in einen Helper verpacken (bei uns liegt er logisch in labsupport).
import java.util.concurrent.CountDownLatch;
CountDownLatch ready = new CountDownLatch(2); // wie viele Aufgaben "ich bin bereit" sagen müssen
CountDownLatch start = new CountDownLatch(1); // gemeinsame "Startpistole"
// in jeder Aufgabe:
ready.countDown(); // "ich bin bereit" (Signal an den Test, dass der Thread die Barriere erreicht hat)
start.await(); // "warte auf den gemeinsamen Start" (damit synchron gestartet wird)
Der Sinn ist einfach: Beide Aufgaben melden „bereit“, der Test wartet, bis beide bereit sind, dann gibt er einen gemeinsamen Start mit einem countDown().
Regressionstest: Eine Reservierung schlägt mit Optimistic Conflict fehl
Jetzt kommt, was einem echten Test ähnelt. Ich zeige ihn im Stil „es gibt ConcurrencySupport“, weil wir in Commerce Persistence Lab solche Hilfsmittel in labsupport halten, um den Multithreading‑Boilerplate nicht über jeden Test zu verteilen.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
@Test
void reserve_twice_simultaneously_producesOptimisticConflict(@Autowired ConcurrencySupport concurrencySupport,
@Autowired InventoryService inventoryService) {
// Zwei Operationen gleichzeitig starten (derselbe InventoryItem)
var result = concurrencySupport.runSimultaneously(
() -> inventoryService.reserve(1L, 3),
() -> inventoryService.reserve(1L, 4)
);
// Vertrag: Von zwei konkurrierenden Aktualisierungen muss eine aufgrund der Version verlieren
assertEquals(1, result.failures().size());
}
Dieser Test fixiert genau einen Vertrag: Bei gleichzeitiger Reservierung desselben InventoryItem muss eine Operation verlieren. Wichtig ist, dass wir nicht behaupten, welche genau verloren hat – das ist für uns unerheblich. Wesentlich ist, dass der Konflikt nicht in einen stillen Datenverlust umschlägt.
Test verstärken: Fehlertyp und Bestandsinvariante prüfen
Eine etwas reifere Variante – nicht nur „ein Fehler“, sondern „Fehler der erwarteten Klasse“ plus „nach dem Konflikt sind die Daten nicht absurd geworden“. In der Spring‑Welt tritt ein Optimistic Conflict oft als ObjectOptimisticLockingFailureException (Wrapper) auf, aber die genaue Klasse hängt von der Ebene ab, auf der Sie die Ausnahme abfangen. Wir prüfen es einfach: Es gibt mindestens eine Ausnahme, und sie betrifft Optimistic Locking.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import static org.junit.jupiter.api.Assertions.*;
@Test
void conflict_isOptimisticLocking(@Autowired ConcurrencySupport concurrencySupport,
@Autowired InventoryService inventoryService) {
// Wiederholen des konkurrierenden Szenarios: zwei Reservierungen zur gleichen Zeit
var result = concurrencySupport.runSimultaneously(
() -> inventoryService.reserve(1L, 3),
() -> inventoryService.reserve(1L, 4)
);
// Wichtig: Ausnahmen aus Threads müssen gesammelt und im Ergebnis verfügbar sein
Throwable failure = result.failures().getFirst();
// Minimale Prüfung für ein Studienprojekt: Der Fehler betrifft tatsächlich Optimistic Locking
assertTrue(failure.getClass().getSimpleName().contains("Optimistic"));
}
Ja, die Prüfung „nach Namen“ sieht etwas studentisch aus. Und das ist für ein Studienprojekt in den ersten Schritten in Ordnung. In Ihrem Projekt werden Sie natürlich den konkreten Ausnahmetyp festlegen, den Sie an der Dienstgrenze erwarten. Aber selbst ein solcher Test ist schon besser als „fällt einfach manchmal aus“.
Wenn man eine Invariantenprüfung hinzufügen möchte, sieht das normalerweise so aus: Nach Ausführung des konkurrierenden Szenarios das InventoryItem erneut lesen und sicherstellen, dass availableQty >= 0 und reservedQty >= 0 ist, und dass die Summe nicht unmöglich geworden ist. Das macht den Test etwas länger, aber viel nützlicher: Sie prüfen nicht nur „es gab einen Konflikt“, sondern auch „die Daten sind gesund geblieben“.
Asserts: Semantik, „DB‑Realität“ und Ergebnis
In diesen drei Szenarien ist es leicht, einen Test zu schreiben, der klug aussieht, aber in Wirklichkeit nichts garantiert. Damit ein Test wirklich ein Regressionstest ist, muss er das prüfen, was bei einem Refactoring kaputtgehen kann, und dies mit einem beobachtbaren Signal prüfen. Für merge() ist das Signal oft der Identity/Managed‑Status (über assertNotSame und contains) plus erneutes Lesen nach flush/clear. Für Bulk ist das Signal „vor clear() stale, nach clear() aktuell“. Für Konkurrenz ist das Signal ein deterministisches Ergebnis: Konflikt, Timeout oder Invariantenerhaltung.
Eine sehr nützliche Gewohnheit: Wenn Sie im Test „etwas in der DB“ prüfen, fragen Sie sich immer: „Lese ich wirklich aus der DB oder aus dem Persistenzkontext?“ Normalerweise erreicht man die Antwort durch drei klassische Schritte: flush() (um SQL zu senden), clear() (um verwaltete Objekte abzukoppeln) und dann erst erneutes Lesen über das Repository. Ja, das sieht nach „überflüssigen Bewegungen“ aus. Aber genau das ist Ihr Testmikroskop: Sie schalten die Illusionen des First‑Level‑Cache aus und sehen die echte PostgreSQL.
Und noch ein Punkt, der Nerven spart: Versuchen Sie in Concurrency‑Tests nicht, assertThrows(...) innerhalb von Threads zu verwenden und zu glauben, der Test „sieht alles“. Eine in einem anderen Thread geworfene Ausnahme fällt nicht automatisch in den Hauptthread. Sie müssen entweder Fehler in einer Ergebnisstruktur sammeln (wie ConcurrencySupport es tut) oder Future.get() verwenden und ExecutionException abfangen. Wenn Sie diese Tatsache akzeptieren, hören Multithreading‑Tests auf, wie Mystik zu wirken, und werden zu einer normalen Ingenieuraufgabe.
5. Typische Fehler
Fehler Nr. 1: Der Test für merge() wurde ohne tatsächlichen detached‑Zustand geschrieben.
Die häufigste Falle ist, entityManager.merge(order) aufzurufen, wobei order gerade im selben Test geladen und detach()/clear() vergessen wurde. Infolgedessen bleibt order managed, merge() tut fast nichts, der Test besteht und erzeugt ein falsches Sicherheitsgefühl. Der richtige Test macht zuerst detach() oder clear() und prüft erst dann den Vertrag von merge() auf dem detached‑Objekt.
Fehler Nr. 2: Nach merge() wird das detached‑Objekt weiter geändert und erwartet, dass Hibernate dies speichert.
Manchmal geschieht dies nicht absichtlich: Sie haben PurchaseOrder managed = merge(detached) gemacht und dann im Code (oder im Test) versehentlich die Variable detached weiter verwendet, weil „sie war doch die erste“. Hibernate ist nicht verpflichtet, die detached‑Instanz zu überwachen, und genau das fixieren wir mit dem Test: Änderungen am detached nach merge() dürfen nicht in die Datenbank „durchsickern“. Wenn dieser Test fehlt, tritt der Fehler spät und sehr unangenehm auf.
Fehler Nr. 3: Ein Bulk‑Test prüft das Ergebnis über eine bereits geladene Entity und hält dies für eine „DB‑Prüfung“.
Eine Bulk‑Operation ändert Zeilen, aber nicht managed‑Objekte. Wenn Sie nach Bulk assertEquals("Archived", product.getName()) machen, fällt der Test erwartungsgemäß aus — und das ist sogar gut: Er zeigt die Mechanik des Stale State. Die schlechte Variante ist, wenn Sie überhaupt kein clear() und erneutes Lesen machen, sondern etwas Indirektes prüfen, weshalb der Test „zufällig grün“ ist und nichts fixiert. In der Bulk‑Regression ist fast immer ein Schritt mit clear() und erneutem Lesen erforderlich.
Fehler Nr. 4: Nach Bulk wird findById() ausgeführt, und man ist überrascht, dass alte Daten kommen.
Das liegt nicht daran, dass „Hibernate nicht in die DB gegangen ist“, sondern der First‑Level‑Cache funktioniert als Identity Map. Wenn die Entität bereits im Persistenzkontext ist, gibt Hibernate dasselbe Objekt zurück und hält dies für richtig. Daher sollte die Regression zwei Phasen zeigen: „Vor clear() alles stale“, „Nach clear() sieht erneutes Lesen die Realität“. Und auf der Grundlage dieses Tests fixieren Sie die Serviceregel: Eine Bulk‑Operation muss von einer Kontextbereinigung oder einer separaten Transaktion begleitet werden.
Fehler Nr. 5: Ein Concurrency‑Test basiert auf sleep() und wird flaky.
Wenn der Test mal bestanden wird, mal nicht, mal „den Konflikt nicht erfasst“, haben Sie keinen Regressionstest geschrieben — Sie haben eine Lotterie geschrieben. Ein normaler Concurrency‑Test muss einen koordinierten Start (Latch/Barrier) haben und idealerweise einen koordinierten Punkt „beide haben gelesen, bevor jemand committet hat“. Ja, das erfordert einen Helper wie ConcurrencySupport, aber genau das ist der Fall, in dem ein Utility keine Abstraktion um der Abstraktion willen ist, sondern eine Möglichkeit, Tests reproduzierbar zu machen.
GO TO FULL VERSION