1. Servicetests: Wir prüfen das Versprechen
Wenn Menschen zum ersten Mal Tests für negative Szenarien schreiben, greift die Hand zur einfachsten Konstruktion: assertThrows(...). Und das ist normal: Das Gehirn liebt einfache Siege. Aber für Dienste, die Transaktionen halten, reicht die bloße Tatsache einer Ausnahme fast nie aus – sonst testen wir „der Dienst kann sich ärgern“, nicht „der Dienst verhält sich richtig“.
In einem Servicetest interessieren uns die Versprechen des Use Cases, nicht die Emotionen der Methode. Zum Beispiel verspricht placeOrder(): entweder wird die Bestellung vollständig erstellt und die Bestände werden konsistent reduziert, oder sie wird gar nicht erstellt und die Bestände ändern sich nicht. Das heißt, das negative Szenario ist nicht nur „eine Ausnahme erhalten“, sondern auch „keinen teilweise gespeicherten Müll hinterlassen“.
Der Dienst ist hier wie ein Kassierer, der entweder den gesamten Beleg erfassen oder die Transaktion so abbrechen muss, dass in der Kasse nicht „minus 100 Bitcoins und die Hälfte der Ware auf dem Band“ übrig bleibt.
Eine nützliche Gewohnheit: für jeden Test ein Hauptversprechen formulieren. Wenn ein Test gleichzeitig Rollback, Korrektheit der Bestellsumme, Verarbeitung von doppelten SKUs und optimistisches Sperren beweisen will, wird er groß, fragil und erklärt schlecht, was genau kaputt gegangen ist, wenn er fehlschlägt. Es ist besser, Tests kurz zu halten, wie gute Kommentare im Code: ein Test – ein Gedanke.
Um nicht unbegründet zu sein, werden wir im Folgenden Tests rund um unsere shop-data-jpa Domäne schreiben: Katalog (CatalogService), Bestände (InventoryService) und Bestellungen (OrderService). Die Methodensignaturen können bei Ihnen etwas abweichen – das ist normal. Wichtig ist, nicht „Buchstabe für Buchstabe“ zu kopieren, sondern die Form zu verstehen: vor → Versuch → nach.
2. Rollback: keine „Spuren“ in der Datenbank
Rollback wird oft als „Ctrl+Z für die Datenbank“ angesehen. Im Grunde ja, nur mit einer unangenehmen Einschränkung: „Ctrl+Z“ funktioniert für Daten in der DB, muss aber nicht bereits geladene Objekte im Speicher zurücksetzen. Daher ist ein Rollback-Test immer eine Geschichte über zwei Wahrheitsquellen: Datenbank und Speicher, und im Test interessiert uns genau die Datenbank.
Die Hauptidee der Rollback-Überprüfung klingt langweilig, rettet aber Projekte: Wenn eine Geschäftsoperation fehlschlägt, sollte in der DB kein teilweise gespeicherter Zustand auftauchen. Für placeOrder() bedeutet das, dass keine Bestellung ohne Positionen erscheint, Bestände nicht „ein bisschen“ reduziert werden, kein geänderter Bestellstatus gespeichert wird und keine „halberfolgreiche“ Kombination entsteht, die später manuell in der Produktion repariert werden muss (normalerweise am Freitagabend).
Rollback in Testbegriffen
Einfach ausgedrückt macht @Transactional auf dem Dienst Folgendes: Vor der Ausführung der öffentlichen Methode öffnet Spring eine Transaktion und nach der Ausführung versucht er sie zu committen. Wenn ein Fehler auftritt, wird die Transaktion als rollback-only markiert und am Ende zurückgesetzt. Der Test sollte dabei die Wirkung von außen beobachten: entweder wurden die Daten vollständig geschrieben oder es wurde nichts geschrieben.
Hier gibt es eine wichtige Nuance, die Tests direkt beeinflusst: Ein Fehler kann nicht genau dort auftreten, wo Sie save(...) aufgerufen haben. Datenbankbeschränkungen und optimistisches Sperren treten oft bei flush() oder beim Commit auf. Daher ist es für den Test wichtig, nicht „diese Zeile hat eine Ausnahme geworfen“ zu überprüfen, sondern „die Operation insgesamt ist fehlgeschlagen und hat die Datenbank nicht verändert“.
Um dies im Test transparent zu machen, werden Service-Integrationstests meistens ohne @Transactional auf der Testmethode geschrieben. Dann erstellt der Dienst selbst die Transaktion, und Sie fangen die Ausnahme dort, wo Sie sie erwarten – beim Aufruf des Dienstes, nicht am Ende des Tests bei einem „stillen Commit“.
Gerüst eines Rollback-Tests: vor → Versuch → nach
Ein guter Rollback-Test sieht fast immer wie eine kleine Buchhaltung aus. Wir erfassen den Zustand vor (z.B. wie viele Bestellungen in der DB und welcher Bestand), führen dann die Operation aus, erwarten eine Ausnahme, und lesen dann den Zustand nach erneut und vergleichen ihn mit dem ursprünglichen. Das ist keine Paranoia – es ist genau das, was „wir hoffen, dass es Rollback gibt“ von „wir haben bewiesen, dass Rollback funktioniert“ unterscheidet.
Unten ist ein kurzes Beispiel im Stil von JUnit 6. Beachten Sie: Wir vergleichen keine Objekte aus dem Speicher, wir lesen erneut aus den Repositories.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@Test
void placeOrderRollsBackWhenStockIsNotEnough() {
// Arrange: Zustand VOR der Operation erfassen
long ordersBefore = orderRepository.count();
int stockBefore = stockItemRepository.findById(1L).orElseThrow()
.getAvailableQuantity();
// Act + Assert: Operation muss fehlschlagen, da nicht genug Ware vorhanden ist
assertThrows(IllegalStateException.class, () -> orderService.placeOrder(1L, 999));
// Assert: DB-Zustand NACH dem Fehler sollte sich nicht geändert haben (erneut aus Repositories lesen)
assertEquals(ordersBefore, orderRepository.count());
assertEquals(stockBefore, stockItemRepository.findById(1L).orElseThrow()
.getAvailableQuantity());
}
Um die Form eines solchen Tests besser im Kopf zu behalten, ist es nützlich, ihn als einfachen Fluss darzustellen:
flowchart TD %% Vorlage Rollback-Test: vor -> Versuch -> nach A["Zustand VOR lesen (count, stock)"] --> B["Dienst aufrufen und Ausnahme erwarten"] B --> C["Zustand NACH lesen (erneut aus Repository)"] C --> D["Vergleichen: VOR == NACH"]
Wenn Sie am Ende die Daten nicht erneut gelesen haben, haben Sie einen halben Test gemacht. Einen schönen, aber nur einen halben.
Rollback und Objekte im Speicher
Der heimtückischste Fehler eines Anfängers in einem Rollback-Test sieht so aus: Sie haben die Entität vor dem Aufruf geladen, den Dienst aufgerufen (Ausnahme erhalten) und dann die Felder in derselben Java-Entität verglichen. Das erscheint logisch: „Ich habe die Transaktion zurückgesetzt, also muss das Objekt auch zurückgekehrt sein.“ Aber JPA und die Realität sind dazu nicht verpflichtet.
Das Objekt im Speicher könnte innerhalb der Transaktion geändert worden sein (Dirty Checking), und dann wurde die Transaktion zurückgesetzt. Die Datenbank wurde ordnungsgemäß zurückgesetzt, aber das Objekt im Speicher bleibt geändert – weil es nur ein Objekt im Heap ist und nicht weiß, dass ein Rollback stattgefunden hat. Am Ende sehen Sie stockItem.getAvailableQuantity() und sehen „9“, obwohl in der DB wieder „10“ steht. Und der Test fällt entweder falsch aus oder (schlimmer) Sie bemerken das Problem gar nicht, wenn der Vergleich schlecht aufgebaut ist.
Daher gibt es eine fast eiserne Regel in Servicetests: Für die Überprüfung „nach“ machen Sie ein erneutes Lesen über das Repository. Wenn der Test aus irgendeinem Grund doch innerhalb einer Transaktion liegt und der Persistenzkontext Entitäten cachen kann, bereinigen Sie den Kontext zusätzlich. In der Praxis reicht es oft, den Test einfach nicht @Transactional zu machen, aber bei Bedarf kann man explizit den „Speicher zurücksetzen“:
import jakarta.persistence.EntityManager;
import org.springframework.beans.factory.annotation.Autowired;
@Autowired
EntityManager entityManager;
void clearPersistenceContext() {
// First-Level-Cache zurücksetzen, damit das nächste Lesen sicher zur DB geht und nicht aus dem Kontext
entityManager.clear();
}
Diese Schaltfläche kann im Test vor dem erneuten Lesen gedrückt werden, wenn Sie sicher sein müssen, dass Sie aus der DB und nicht aus dem Kontextcache lesen. Aber meistens ist es für Anfänger besser, es einfacher zu halten: Test nicht transaktional, Dienst transaktional, Lesen „nach“ – erneut.
3. Geschäftsinvarianten: Regeln für erfolgreiche Szenarien
Wenn Rollback darum geht, „keinen Müll nach einem Fehlschlag zu hinterlassen“, dann geht es bei Geschäftsinvarianten darum, dass „das System in einem erfolgreichen Szenario nach den Regeln lebt“. Und das ist besonders auf Serviceebene wichtig, weil Repositories nicht wissen, was eine „korrekte Bestellung“ ist. Repositories können speichern und lesen. Aber die semantischen Versprechen sind die Verantwortung der Dienste.
In unserem Mini-Shop sind die Invarianten sehr bodenständig. Eine Bestellung sollte nicht ohne Positionen erscheinen. Die Bestellsumme sollte der Summe der Positionen entsprechen. Die Bestände sollten nicht negativ werden. Der Bestellstatus sollte mit dem Geschehenen übereinstimmen. Das ist keine Philosophie – es ist minimale Hygiene, die „Daten wurden gespeichert“ von „Daten haben einen Sinn“ unterscheidet.
Invariante: Bestellung ganz oder gar nicht
Auf den ersten Blick scheint „ganz oder gar nicht“ genau das Rollback zu sein. Aber der Unterschied ist fein: Rollback ist eine Reaktion auf einen Fehler, eine Invariante ist eine Regel in einem erfolgreichen Szenario. Eine Invariante beantwortet die Frage: Wenn placeOrder() erfolgreich abgeschlossen wurde, was muss dann zwangsläufig über das System wahr sein? Zum Beispiel: Wenn placeOrder() „Erfolg“ zurückgibt, existiert die Bestellung definitiv in der DB, sie hat mindestens eine Position, die Summe ist nicht negativ und die Bestände haben sich verringert.
Das kann man als Integrationstest überprüfen, der den Dienst aufruft und dann mehrere punktuelle Lesevorgänge durchführt. Ein solcher Test ist normalerweise nicht für jede Kleinigkeit nötig, aber ein oder zwei „Anker“-Tests für den Schlüssel-Use-Case (bei uns placeOrder()) zahlen sich fast immer aus.
Die Überprüfung lässt sich bequem „signalisierend“ aufbauen: Wir müssen nicht alle Felder der Bestellung vergleichen, einschließlich createdAt und updatedAt. Wir müssen das überprüfen, was die Geschäftskorrektheit beweist. Zum Beispiel: Bestellung ist vorhanden, so viele Positionen, Summe stimmt, Bestand um die richtige Menge reduziert.
Bestellsumme und BigDecimal
Geld lebt in Java normalerweise in BigDecimal, daher können Sie sich in Tests nicht auf equals() als „universelle Wahrheit“ verlassen. BigDecimal hat eine Nuance: 10.0 und 10.00 sind unterschiedliche Werte für equals(), aber bedeutungsgleiche Beträge. Daher verwendet man in Tests für Beträge fast immer compareTo().
Unten ein Beispiel, das überprüft, dass der gespeicherte Preis dem erwarteten entspricht. Es ist klein, aber es illustriert den korrekten Vergleichsstil.
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
BigDecimal storedPrice = productRepository.findById(productId).orElseThrow()
.getPrice();
// Für Geld über compareTo vergleichen: 10.0 und 10.00 sollten als bedeutungsgleich angesehen werden
assertEquals(0, storedPrice.compareTo(new BigDecimal("19.99")));
Genauso können Sie totalAmount der Bestellung mit der Summe der lineTotal der Positionen vergleichen. Das kann man in zwei Schritten machen: Bestellung lesen, Positionen lesen, Summe berechnen und vergleichen. Ja, das sind ein paar Zeilen. Aber so ein Test beweist tatsächlich, dass der Dienst die Mathematik einhält.
Wenn Sie sich bei dem Gedanken ertappen „die Summe wird doch im Code berechnet, wozu ein Test?“ – denken Sie daran, wie oft die Menschheit einfache Mathematik durch banale „falsche Operationsreihenfolge“ oder „versehentlich die Maßeinheit geändert“ kaputt gemacht hat. Ein Test ist Ihre kleine Steuer auf Seelenfrieden.
Bestände: verringern sich und werden nicht negativ
Bestände sind ein sehr dankbarer Bereich für Tests, weil man hier leicht das Versprechen beschreiben kann. Bei einer erfolgreichen Bestellung muss sich der Bestand genau um die Menge verringern. Bei einer erfolglosen – sich gar nicht ändern. Und wichtig: Der Bestand darf nicht negativ werden, selbst wenn irgendwo im Code jemand „sehr darum gebeten hat, weil Deadline“.
Daher enthält ein erfolgreicher Test auf placeOrder() normalerweise eine Überprüfung der Bestandsänderung, ein negativer Test eine Überprüfung der fehlenden Änderung. Und dies muss nach erneutem Lesen überprüft werden, sonst riskieren Sie erneut, auf den Speicher statt auf die DB zu schauen.
Ein Mini-Skelett einer positiven Überprüfung könnte so aussehen:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@Test
void placeOrderDecreasesStock() {
// Arrange: Bestand VOR lesen (genau aus dem Repository)
int before = stockItemRepository.findById(1L).orElseThrow()
.getAvailableQuantity();
// Act: erfolgreiche Bestellung sollte Bestand verringern
orderService.placeOrder(1L, 2);
// Assert: Bestand NACH erneut lesen und erwartete Änderung prüfen
int after = stockItemRepository.findById(1L).orElseThrow()
.getAvailableQuantity();
assertEquals(before - 2, after);
}
Ja, das ist einfache Arithmetik. Aber genau solche einfachen Prüfungen fangen die teuersten Fehler: wenn irgendwo ein „Minus“ zu einem „Plus“ wird, oder die Bestände bei der falschen Ware geändert wurden, oder die Änderung doppelt durchgeführt wurde. Und all diese Fehler verstecken sich gerne hinter „der Code sieht doch normal aus“.
4. Integritätsverletzungen: Datenbankbeschränkungen in Servicetests
Datenbankbeschränkungen sind die letzte Verteidigungslinie der Daten. Ein Dienst kann Eingabeparameter validieren, Geschäftsregeln prüfen und sehr aufmerksam sein, aber in einer Mehrbenutzerwelt sind dennoch Wettlaufsituationen möglich. Daher sind UNIQUE- und Fremdschlüssel-Beschränkungen keine „Laune des DBA“, sondern eine architektonische Stütze. Und manchmal muss ein Servicetest überprüfen: Bei Verletzung einer Beschränkung fällt der Use Case korrekt aus und hinterlässt keinen Müll.
Hier kann man leicht ins Extreme abrutschen und anfangen zu testen, „wie die Datenbank auf Russisch schimpft“. Das sollte man nicht tun. Uns interessiert nicht der Text der Meldung, sondern die Tatsache der Integritätsverletzung und die Reaktion des Dienstes. Und uns interessiert auch, dass die Datenbank nach dem Fehler nicht „halb kaputt“ ist.
Integritätsfehler tritt nicht sofort auf
Ein häufiger Schock für Anfänger: Sie rufen repository.save(entity) auf, erwarten eine Ausnahme, aber es gibt keine… und dann erscheint sie später beim Commit. Das ist normal. save() muss nicht sofort SQL an die DB senden. SQL kann erst bei flush() gesendet werden, und flush() erfolgt oft am Ende der Transaktion.
Wenn Sie also einen Integritätsfehler im Test abfangen möchten, haben Sie zwei praktische Wege. Erster: Test nicht transaktional, Dienst transaktional: dann erfolgt der Commit innerhalb des Dienstaufrufs und die Ausnahme ist dort sichtbar. Zweiter: Wenn Sie anders arbeiten, müssen Sie explizit ein flush() provozieren (z.B. über saveAndFlush() auf Repository-Ebene), aber das ist schon ein feineres Spiel.
Im Rahmen unseres Kurses ist es einfacher und richtiger, Service-Integrationstests ohne @Transactional zu halten, damit die Ausnahme dort auftritt, wo Sie sie überprüfen.
Doppelte SKU: Fehler und keine Einträge
Angenommen, die sku eines Product ist eindeutig. Der Katalogdienst erstellt ein Produkt. Wenn wir versuchen, ein zweites Produkt mit derselben sku zu erstellen, sollte ein Fehler auftreten. In einer einfachen Variante können Sie DataIntegrityViolationException erwarten, oder Ihr Dienst kann sie in eine domänenspezifischere Ausnahme verpacken. Für den Test ist die Form wichtiger: Der Fehler ist aufgetreten und das zweite Produkt ist nicht erschienen.
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
@Test
void createProductRejectsDuplicateSku() {
// Arrange: Einträge VOR dem Versuch, ein Duplikat einzufügen, zählen
long before = productRepository.count();
// Act + Assert: Bei Verletzung der UNIQUE-Beschränkung erwarten wir eine Datenausnahme
assertThrows(DataIntegrityViolationException.class,
() -> catalogService.createProduct("SKU-001", "Duplicate", new BigDecimal("10.00")));
// Assert: Nach dem Fehler sollte in der DB kein neuer Eintrag erscheinen
assertEquals(before, productRepository.count());
}
Beachten Sie die „Signalisierung“. Wir überprüfen nicht die gesamte Produktliste, vergleichen keine Felder, suchen nicht nach Namen. Wir machen eine einfache Prüfung: Die Anzahl der Einträge ist nicht gestiegen. Das ist schnell, klar und für ein Übungsprojekt zuverlässig genug.
FK-Verletzung: nicht vom Fehlertext abhängig
Ein Fremdschlüsselfehler ist ein typischer Fall, wenn ein Dienst versucht, eine Entität mit einem nicht existierenden „Elternteil“ zu verknüpfen. Zum Beispiel ein Product mit einer categoryId, die es nicht gibt. Idealerweise prüft der Dienst selbst die Existenz der Kategorie und wirft einen verständlichen Domänenfehler. Aber selbst wenn er nicht geprüft hat, sollte die Datenbank die Integrität schützen.
Der Test wird hier genauso aufgebaut: „vor“ erfassen, Dienst aufrufen, Fehler auf Datenzugriffsebene erwarten, und dann sicherstellen, dass keine Einträge erschienen sind. Wichtig ist, nicht zu versuchen, ex.getMessage() zu vergleichen: Verschiedene Treiberversionen, Dialekte und sogar Lokalisierungseinstellungen können einen solchen Test leicht kaputt machen.
Wenn Ihr Dienst eine Domänenausnahme wirft (z.B. CategoryNotFoundException) – großartig, testen Sie diese. Wenn Sie jedoch genau den FK auf DB-Ebene überprüfen, erwarten Sie DataIntegrityViolationException und halten Sie die Assertions minimal und stabil.
5. Optimistisches Sperren: Test des Versionskonflikts
Optimistisches Sperren ist eines der Themen, bei denen Tests besonders nützlich sind, weil „auf den ersten Blick“ alles normal aussieht. Sie lesen den Bestand, ändern die Zahl, speichern – alles funktioniert. Aber dann machen in der Realität zwei Benutzer das gleichzeitig, und eines der Updates überschreibt das andere stillschweigend. @Version ist genau dafür da, dass eine solche Situation als offensichtlicher Fehler sichtbar wird, und nicht als „naja, ist halt passiert“.
Ein Servicetest für optimistisches Sperren soll einen einfachen Gedanken beweisen: Wenn zwei unabhängige Zustände versuchen, dasselbe StockItem zu aktualisieren, dann sollte das zweite Update aus einer veralteten Version mit einem Konflikt enden. Das heißt, unser System sollte nicht „höflich und still“ sein, es sollte „höflich und ehrlich“ sein.
Was wir beim optimistischen Sperren überprüfen
Wir müssen in diesem Test weder die Leistung messen noch ein Rennen mit zehn Threads veranstalten, um „genau gleichzeitig“ zu sein. Für ein Übungsprojekt reicht es, die Schlüsselsequenz zu modellieren: Zustand A hat Version 1 gelesen, Zustand B hat auch Version 1 gelesen, dann hat A die Änderungen gespeichert und Version 2 erstellt, und B hat versucht, seine Änderungen mit Version 1 zu speichern – und einen Konflikt erhalten.
Das kann deterministisch und ohne Threads gemacht werden: mit zwei oder drei separaten Transaktionen innerhalb eines Tests. Dieser Ansatz ist einfacher zu erklären, stabiler und verwandelt den Test nicht in eine Lotterie „mal bestanden, mal nicht“.
Drei Transaktionen über TransactionTemplate
Der verständlichste „Simulator unabhängiger Transaktionen“ in einem Spring-Test ist TransactionTemplate. Es ermöglicht die Ausführung eines Codeblocks in einer separaten Transaktion und garantiert, dass die Transaktion nach dem Block tatsächlich abgeschlossen ist (Commit oder Rollback). Und genau das brauchen wir: einen detachierten Snapshot der Entität erhalten, dann ein echtes Update durchführen und dann versuchen, den veralteten Snapshot zu aktualisieren.
import org.junit.jupiter.api.Test;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.transaction.support.TransactionTemplate;
import static org.junit.jupiter.api.Assertions.*;
@Test
void staleStockUpdateFailsWithOptimisticLock() {
TransactionTemplate tx = new TransactionTemplate(txManager);
// Transaktion Nr. 1: Entität lesen und "veralteten" Snapshot (stale) erhalten
StockItem stale = tx.execute(status -> stockItemRepository.findById(1L).orElseThrow());
// Transaktion Nr. 2: Denselben Eintrag aktualisieren, um die Version zu erhöhen (@Version)
tx.execute(status -> {
StockItem fresh = stockItemRepository.findById(1L).orElseThrow();
fresh.setAvailableQuantity(10); // Wichtig: Feld tatsächlich ändern, damit ein UPDATE erfolgt
return null;
});
// Veraltetes Objekt im Speicher ändern (es hat noch die alte Version)
stale.setAvailableQuantity(9);
// Transaktion Nr. 3: Versuche, die veraltete Version zu speichern – sollte Konflikt geben
assertThrows(ObjectOptimisticLockingFailureException.class,
() -> tx.execute(status -> stockItemRepository.save(stale)));
}
Ja, hier gibt es „Magie“ in Form von drei Transaktionen. Aber das ist genau die nützliche Magie: Wir erschaffen zwei unabhängige Realitätsschnappschüsse. Die zweite Transaktion aktualisiert den Eintrag und erhöht die Version, die dritte versucht, die alte Version zu speichern – und sollte verlieren.
Wenn Sie sehen, dass der Test nicht fehlschlägt, sondern bestanden wird, bedeutet das fast immer, dass Sie keinen echten Konflikt erzeugt haben: entweder die Version ändert sich nicht (z.B. @Version nicht konfiguriert), oder Sie machen alles in einer Transaktion, oder Sie provozieren kein UPDATE (Sie ändern das Feld nicht, und Hibernate sendet möglicherweise kein Update).
Welche Ausnahme erwarten
In reinem JPA können Sie OptimisticLockException antreffen. Im Spring-Stack kommt oft eine „Spring-ähnlichere“ Wrapper-Klasse ObjectOptimisticLockingFailureException. In einem Übungsprojekt ist es sinnvoll, genau die Spring-Ausnahme zu erwarten, weil sie innerhalb von Spring Data JPA stabiler ist und die Schicht, auf der Sie den Dienst testen, besser widerspiegelt.
Hier ist es wichtig, sich nicht in „warum genau diese Klasse“ zu verlieren. Wir testen das Verhalten: Der Konflikt muss offensichtlich sein. Überprüfen Sie daher den Ausnahmetyp auf Spring-Ebene (oder, wenn Ihr Code den Konflikt speziell abfängt und in eine Domänenausnahme umwandelt, testen Sie die Domänenausnahme). Testen Sie nicht den Nachrichtentext und nicht das konkrete SQL – das ist für einen Servicetest zu fragil.
6. Typische Fehler bei Rollback, Invarianten und optimistischem Sperren
Fehler Nr. 1: Der Test beschränkt sich auf assertThrows(...) und der Datenbankzustand nach dem Fehler wird nicht überprüft.
Wenn der Test mit „Ausnahme war da“ endet, beweist er nichts über Rollback. Der Dienst könnte die Hälfte der Daten gespeichert haben und dann abstürzen. Ein korrekter Test liest immer mindestens 1–3 „Signal“-Dinge aus den Repositories erneut und vergleicht „vor“ und „nach“.
Fehler Nr. 2: Der Vergleich erfolgt mit einem Objekt, das vor dem Rollback geladen wurde, ohne erneutes Lesen.
Nach einem Rollback muss das Objekt im Speicher nicht in den ursprünglichen Zustand zurückkehren. Das führt zu sehr seltsamen Situationen: Die Datenbank wurde zurückgesetzt, aber der Test sieht das alte geänderte Objekt und zieht falsche Schlüsse. Es hilft, erneut zu lesen und (wenn nötig) entityManager.clear().
Fehler Nr. 3: Der Test ist mit @Transactional markiert, und deshalb fangen Sie die Ausnahme „an der falschen Stelle“.
Wenn der Test selbst in einer Transaktion läuft, wird sich die Dienstmethode mit REQUIRED dieser anschließen, und der Commit erfolgt am Ende des Tests, nicht beim Aufruf des Dienstes. Dann kann assertThrows(...) möglicherweise nicht greifen, und der Fehler erscheint „nach dem Test“, was ein mystisches Gefühl erzeugt. Für die Überprüfung der Transaktionsgrenze ist es normalerweise einfacher, den Test nicht transaktional zu machen.
Fehler Nr. 4: Optimistisches Sperren wird ohne unabhängige Transaktionen getestet.
Wenn Sie den Dienst zweimal hintereinander aufrufen, lesen Sie meistens beide Male den bereits aktuellen Zustand, und es gibt keinen Konflikt. Ein optimistischer Konflikt ist eine Geschichte über eine veraltete Version, also müssen Sie zwei unabhängige Zustände erstellen: über TransactionTemplate, über zwei EntityManager oder über eine andere ehrliche Methode.
Fehler Nr. 5: Erwartung des falschen Ausnahmetyps und Versuch, den Test durch Überprüfung des Nachrichtentexts zu „heilen“.
Im Spring-Stack tritt optimistisches Sperren oft als ObjectOptimisticLockingFailureException auf, und Integritätsfehler als DataIntegrityViolationException. Der Nachrichtentext ändert sich mit der Treiberversion und den Datenbankeinstellungen, daher macht die Überprüfung von ex.getMessage().contains(...) Tests fragil und sinnlos.
GO TO FULL VERSION