1. Test mapowania: zachowanie, nie adnotacje
Jeśli dopiero zaczynasz, jest niebezpieczna iluzja: „postawiłem @ManyToOne, znaczy związek jest". To mniej więcej jak naklejenie na laptopa naklejki "Gaming PC" i oczekiwanie +30 FPS w życiu. ORM działa nie po naklejkach, ale przez to jak naprawdę zorganizowane są tabele, klucze obce, kolumny nullable i kto „jest właścicielem" związku. Dlatego test mapowania — to nie o „czy piękny kod się zgadza", ale o „czy model przeżywa realny cykl zapisu/odczytu".
Gdy mówimy „przetestować mapowanie", zazwyczaj chcemy udowodnić jedną z bardzo konkretnych rzeczy. Na przykład: „jeśli zapisałem Product z kategorią, to po ponownym odczycie z bazy produkt rzeczywiście ma kategorię". Lub: „jeśli zapisałem CustomerOrder z jedną pozycją, to ta pozycja się nie zgubiła, a FK wskazuje na zamówienie". Lub: „jeśli usunąłem pozycję z zamówienia, to naprawdę znikła z tabeli order_item".
Przydatne jest mieć w głowie prostą „formułę uczciwego testu mapowania":
flowchart TD
A[Arrange: tworzymy obiekty encji] --> B[Act: zapisujemy przez repozytorium]
B --> C[flush: zmuszamy SQL do pójścia do bazy]
C --> D[clear: czyścimy persistence context]
D --> E[Assert: czytamy na nowo i sprawdzamy fakt w bazie]
Zwróć uwagę, na końcu sprawdzamy nie „pole w obiekcie", ale konsekwencję w bazie, która przejawia się przez ponowny odczyt.
2. Narzędzia testu mapowania: findById(...), flush(), clear()
Mechanikę flush() i clear() już omówiliśmy na JPA-slice. Tu ważny jest tylko stosowany sens: test mapowania musi przejść cykl save → flush → clear → ponowny odczyt, inaczej zbyt łatwo sprawdzić piękny graf obiektów w pamięci zamiast faktu w tabelach.
Dla ponownego odczytu zazwyczaj wystarczy repository.findById(...). To jest w porządku, bo w tym wykładzie nie testujemy zapytań. Chcemy uczciwie sprawdzić, że FK, owning side i kaskady przeżyły zapis i potem się przywróciły z bazy.
Przykład szablonu klasy testowej w stylu naszego projektu shop-data-jpa:
package com.example.shopdatajpa.ordering.repository;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest
class CustomerOrderMappingTest {
@Autowired
EntityManager entityManager; // Dostęp do flush/clear i niskopoziomowych sprawdzeń bazy
@Autowired
CustomerOrderRepository orderRepository; // Repozytorium — punkt wejścia do zapisu/odczytu aggregate root
@Test
void smoke() {
// ciało testów będzie dalej
}
}
I ten właśnie „kontrolny gest", który czyni test uczciwszym:
entityManager.flush(); // Ważne: zmuszamy Hibernate do naprawdę wysłania SQL do bazy
entityManager.clear(); // Ważne: czyścimy persistence context, żeby przy odczycie nie zwrócić obiektu „z pamięci"
flush() i clear() nie są potrzebne w każdym teście jak rytuał. Ale tam, gdzie chcesz udowodnić zachowanie bazy, a nie bieżący stan obiektów managed, bez nich łatwo oszukać samego siebie.
3. ManyToOne: Product → Category i sprawdzenie FK
Zacznijmy od najbardziej zrozumiałego rodzaju związku, bo doskonale pokazuje sens „mapowania jako kontraktu". W domenie mini-shop kategoria — to słownik, produkt na nią wskazuje. W tabelach wygląda to jak klucz obcy product.category_id → category.id. W modelu obiektowym — Product.getCategory(). W teście chcemy udowodnić, że po zapisaniu produktu związek naprawdę jest zapisywany i przeżywa ponowny odczyt.
Zakładamy, że mamy encje Category i Product, a także repozytoria CategoryRepository i ProductRepository. Test będzie wyglądał mniej więcej tak: najpierw zapisujemy kategorię (jako niezależną encję), potem tworzymy produkt i wskazujemy kategorię, potem zapisujemy produkt, robimy flush + clear, i czytamy produkt na nowo.
package com.example.shopdatajpa.catalog.repository;
import jakarta.persistence.EntityManager;
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.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class ProductCategoryMappingTest {
@Autowired EntityManager entityManager; // flush/clear: robimy sprawdzenie „naprawdę przez bazę"
@Autowired CategoryRepository categoryRepository; // Słownik kategorii (zazwyczaj żyje osobno od produktów)
@Autowired ProductRepository productRepository; // Produkty wskazują na kategorię przez FK
@Test
void productKeepsCategoryAfterReload() {
// Arrange: najpierw tworzymy i zapisujemy kategorię jako niezależną encję
Category tea = categoryRepository.save(new Category("tea", "Tea"));
// Arrange: tworzymy produkt i przywiązujemy do już zapisanej kategorii
Product product = new Product("SKU-1", "Green Tea");
product.setCategory(tea);
// Act: zapisujemy produkt (powinien zapisać category_id)
productRepository.save(product);
// Assert (przez bazę): wyrzucamy SQL i „zapominamy" obiekty z persistence context
entityManager.flush();
entityManager.clear();
// Assert: czytamy na nowo i sprawdzamy, że związek naprawdę przywrócił się z bazy
Product reloaded = productRepository.findById(product.getId()).orElseThrow();
assertThat(reloaded.getCategory().getCode()).isEqualTo("tea");
}
}
Co dokładnie udowodniliśmy tym testem? Udowodniliśmy, że związek nie jest „narysowany w pamięci", ale naprawdę wyrażony w bazie. Jeśli gdzieś jest pomylony @JoinColumn, jeśli kolumna nie ta, jeśli FK nie jest tworzony lub nullable nie odpowiada oczekiwaniom, dowiesz się o tym tu, a nie za tydzień w najbardziej nieodpowiednim miejscu.
Typowa szkolna (w sensie „bardzo powszechna") pomyłka — próba w takim teście zapisania produktu z nową kategorią bez zapisywania kategorii, licząc na kaskadę. W większości modeli ManyToOne do słownika nie powinien być kaskadowany, i test właśnie pomaga to poczuć rękami.
4. Dwustronny związek: CustomerOrder i OrderItem
Dwustronne związki — główny „generator zaskoczenia" u początkujących. Przyczyna prosta: masz jakby dwie prawdy. Z jednej strony, zamówienie ma kolekcję items. Z drugiej strony, pozycja ma pole order. I jeśli zaktualizowałeś tylko jedną stronę, druga sama „magicznie" się nie podciągnie — niestety, to nie React, i state management tu nie jest wbudowany.
Dlatego pierwsza warstwa testowania takich związków — nawet nie baza, ale metody pomocnicze, które trzymają obie strony zsynchronizowane. W projekcie prawie na pewno robiliśmy coś takiego:
public void addItem(OrderItem item) {
items.add(item); // Ważne: aktualizujemy kolekcję po stronie zamówienia
item.setOrder(this); // Ważne: aktualizujemy odwrotną referencję po stronie pozycji
}
To można testować nawet bez repozytorium — po prostu jako zwykłą metodę, bo zapobiega dziesiątkom „dziwnych" błędów później.
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerOrderHelpersTest {
@Test
void addItemKeepsBothSidesInSync() {
// Arrange: tworzymy obiekty domenowe, bez bazy i bez ORM
CustomerOrder order = new CustomerOrder("ORD-1", "a@b.com");
OrderItem item = new OrderItem();
// Act: używamy metody pomocniczej, która musi trzymać graf obiektów spójny
order.addItem(item);
// Assert: sprawdzamy obie strony związku w pamięci
assertThat(order.getItems()).contains(item);
assertThat(item.getOrder()).isSameAs(order);
}
}
Ale dziś jesteśmy w kontekście @DataJpaTest, więc obowiązkowo dodajemy drugą warstwę: sprawdzamy, że związek przeżywa flush + clear.
Żeby test był realistyczny, potrzebujemy przynajmniej jeden Product, bo OrderItem wskazuje na Product. Tu ważne jest nierozpuchnięcie fixture: tworzymy minimalnie potrzebne encje, które dają nam „rozróżnialny" wynik.
package com.example.shopdatajpa.ordering.repository;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class CustomerOrderItemsMappingTest {
@Autowired EntityManager entityManager; // flush/clear: zmuszamy test do sprawdzania właśnie bazy
@Autowired CustomerOrderRepository orderRepository; // Zapisujemy/czytamy zamówienie jako korzeń agregatu
@Autowired ProductRepository productRepository; // Potrzebny, żeby stworzyć Product dla OrderItem
@Test
void orderKeepsItemsAfterReload() {
// Arrange: tworzymy minimalny produkt, żeby OrderItem mógł na niego wskazywać
Product product = productRepository.save(new Product("SKU-1", "Green Tea"));
// Arrange: tworzymy zamówienie i dodajemy pozycję przez metodę pomocniczą (obie strony powinny się synchronizować)
CustomerOrder order = new CustomerOrder("ORD-1", "a@b.com");
order.addItem(new OrderItem(product, 2, new BigDecimal("10.00")));
// Act: zapisujemy zamówienie (oczekujemy, że mapowanie i kaskady są poprawnie skonfigurowane)
orderRepository.save(order);
// Assert (przez bazę): utrwalamy w bazie i potem czytamy „od nowa"
entityManager.flush();
entityManager.clear();
// Assert: po ponownym odczycie związki powinny się przywrócić z tabel
CustomerOrder reloaded = orderRepository.findById(order.getId()).orElseThrow();
assertThat(reloaded.getItems()).hasSize(1);
assertThat(reloaded.getItems().get(0).getProduct().getSku()).isEqualTo("SKU-1");
}
}
Zwróć uwagę na ukrytą ważność tego testu: nie po prostu sprawdziliśmy hasSize(1). Sprawdziliśmy, że łańcuch Order → items → product naprawdę istnieje po ponownym odczycie. To właśnie ten przypadek, gdy „sprawdzić tylko rozmiar" jest zbyt słabe: rozmiar może być prawidłowy nawet przy częściowo zepsutym związku, a konkretne pole produktu daje nam pewność, że wszystko powiązało się naprawdę.
5. cascade: korzeń zapisuje rekordy-dzieci
cascade — to mechanizm, przez który nowicjusze albo cieszą się („ura, samo się zapisało!"), albo przerażają się („oj, samo się usunęło!"). W normalnym projekcie kaskadowanie powinno odpowiadać cyklowi życia. U zamówienia i pozycji cykl życia jest wspólny: pozycja prawie nigdy nie żyje sama bez zamówienia, znaczy cascade tu jest logiczne.
Test na cascade powinien odpowiadać na bardzo konkretne pytanie: czy wystarczy zapisać korzeń (CustomerOrder), żeby potomne OrderItem naprawdę pojawiły się w bazie? I znowu ważne jest nieputanie „w pamięci ich jest" z „w tabeli ich jest".
Jeden z dobrych sposobów sprawdzenia kaskady — wziąć ID encji-dziecka po flush, wyczyścić kontekst, i spróbować ją znaleźć bezpośrednio przez EntityManager.find(...). Wygląda to trochę „niskopoziomowo", ale jest bardzo uczciwe i nie ciągnie do testu zbędnych repozytoriów.
package com.example.shopdatajpa.ordering.repository;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class CascadePersistTest {
@Autowired EntityManager entityManager; // Potrzebny, żeby bezpośrednio sprawdzić zapis w tabeli przez find(...)
@Autowired CustomerOrderRepository orderRepository; // Zapisujemy „korzeń"
@Autowired ProductRepository productRepository; // Tworzymy produkt dla pozycji
@Test
void savingOrderAlsoPersistsItems() {
// Arrange: produkt musi istnieć, żeby FK z OrderItem był poprawny
Product product = productRepository.save(new Product("SKU-1", "Green Tea"));
// Arrange: tworzymy zamówienie i pozycję, pozycję dodajemy przez helper
CustomerOrder order = new CustomerOrder("ORD-1", "a@b.com");
order.addItem(new OrderItem(product, 1, new BigDecimal("10.00")));
// Act: zapisujemy tylko zamówienie (jeśli cascade jest skonfigurowany, pozycje też powinny się zapisać)
orderRepository.save(order);
// Flush jest potrzebny, żeby pozycje dostały ID i naprawdę pojawiły się w bazie
entityManager.flush();
// Zapamiętujemy ID encji-dziecka, żeby sprawdzić jej istnienie osobno od grafu obiektów
Long itemId = order.getItems().get(0).getId();
// Czyścimy kontekst, żeby następny find nie zwrócił „tego samego obiektu z pamięci"
entityManager.clear();
// Assert: pozycja musi być znajdywalna bezpośrednio po ID
OrderItem found = entityManager.find(OrderItem.class, itemId);
assertThat(found).isNotNull();
}
}
Jeśli kaskady nie ma, to często wystąpi jedno z dwóch: albo przy flush() Hibernate powie coś w stylu "transient instance must be saved before current operation" (bo przyczepiłeś niezapisaną pozycję do zamówienia), albo pozycja nie dostanie ID i nie okaże się w bazie. I w obu przypadkach test jest dobry: łapie problem na poziomie data-model, a nie na poziomie „dlaczego w serwisie nie działa placeOrder".
6. orphanRemoval: usuwanie sierot
orphanRemoval — to następny poziom po kaskadzie. Kaskada pomaga zapisywać/usuwać „po łańcuchu" przy operacjach na rodzicu. A orphanRemoval oznacza: jeśli element zostaje usunięty z kolekcji rodzica, uważany jest za „sierotę" i powinien być usunięty z bazy. To ważna semantyka biznesowa dla zamówienia: jeśli usunąłeś pozycję z zamówienia, nie powinna ona „wisieć" w tabeli order_item.
Główna metodyczna pułapka: sprawdzenie orphanRemoval „w pamięci" jest bardzo łatwe, ale niczego nie dowodzi. Usunąłeś element z listy — lista jest mniejsza. Gratulacje, udowodniłeś działanie ArrayList, a nie działanie ORM. Dlatego dla orphanRemoval obowiązkowo potrzebny jest flush i najlepiej clear, a sprawdzenie powinno iść przez fakt w bazie.
Potrzebujemy sekwencji kroków: zapisz zamówienie z jedną pozycją, zapamiętaj ID pozycji, usuń pozycję z zamówienia, zrób flush, wyczyść kontekst i sprawdź, że entityManager.find(OrderItem.class, itemId) zwraca null.
package com.example.shopdatajpa.ordering.repository;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class OrphanRemovalTest {
@Autowired EntityManager entityManager; // Sprawdzamy fakt w bazie przez find(...)
@Autowired CustomerOrderRepository orderRepository; // Zapisujemy/czytamy zamówienie
@Autowired ProductRepository productRepository; // Tworzymy produkt dla pozycji
@Test
void removingItemFromOrderDeletesItFromDb() {
// Arrange: tworzymy produkt
Product product = productRepository.save(new Product("SKU-1", "Green Tea"));
// Arrange: tworzymy zamówienie i pozycję
CustomerOrder order = new CustomerOrder("ORD-1", "a@b.com");
OrderItem item = new OrderItem(product, 1, new BigDecimal("10.00"));
order.addItem(item);
// Act: zapisujemy zamówienie i pobieramy ID pozycji
orderRepository.save(order);
entityManager.flush(); // Ważne: ID u item pojawia się po flush
Long itemId = item.getId();
// Act: usuwamy pozycję z zamówienia przez helper (powinien zerwać związek po obu stronach)
order.removeItem(item); // ważne: metoda pomocnicza synchronizuje obie strony
// Assert (przez bazę): utrwalamy zmiany i czytamy „poza kontekstem"
entityManager.flush();
entityManager.clear();
// Assert: „sierota" musi zniknąć z tabeli
assertThat(entityManager.find(OrderItem.class, itemId)).isNull();
}
}
Dlaczego ważna jest tu metoda pomocnicza removeItem()? Bo w dwustronnym związku musisz synchronizować i kolekcję, i odwrotną referencję. Prawidłowe removeItem zazwyczaj robi dwie rzeczy: usuwa z order.getItems() i stawia item.setOrder(null). Jeśli zrobisz tylko jedno, zachowanie może być „dziwne": w pamięci myślisz, że usunąłeś, a Hibernate przy flush zobaczy inny stan grafu.
I jeszcze jeden niuans, który przydatne jest zrozumieć: orphanRemoval — to nie „uprzejma prośba". To bezpośredni kontrakt: zerwałeś związek — rekord musi zniknąć. Jeśli nie odpowiada to twojej dziedzinie przedmiotowej, orphanRemoval lepiej nie włączać. Ale jeśli odpowiada — test na orphanRemoval zamienia się w twój „pas bezpieczeństwa".
Mini-przypadek OneToOne: Product → StockItem
Związek OneToOne wygląda ładnie na diagramie, ale w bazie trzyma się na bardzo przyziemnych rzeczach: unikalności klucza obcego i dyscyplinie „kto jest właścicielem związku". W naszym projekcie StockItem — to ewidencja stanów, i na jeden Product powinien być dokładnie jeden StockItem. W teście chcemy udowodnić, że StockItem jest dostępny przez produkt po ponownym odczycie.
Przykład w uproszczonej formie (nie testujemy logiki biznesowej stanów, tylko mapowanie):
package com.example.shopdatajpa.inventory.repository;
import jakarta.persistence.EntityManager;
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.assertj.core.api.Assertions.assertThat;
@DataJpaTest
class ProductStockItemMappingTest {
@Autowired EntityManager entityManager; // flush/clear: czytamy „jak z prawdziwej bazy"
@Autowired ProductRepository productRepository; // Tworzymy produkt
@Autowired StockItemRepository stockItemRepository; // Tworzymy rekord stanów powiązany z produktem
@Test
void stockItemIsReachableFromProductAfterReload() {
// Arrange: tworzymy produkt
Product product = productRepository.save(new Product("SKU-1", "Green Tea"));
// Arrange: tworzymy stany dla produktu (posiadanie związku zależy od twojego modelu)
stockItemRepository.save(new StockItem(product, 10, 0));
// Assert (przez bazę): utrwalamy i czytamy na nowo
entityManager.flush();
entityManager.clear();
// Assert: po ponownym odczycie StockItem powinien być dostępny z Product
Product reloaded = productRepository.findById(product.getId()).orElseThrow();
assertThat(reloaded.getStockItem().getAvailableQuantity()).isEqualTo(10);
}
}
Tak, tutaj używamy osobnego repozytorium StockItemRepository. Jest to normalne w testach mapowania, bo potrzebujemy stworzyć poprawny stan bazy. Jeśli w twoim modelu jest skonfigurowana kaskada od Product do StockItem, test można uprościć i zapisywać tylko produkt; ale częściej w prawdziwym życiu kaskada dla OneToOne wymaga włączenia bardzo świadomie, więc oba warianty są dopuszczalne — ważne, żeby test udowadniał rzeczywiste zachowanie po flush + clear.
7. Typowe błędy przy testach mapowania
Błąd nr 1: test bez ponownego odczytu.
Taki test zazwyczaj udowadnia tylko stan obiektów Java, które właśnie stworzyłeś. ORM przy tym mógł w ogóle nic nie zapisać do bazy (lub zapisać nie tak), a ty tego nie zauważyłeś, bo persistence context „trzyma" obiekt i zwraca go z powrotem. Jeśli chcesz sprawdzić mapowanie, trzeba zmusić SQL do wyjścia (flush) i potem czytać na nowo po wyczyszczeniu kontekstu (clear).
Błąd nr 2: zmiana tylko inverse side w dwustronnym związku.
Nowicjusze często dodają item do order.getItems() i myślą, że item.setOrder(order) „jakoś zrobi Hibernate". Hibernate, oczywiście, zrobi. Ale nie to, czego chciałeś. W rezultacie FK może nie zostać ustawiony lub zostanie ustawiony później i nieoczywistnie. Dlatego metody pomocnicze addItem()/removeItem() w encjach domenowych — nie zachcianka nauczyciela, ale sposób na stabilne zachowanie.
Błąd nr 3: mieszanie w jednym teście cascade, orphanRemoval i innych asercji.
Duży „uniwersalny" test wydaje się wygodny do pierwszego psucia się. Potem patrzysz na niego i nie rozumiesz: zepsułeś się kaskada, usuwanie, związek, czy w ogóle produkt się nie zapisał? Testy mapowania powinny być małe: jeden test — jeden sens. Przyspiesza to debugowanie i czyni bazę testów zrozumiałą nawet za miesiąc.
Błąd nr 4: orphanRemoval sprawdza się tylko po rozmiarze kolekcji.
Usunąć element z kolekcji — to działanie w pamięci. orphanRemoval — to działanie w bazie. Między nimi stoi flush. Jeśli w teście nie ma flush, nie sprawdziłeś orphanRemoval. Jeśli w teście nie ma sprawdzenia faktu w bazie (na przykład entityManager.find(...) == null), nie sprawdziłeś orphanRemoval. Sprawdziłeś List.remove().
Błąd nr 5: „włączymy kaskadę wszędzie, żeby mniej myśleć".
To bardzo ludzkie pragnienie: mniej pisać kodu, mniej repozytoriów, więcej magii. Ale kaskada — to o cyklu życia. Kaskadowanie ManyToOne do słownika (Product → Category) zazwyczaj jest złe, bo kategorie żyją własnym życiem. Kaskadowanie OneToMany od zamówienia do pozycji zazwyczaj jest dobre, bo pozycje — to część zamówienia. Testy właśnie pomagają zobaczyć tę granicę i utrwalić ją jako kontrakt.
GO TO FULL VERSION