CodeGym /Kursy /Spring Data JPA /Testujemy mapowanie i kaskady

Testujemy mapowanie i kaskady

Spring Data JPA
Poziom 27, Lekcja 2
Dostępny

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.

1
Zadanie
Spring Data JPA, poziom 27, lekcja 2
Niedostępne
Kaskadowe zapisywanie `CustomerOrder -> OrderItem`
Kaskadowe zapisywanie `CustomerOrder -> OrderItem`
1
Zadanie
Spring Data JPA, poziom 27, lekcja 2
Niedostępne
Usuwanie sieroty po zerwaniu relacji
Usuwanie sieroty po zerwaniu relacji
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION