1. Testy serwisowe: sprawdzamy obietnicę
Gdy ludzie po raz pierwszy zaczynają pisać testy na negatywne scenariusze, ręka sięga po najprostszą konstrukcję: assertThrows(...). I to jest normalne: mózg lubi proste zwycięstwa. Ale dla serwisów, które trzymają transakcje, samego faktu wyjątku prawie nigdy nie wystarczy — inaczej testujemy „serwis potrafi się złościć", a nie „serwis zachowuje się prawidłowo".
W teście serwisowym interesują nas obietnice use case, a nie emocje metody. Na przykład placeOrder() obiecuje: albo zamówienie tworzy się w całości i stany zmniejszają się spójnie, albo nie tworzy się w ogóle, i stany się nie zmieniają. To znaczy scenariusz negatywny — to nie tylko „dostać wyjątek", ale i „nie zostawić częściowo zapisanych śmieci". Serwis tu przypomina kasjera, który musi albo wybić paragon w całości, albo anulować operację tak, żeby w kasie nie zostało „minus 100 bitcoinów i połowa towaru na taśmie".
Przydatny nawyk: sformułować dla każdego testu jedną główną obietnicę. Jeśli test jednocześnie próbuje dowodzić rollbacku, prawidłowości sumy zamówienia, obsługi duplicate SKU i optimistic locking — staje się duży, kruchy i słabo wyjaśnia, co konkretnie się psuje, gdy pada. Lepiej trzymać testy krótkie, jak dobre komentarze w kodzie: jeden test — jedna myśl.
Żeby nie być gołosłownymi, dalej będziemy pisać testy wokół domeny naszego shop-data-jpa: katalog (CatalogService), stany (InventoryService) i zamówienia (OrderService). Sygnatury metod mogą się u ciebie trochę różnić — to normalne. Ważne jest nie kopiowanie „litera w literę", ale rozumienie formy: przed → próba → po.
2. Rollback: brak „śladów" w bazie
Rollback często jest postrzegany jako przycisk "Ctrl+Z dla bazy danych". W całości — tak, tylko z jednym nieprzyjemnym zastrzeżeniem: "Ctrl+Z" działa dla danych w bazie, ale nie musi cofać już załadowanych obiektów w pamięci. Dlatego test rollbacku — to zawsze historia o dwóch źródłach prawdy: baza i pamięć, a interesuje nas w teście właśnie baza.
Główna idea sprawdzenia rollbacku brzmi nudno, ale ratuje projekty: jeśli operacja biznesowa padła, to w bazie nie powinno pojawiać się częściowo zapisane stato. Dla placeOrder() oznacza to, że nie pojawi się zamówienie bez pozycji, nie zmniejszą się stany „trochę", nie zostanie zapisany zmieniony status zamówienia i nie pojawi się „na wpół udana" kombinacja, którą potem będą naprawiać ręcznie na produkcji (zazwyczaj w piątek wieczorem).
Rollback w kategoriach testu
Mówiąc prosto, @Transactional na serwisie działa tak: przed wykonaniem publicznej metody Spring otwiera transakcję, a po wykonaniu — próbuje ją zatwierdzić. Jeśli wewnątrz wystąpił błąd, transakcja jest oznaczana jako rollback-only i na końcu jest wycofywana. Test przy tym musi obserwować efekt z zewnątrz: albo dane zapisały się w całości, albo nie zapisało się nic.
Tu jest ważny niuans, który bezpośrednio wpływa na testy: błąd może wylatywać nie ściśle tam, gdzie zrobiłeś save(...). Ograniczenia bazy i optimistic locking często przejawiają się przy flush() lub przy commit. Dlatego testowi ważne jest sprawdzenie nie „ta linijka rzuciła wyjątek", ale „operacja jako całość zakończyła się błędem i nie zmieniła bazy".
Żeby w teście było to przejrzyste, integracyjne testy serwisowe najczęściej pisze się bez @Transactional na metodzie testowej. Wtedy serwis sam tworzy transakcję, a ty łapiesz wyjątek tam, gdzie się spodziewasz — przy wywołaniu serwisu, a nie na końcu testu przy „cichym commit".
Szkielet testu rollbacku: przed → próba → po
Dobry test rollbacku prawie zawsze wygląda jak mała księgowość. Ustalamy stan przed (na przykład ile zamówień w bazie i jaki stan), potem uruchamiamy operację, spodziewając się wyjątku, a potem ponownie czytamy stan po, porównując go z wyjściowym. To nie paranoja — to właśnie to, co odróżnia „mamy nadzieję, że rollback jest" od „udowodniliśmy, że rollback działa".
Poniżej — krótki przykład w stylu JUnit 6. Zwróć uwagę: nie porównujemy obiektów z pamięci, czytamy z repozytoriów na nowo.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@Test
void placeOrderRollsBackWhenStockIsNotEnough() {
// Arrange: ustalamy stan PRZED operacją
long ordersBefore = orderRepository.count();
int stockBefore = stockItemRepository.findById(1L).orElseThrow()
.getAvailableQuantity();
// Act + Assert: operacja musi paść, bo towaru nie wystarczy
assertThrows(IllegalStateException.class, () -> orderService.placeOrder(1L, 999));
// Assert: stan bazy PO błędzie nie powinien się zmienić (czytamy na nowo z repozytoriów)
assertEquals(ordersBefore, orderRepository.count());
assertEquals(stockBefore, stockItemRepository.findById(1L).orElseThrow()
.getAvailableQuantity());
}
Tu ważne jest to, że sprawdzamy dwa niezależne ślady: liczbę zamówień i ilość towaru w magazynie. W prawdziwym projekcie możesz dodać jeszcze jeden „sygnałowy" fakt sprawdzający, na przykład brak nowych OrderItem, ale zazwyczaj dwóch lub trzech punktów wystarczy. Test powinien być zwarty, inaczej zamienia się w powieść, w której już nie pamiętasz, kto jest głównym bohaterem.
Żeby lepiej trzymać w głowie formę takiego testu, przydatne jest przedstawianie go jako prostego przepływu:
flowchart TD %% Szablon testu rollbacku: przed → próba → po A["Odczytać stan PRZED (count, stock)"] --> B["Wywołać serwis i oczekiwać wyjątku"] B --> C["Odczytać stan PO (znowu z repozytorium)"] C --> D["Porównać: PRZED == PO"]
Jeśli na końcu nie odczytałeś danych na nowo — zrobiłeś połowę testu. Ładną, ale połowę.
Rollback i obiekty w pamięci
Najbardziej podstępny błąd nowicjusza w teście rollbacku wygląda tak: załadowałeś encję przed wywołaniem, wywołałeś serwis (dostałeś wyjątek), a potem porównałeś pola w tej samej Javowej encji. Wydaje się logiczne: „przecież wycofałem transakcję, znaczy obiekt też wrócił". Ale JPA i rzeczywistość tak nie muszą.
Obiekt w pamięci mógł zostać zmieniony w ramach transakcji (dirty checking), a potem transakcja się wycofała. Baza uczciwie się wycofała, a obiekt w pamięci pozostał zmieniony — bo to po prostu obiekt w heap, nie wie, że nastąpił rollback. W efekcie patrzysz na stockItem.getAvailableQuantity() i widzisz „9", choć w bazie znowu „10". I test albo fałszywie pada, albo (gorzej) w ogóle nie zauważasz problemu, jeśli porównanie jest zbudowane niefortunnie.
Dlatego w testach serwisowych jest prawie żelazna reguła: do sprawdzenia „po" robisz ponowny odczyt przez repozytorium. Jeśli z jakichś powodów test i tak znalazł się wewnątrz transakcji i persistence context może buforować encje, dodatkowo czyścisz kontekst. W praktyce często wystarczy po prostu nie robić testu @Transactional, ale przy chęci można jawnie „wyzerować pamięć":
import jakarta.persistence.EntityManager;
import org.springframework.beans.factory.annotation.Autowired;
@Autowired
EntityManager entityManager;
void clearPersistenceContext() {
// Resetujemy cache pierwszego poziomu, żeby następny odczyt na pewno poszedł do bazy, a nie z kontekstu
entityManager.clear();
}
Ten przycisk możesz naciskać w teście przed ponownym odczytem, jeśli chcesz mieć pewność, że czytasz z bazy, a nie z cache kontekstu. Ale najczęściej dla nowicjuszy prościej jest trzymać się prostego: test nie jest transakcyjny, serwis jest transakcyjny, odczyt „po" — na nowo.
3. Niezmienniki biznesowe: reguły udanego scenariusza
Jeśli rollback — to o „nie zostawiać śmieci po upadku", to niezmienniki biznesowe — to o „w udanym scenariuszu system żyje wg reguł". I jest to szczególnie ważne na poziomie serwisowym, bo repozytoria nie wiedzą, co to jest „prawidłowe zamówienie". Repozytoria potrafią zapisywać i czytać. A obietnice znaczeniowe — to odpowiedzialność serwisów.
W naszym mini-shop niezmienniki są całkiem ziemskie. Zamówienie nie powinno pojawiać się bez pozycji. Suma zamówienia powinna odpowiadać sumie pozycji. Stany nie powinny stawać się ujemne. Status zamówienia powinien być zgodny z tym, co nastąpiło. To nie filozofia — to minimalna higiena, która odróżnia „dane zapisały się" od „dane mają sens".
Niezmienniyk: zamówienie w całości albo wcale
Na pierwszy rzut oka wydaje się, że „albo w całości, albo wcale" — to właśnie rollback. Ale różnica jest subtelna: rollback — reakcja na błąd, a niezmienniyk — reguła w udanym scenariuszu. Niezmienniyk odpowiada na pytanie: jeśli placeOrder() zakończył się pomyślnie, co obowiązkowo musi być prawdą o systemie?
Na przykład, jeśli placeOrder() zwrócił „sukces", to zamówienie na pewno istnieje w bazie, ma przynajmniej jedną pozycję, suma jest nieujemna, a stany się zmniejszyły. Można to sprawdzić jako test integracyjny, który wywołuje serwis i potem robi kilka punktowych odczytów. Taki test zazwyczaj nie jest potrzebny dla każdej drobiazgowości, ale jeden lub dwa „kotwiczne" testy na kluczowy use case (u nas to placeOrder()) prawie zawsze się zwracają.
Sprawdzenie wygodnie jest budować „sygnałowo": nie trzeba porównywać wszystkich pól zamówienia, włącznie z createdAt i updatedAt. Trzeba sprawdzić to, co dowodzi biznesowej prawidłowości. Na przykład: zamówienie pojawiło się, pozycji tyle-a-tyle, suma się zgadza, stan zmniejszył się o potrzebną ilość.
Suma zamówienia i BigDecimal
Pieniądze w Javie zazwyczaj żyją w BigDecimal, a znaczy w testach nie można polegać na equals() jako na „uniwersalnej prawdzie". W BigDecimal jest niuans: 10.0 i 10.00 — to różne wartości dla equals(), ale identyczne co do sensu sumy. Dlatego w testach na sumy prawie zawsze używa się compareTo().
Poniżej przykład sprawdzenia, że zapisana cena stała się oczekiwaną. Jest mały, ale ilustruje właściwy styl porównania.
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.*;
BigDecimal storedPrice = productRepository.findById(productId).orElseThrow()
.getPrice();
// Dla pieniędzy porównujemy przez compareTo: 10.0 i 10.00 muszą być uważane za równe co do sensu
assertEquals(0, storedPrice.compareTo(new BigDecimal("19.99")));
Tak samo możesz porównywać totalAmount zamówienia z sumą lineTotal pozycji. Można to zrobić w dwóch krokach: odczytać zamówienie, odczytać pozycje, obliczyć sumę i porównać. Tak, to kilka linii. Ale taki test naprawdę dowodzi, że serwis trzyma matematykę.
Stany: zmniejsza się i nie schodzi poniżej zera
Stany — bardzo wdzięczny obszar dla testów, bo tu łatwo opisać obietnicę. W udanym zamówieniu stan musi zmniejszyć się dokładnie o ilość. W nieudanym — nie zmienić się w ogóle. I jeszcze ważne: stan nie może stać się ujemny, nawet jeśli gdzieś w kodzie ktoś „bardzo prosił, bo deadline".
Dlatego udany test na placeOrder() zazwyczaj zawiera sprawdzenie zmiany stanu, a negatywny — sprawdzenie braku zmiany. Przy czym musi to być sprawdzane po ponownym odczycie, inaczej znowu ryzykujesz patrzenie na pamięć, a nie na bazę.
Mini-szkielet pozytywnego sprawdzenia może wyglądać tak:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@Test
void placeOrderDecreasesStock() {
// Arrange: czytamy stan PRZED (właśnie z repozytorium)
int before = stockItemRepository.findById(1L).orElseThrow()
.getAvailableQuantity();
// Act: udane zamówienie musi zmniejszyć stan
orderService.placeOrder(1L, 2);
// Assert: czytamy stan PO na nowo i sprawdzamy oczekiwaną zmianę
int after = stockItemRepository.findById(1L).orElseThrow()
.getAvailableQuantity();
assertEquals(before - 2, after);
}
Tak, to prosta arytmetyka. Ale właśnie takie proste sprawdzenia łapią najdroższe błędy: gdy gdzieś „minus" zamienił się w „plus", lub stany zmieniły się nie u tego towaru, lub zmiana nastąpiła dwa razy. A te wszystkie błędy bardzo lubią chować się za „no kod przecież wygląda normalnie".
4. Naruszenia integralności: ograniczenia bazy w testach serwisowych
Ograniczenia w bazie — to ostatnia linia obrony danych. Serwis może walidować parametry wejściowe, sprawdzać reguły biznesowe i być bardzo uważny, ale w wieloużytkownikowym świecie wyścigi są nadal możliwe. Dlatego unique i foreign key ograniczenia — to nie „kaprys DBA", ale architektoniczne wsparcie. I czasem test serwisowy musi sprawdzić: przy naruszeniu ograniczenia use case poprawnie pada i nie zostawia śmieci.
Tu łatwo pójść w skrajność i zacząć testować „jak baza narzeka po rosyjsku". Tak robić nie trzeba. Interesuje nas nie tekst komunikatu, ale fakt naruszenia integralności i reakcja serwisu. I jeszcze interesuje nas to, że po błędzie baza nie stała się „na wpół zepsuta".
Błąd integralności pojawia się nie od razu
Częsty szok nowicjusza: wywołujesz repository.save(entity), oczekujesz wyjątku, a go nie ma… a potem pojawia się później, przy commit. To normalne. save() nie musi od razu wysyłać SQL do bazy. SQL może pójść przy flush(), a flush() często następuje na końcu transakcji.
Dlatego jeśli chcesz łapać błąd integralności w teście, masz dwie praktyczne drogi. Pierwsza — test nie jest transakcyjny, serwis jest transakcyjny: wtedy commit następuje wewnątrz wywołania serwisu, i wyjątek jest widoczny tam. Druga — jeśli z jakichś powodów pracujesz inaczej, będziesz musiał jawnie prowokować flush() (na przykład przez saveAndFlush() na poziomie repozytorium), ale to jest już bardziej delikatna gra.
W ramach naszego kursu prościej i prawidłowiej jest trzymać integracyjne testy serwisowe bez @Transactional, żeby wyjątek pojawiał się tam, gdzie go sprawdzasz.
Duplicate SKU: błąd i brak rekordów
Wyobraźmy sobie, że sku u Product jest unikalny. Serwis katalogu tworzy towar. Jeśli próbujemy stworzyć drugi towar z tym samym sku, musi być błąd. W prostym wariancie możesz oczekiwać DataIntegrityViolationException, lub twój serwis może opakować to w bardziej domenowy wyjątek. Dla testu ważniejsza jest forma: błąd nastąpił, i drugi towar się nie pojawił.
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: liczymy rekordy PRZED próbą wstawienia duplikatu
long before = productRepository.count();
// Act + Assert: przy naruszeniu ograniczenia unique oczekujemy wyjątku warstwy danych
assertThrows(DataIntegrityViolationException.class,
() -> catalogService.createProduct("SKU-001", "Duplicate", new BigDecimal("10.00")));
// Assert: po błędzie w bazie nie powinien pojawić się nowy rekord
assertEquals(before, productRepository.count());
}
Zwróć uwagę na „sygnałowość". Nie sprawdzamy całej listy produktów, nie porównujemy pól, nie szukamy po nazwie. Robimy proste sprawdzenie: liczba rekordów nie wzrosła. To szybkie, jasne i wystarczająco wiarygodne dla projektu edukacyjnego.
Naruszenie FK: nie zależymy od tekstu błędu
Błąd klucza obcego — to typowy przypadek, gdy serwis próbuje powiązać encję z nieistniejącym „rodzicem". Na przykład stworzyć Product z categoryId, którego nie ma. Idealnie serwis sam sprawdzi istnienie kategorii i rzuci zrozumiały domenowy błąd. Ale nawet jeśli nie sprawdził, baza musi chronić integralność.
Test jest budowany tak samo: ustalamy „przed", wywołujemy serwis, oczekujemy błędu na poziomie data access, i potem upewniamy się, że rekordy się nie pojawiły. Główne — nie próbować porównywać ex.getMessage(): różne wersje sterownika, dialektów i nawet ustawienie lokalizacji łatwo zepsują taki test.
Jeśli twój serwis rzuca domenowy wyjątek (na przykład CategoryNotFoundException) — świetnie, testuj jego. Jeśli zaś sprawdzasz właśnie FK na poziomie bazy — oczekuj DataIntegrityViolationException i trzymaj asercje minimalne i stabilne.
5. Optimistic locking: test konfliktu wersji
Optimistic locking — jedna z tych tematów, gdzie testy są szczególnie przydatne, bo „na oko" wszystko wygląda normalnie. Czytasz stan, zmieniasz liczbę, zapisujesz — wszystko działa. A potem w rzeczywistości dwóch użytkowników robi to jednocześnie, i jedna z aktualizacji po cichu nadpisuje drugą. @Version jest właśnie po to, żeby taka sytuacja przejawiała się jawnym błędem, a nie „no jakoś tak wyszło".
Test serwisowy na optimistic locking musi dowodzić jednej prostej myśli: jeśli dwa niezależne stany próbują aktualizować ten sam StockItem, to druga aktualizacja ze przestarzałej wersji musi zakończyć się konfliktem. To znaczy nasz system nie powinien być „uprzejmy i milczący", musi być „uprzejmy i uczciwy".
Co sprawdzamy przy optimistic locking
Nie musimy w tym teście mierzyć wydajności i nie musimy urządzać wyścigu z dziesięciu wątków, żeby „na pewno było jednocześnie". Dla projektu edukacyjnego wystarczy zamodelować kluczową sekwencję: stan A odczytał wersję 1, stan B też odczytał wersję 1, potem A zapisał zmiany i zrobił wersję 2, a B próbuje zapisać swoje zmiany z wersją 1 — i dostaje konflikt.
Można to zrobić deterministycznie i bez wątków: przez dwie lub trzy osobne transakcje wewnątrz jednego testu. Takie podejście jest łatwiejsze do wyjaśnienia, jest stabilniejsze i nie zamienia testu w loterię „to przechodzi, to nie przechodzi".
Trzy transakcje przez TransactionTemplate
Najbardziej zrozumiały „symulator niezależnych transakcji" w ramach testu Spring — TransactionTemplate. Pozwala wykonać blok kodu w osobnej transakcji i gwarantować, że po bloku transakcja naprawdę się zakończyła (commit lub rollback). A tego właśnie nam trzeba: dostać odłączony zdjęcie encji, potem zrobić realne aktualizację, a potem próbować zaktualizować przestarzały zdjęcie.
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);
// Transakcja nr 1: czytamy encję i dostajemy „przestarzały" zdjęcie (stale)
StockItem stale = tx.execute(status -> stockItemRepository.findById(1L).orElseThrow());
// Transakcja nr 2: aktualizujemy ten sam rekord, żeby zwiększyć wersję (@Version)
tx.execute(status -> {
StockItem fresh = stockItemRepository.findById(1L).orElseThrow();
fresh.setAvailableQuantity(10); // ważne naprawdę zmienić pole, żeby był UPDATE
return null;
});
// Zmieniamy przestarzały obiekt w pamięci (nadal ma stara wersję)
stale.setAvailableQuantity(9);
// Transakcja nr 3: próbujemy zapisać przestarzałą wersję — powinien być konflikt
assertThrows(ObjectOptimisticLockingFailureException.class,
() -> tx.execute(status -> stockItemRepository.save(stale)));
}
Tak, tu jest „magia" w postaci trzech transakcji. Ale to właśnie przydatna magia: tworzymy dwa niezależne zdjęcia rzeczywistości. Druga transakcja aktualizuje rekord, zwiększając wersję, a trzecia próbuje zapisać starą wersję — i musi przegrać.
Jeśli widzisz, że test nie pada, ale przechodzi, to prawie zawsze oznacza, że nie stworzyłeś prawdziwego konfliktu: albo wersja się nie zmienia (na przykład @Version nie jest skonfigurowany), albo robisz wszystko w jednej transakcji, albo nie prowokujesz UPDATE (nie zmieniasz pola, i Hibernate może nie wysłać aktualizacji).
Jakiego wyjątku oczekiwać
W czystym JPA możesz spotkać OptimisticLockException. W stosie Spring często przychodzi bardziej „springowy" wrapper ObjectOptimisticLockingFailureException. W projekcie edukacyjnym rozsądne jest oczekiwać właśnie wyjątku Spring, bo jest on stabilniejszy w ramach Spring Data JPA i lepiej odzwierciedla warstwę, na której testujesz serwis.
Tu ważne jest niezakopywanie się w „a dlaczego właśnie ta klasa". Testujemy zachowanie: konflikt musi być jawny. Dlatego sprawdzaj typ wyjątku na poziomie Spring (lub, jeśli twój kod specjalnie przechwytuje i zamienia konflikt na domenowy błąd — testuj domenowy błąd). Nie testuj tekstu komunikatu i nie testuj konkretnego SQL — dla testu serwisowego jest to zbyt kruche.
6. Typowe błędy przy rollback, niezmiennikach i optimistic locking
Błąd nr 1: test ogranicza się do assertThrows(...), a stan bazy po błędzie nie jest sprawdzany.
Jeśli test kończy się na „wyjątek był", nic nie dowodzi o rollback. Serwis mógł zdążyć zapisać połowę danych, a potem paść. Prawidłowy test zawsze ponownie czyta z repozytoriów przynajmniej 1–3 „sygnałowe" rzeczy i porównuje „przed" i „po".
Błąd nr 2: porównanie jest robione po obiekcie, który był załadowany przed rollbackiem, bez ponownego odczytu.
Po rollbacku obiekt w pamięci nie musi wracać do stanu wyjściowego. Prowadzi to do bardzo dziwnych sytuacji: baza się wycofała, a test patrzy na stary zmieniony obiekt i wyciąga błędny wniosek. Ratuje ponowny odczyt i (w razie potrzeby) entityManager.clear().
Błąd nr 3: test jest oznaczony @Transactional, i przez to łapiesz wyjątek „nie tam".
Jeśli test sam jest w transakcji, metoda serwisowa z REQUIRED podłącza się do niej, i commit nastąpi na końcu testu, a nie przy wywołaniu serwisu. Wtedy assertThrows(...) może nie zadziałać, a błąd wyleci „po teście", tworząc odczucie mistyki. Dla sprawdzania granic transakcji zazwyczaj prościej nie robić testu transakcyjnym.
Błąd nr 4: optimistic locking jest testowany bez niezależnych transakcji.
Jeśli dwa razy po kolei wywołujesz serwis, najczęściej oba razy czytasz już aktualne stan, i konfliktu nie będzie. Konflikt optymistyczny — to historia o przestarzałej wersji, znaczy musisz stworzyć dwa niezależne stany: przez TransactionTemplate, przez dwa EntityManager, lub przez inny uczciwy sposób.
Błąd nr 5: oczekiwanie błędnego typu wyjątku i próba „leczenia" testu sprawdzaniem tekstu komunikatu.
W stosie Spring optimistic locking często przejawia się jako ObjectOptimisticLockingFailureException, a błędy integralności — jako DataIntegrityViolationException. Teksty komunikatów i kody stanu zmieniają się zależnie od sterownika i ustawień bazy, więc sprawdzanie ex.getMessage().contains(...) czyni testy kruchymi i bezużytecznymi.
GO TO FULL VERSION