JavaRush /Курси /Spring Data JPA /Тестуємо мапінг і каскади

Тестуємо мапінг і каскади

Spring Data JPA
Рівень 27 , Лекція 2
Відкрита

1. Мапінг-тест: поведінка, а не анотації

Якщо ви тільки починаєте, є небезпечна ілюзія: «я поставив @ManyToOne, отже зв’язок є». Це приблизно як наклеїти на ноутбук стікер “Gaming PC” і очікувати +30 FPS у житті. ORM працює не за наклейками, а за тим, як реально влаштовані таблиці, зовнішній ключ, nullable-колонки та тим, хто «володіє» зв’язком. Тому мапінг-тест — це не про «чи сходиться гарний код», а про «чи переживає модель реальний цикл збереження й читання».

Коли ми говоримо «протестувати мапінг», ми зазвичай хочемо довести одну з дуже конкретних речей. Наприклад: «якщо я зберіг Product із категорією, то після повторного читання з БД у товару справді є категорія». Або: «якщо я зберіг CustomerOrder з однією позицією, то ця позиція не загубилася, і FK вказує на замовлення». Або: «якщо я видалив позицію із замовлення, то вона справді зникла з таблиці order_item».

Корисно тримати в голові просту «формулу чесного мапінг-тесту»:

flowchart TD
    A[Підготовка: створюємо entity-обʼєкти] --> B[Дія: зберігаємо через repository]
    B --> C[flush: змушуємо SQL піти в БД]
    C --> D[clear: очищаємо persistence context]
    D --> E[Перевірка: читаємо ще раз і звіряємо факт у БД]

Зверніть увагу: наприкінці ми перевіряємо не «поле в об’єкті», а наслідок у базі, який проявляється через повторне читання.

2. Інструменти мапінг-тесту: findById(...), flush(), clear()

Механіку flush() і clear() ми вже розібрали на JPA-slice. Тут важливий лише прикладний сенс: мапінг-тест має пройти цикл saveflushclear → повторне читання, інакше надто легко перевірити гарний граф об’єктів у пам’яті замість факту в таблицях.

Для повторного читання зазвичай достатньо repository.findById(...). Це нормально, тому що в цій лекції ми не тестуємо запити. Нам важливо чесно перевірити, що FK, owning side і каскади пережили запис, а потім відновилися з БД.

Приклад заготовки тестового класу у стилі нашого проєкту 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; // Доступ до flush/clear і низькорівневих перевірок БД

    @Autowired
    CustomerOrderRepository orderRepository; // Репозиторій — точка входу для збереження й читання кореня агрегату

    @Test
    void smoke() {
        // Тіло тестів буде далі
    }
}

І ось той самий «контрольний жест», який робить тест чеснішим:

entityManager.flush(); // Важливо: змушуємо Hibernate реально надіслати SQL у БД
entityManager.clear(); // Важливо: очищаємо persistence context, щоб під час читання не повернути обʼєкт "із памʼяті"

flush() і clear() не потрібні в кожному тесті як ритуал. Але там, де ви хочете довести поведінку БД, а не поточний стан managed-об’єктів, без них легко обдурити самого себе.

3. ManyToOne: ProductCategory і перевірка FK

Почнімо з найзрозумілішого виду зв’язку, тому що він чудово показує сенс мапінгу як контракту. У домені міні-магазину категорія — це довідник, а товар на неї посилається. У таблицях це виглядає як зовнішній ключ product.category_idcategory.id. В об’єктній моделі — Product.getCategory(). У тесті ми хочемо довести, що після збереження товару зв’язок справді зберігається і переживає повторне читання.

Припустімо, у нас є сутності Category і Product, а також репозиторії CategoryRepository і ProductRepository. Тест виглядатиме приблизно так: спочатку зберігаємо категорію як незалежну сутність, потім створюємо товар і вказуємо категорію, потім зберігаємо товар, робимо flush + clear і читаємо товар знову.

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: робимо перевірку "по-справжньому через БД"
    @Autowired CategoryRepository categoryRepository; // Довідник категорій (зазвичай живе окремо від товарів)
    @Autowired ProductRepository productRepository; // Товари посилаються на категорію через FK

    @Test
    void productKeepsCategoryAfterReload() {
        // Підготовка: спочатку створюємо і зберігаємо категорію як незалежну сутність
        Category tea = categoryRepository.save(new Category("tea", "Tea"));

        // Підготовка: створюємо товар і привʼязуємо його до вже збереженої категорії
        Product product = new Product("SKU-1", "Green Tea");
        product.setCategory(tea);

        // Дія: зберігаємо товар (має записати category_id)
        productRepository.save(product);

        // Перевірка через БД: виштовхуємо SQL і "забуваємо" обʼєкти з persistence context
        entityManager.flush();
        entityManager.clear();

        // Перевірка: читаємо ще раз і звіряємо, що звʼязок справді відновився з БД
        Product reloaded = productRepository.findById(product.getId()).orElseThrow();
        assertThat(reloaded.getCategory().getCode()).isEqualTo("tea");
    }
}

Що саме ми довели цим тестом? Ми довели, що зв’язок не «намальований у пам’яті», а реально виражений у базі. Якщо у вас десь переплутано @JoinColumn, якщо колонка не та, якщо FK не створюється або nullable не відповідає очікуванню, ви дізнаєтеся про це тут, а не через тиждень у найневдалішому місці.

Типова, якщо точніше — дуже поширена, помилка — спробувати в такому тесті зберегти товар із новою категорією без збереження категорії, сподіваючись на каскад. У більшості моделей ManyToOne до довідника не має каскадитися, і тест якраз допомагає це відчути руками.

4. Двосторонній зв’язок: CustomerOrder і OrderItem

Двосторонні зв’язки — головний «генератор здивування» у початківців. Причина проста: у вас наче дві правди. З одного боку, у замовлення є колекція items. З іншого боку, у позиції є поле order. І якщо ви оновили лише один бік, другий сам «магічно» не підтягнеться — на жаль, це не React, і керування станом тут не вбудоване.

Тому перший шар тестування таких зв’язків — навіть не БД, а helper-методи, які тримають обидва боки синхронізованими. У проєкті ми майже напевно робили щось таке:

public void addItem(OrderItem item) {
    items.add(item);      // Важливо: оновлюємо колекцію на боці замовлення
    item.setOrder(this);  // Важливо: оновлюємо зворотне посилання на боці позиції
}

Це можна тестувати навіть без репозиторію — просто як звичайний метод, тому що він запобігає десяткам «дивних» багів пізніше.

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class CustomerOrderHelpersTest {

    @Test
    void addItemKeepsBothSidesInSync() {
        // Підготовка: створюємо доменні обʼєкти, без БД і без ORM
        CustomerOrder order = new CustomerOrder("ORD-1", "a@b.com");
        OrderItem item = new OrderItem();

        // Дія: використовуємо helper-метод, який зобовʼязаний тримати граф обʼєктів узгодженим
        order.addItem(item);

        // Перевірка: звіряємо обидва боки звʼязку в памʼяті
        assertThat(order.getItems()).contains(item);
        assertThat(item.getOrder()).isSameAs(order);
    }
}

Але сьогодні ми в контурі @DataJpaTest, тому обов’язково додамо другий шар: перевіримо, що зв’язок переживає flush + clear.

Щоб тест був реалістичним, нам потрібен хоча б один Product, тому що OrderItem посилається на Product. Тут важливо не роздувати fixture: ми створюємо мінімально потрібні сутності, які дають нам «відмінний» результат.

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: змушуємо тест перевіряти саме БД
    @Autowired CustomerOrderRepository orderRepository; // Зберігаємо й читаємо замовлення як корінь агрегату
    @Autowired ProductRepository productRepository; // Потрібен, щоб створити Product для OrderItem

    @Test
    void orderKeepsItemsAfterReload() {
        // Підготовка: створюємо мінімальний товар, щоб OrderItem міг на нього посилатися
        Product product = productRepository.save(new Product("SKU-1", "Green Tea"));

        // Підготовка: створюємо замовлення і додаємо позицію через helper-метод (обидва боки мають синхронізуватися)
        CustomerOrder order = new CustomerOrder("ORD-1", "a@b.com");
        order.addItem(new OrderItem(product, 2, new BigDecimal("10.00")));

        // Дія: зберігаємо замовлення (очікуємо, що мапінг і каскади налаштовано коректно)
        orderRepository.save(order);

        // Перевірка через БД: фіксуємо в БД і потім читаємо з "чистого аркуша"
        entityManager.flush();
        entityManager.clear();

        // Перевірка: після повторного читання звʼязки мають відновитися з таблиць
        CustomerOrder reloaded = orderRepository.findById(order.getId()).orElseThrow();
        assertThat(reloaded.getItems()).hasSize(1);
        assertThat(reloaded.getItems().get(0).getProduct().getSku()).isEqualTo("SKU-1");
    }
}

Зверніть увагу на приховану важливість цього тесту: ми не просто перевірили hasSize(1). Ми перевірили, що ланцюжок Orderitemsproduct справді існує після повторного читання. Це якраз той випадок, коли «перевірити лише розмір» надто слабко: розмір може бути правильним навіть за частково зламаного зв’язку, а ось конкретне поле товару дає нам упевненість, що все зв’язалося по-справжньому.

5. cascade: корінь зберігає дочірні записи

cascade — це механізм, через який початківці найчастіше або радіють («ура, само збереглося!»), або лякаються («ой, само видалилося!»). У нормальному проєкті каскадування має відповідати життєвому циклу. У замовлення та позицій життєвий цикл спільний: позиція майже ніколи не живе сама по собі без замовлення, отже cascade тут логічний.

Тест на cascade має відповідати на дуже конкретне запитання: чи достатньо зберегти корінь (CustomerOrder), щоб дочірні OrderItem справді зʼявилися в БД? І тут знову важливо не переплутати «у пам’яті вони є» з «у таблиці вони є».

Один із хороших способів перевірити каскад — взяти ID дочірньої сутності після flush, очистити контекст і спробувати знайти її напряму через EntityManager.find(...). Це виглядає трохи «низькорівнево», зате дуже чесно і не тягне в тест зайві репозиторії.

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; // Потрібен, щоб напряму перевірити запис у таблиці через find(...)
    @Autowired CustomerOrderRepository orderRepository; // Зберігаємо "корінь"
    @Autowired ProductRepository productRepository; // Створюємо товар для позиції

    @Test
    void savingOrderAlsoPersistsItems() {
        // Підготовка: товар має існувати, щоб FK з OrderItem був валідним
        Product product = productRepository.save(new Product("SKU-1", "Green Tea"));

        // Підготовка: створюємо замовлення і позицію, позицію додаємо через helper
        CustomerOrder order = new CustomerOrder("ORD-1", "a@b.com");
        order.addItem(new OrderItem(product, 1, new BigDecimal("10.00")));

        // Дія: зберігаємо лише замовлення (якщо cascade налаштовано, позиції теж мають зберегтися)
        orderRepository.save(order);

        // Flush потрібен, щоб позиції отримали ID і справді зʼявилися в БД
        entityManager.flush();

        // Запамʼятовуємо ID дочірньої сутності, щоб перевірити її існування окремо від графа обʼєктів
        Long itemId = order.getItems().get(0).getId();

        // Очищаємо контекст, щоб наступний find не повернув "той самий обʼєкт із памʼяті"
        entityManager.clear();

        // Перевірка: позиція має знаходитися напряму за ID
        OrderItem found = entityManager.find(OrderItem.class, itemId);
        assertThat(found).isNotNull();
    }
}

Якщо каскаду немає, то часто станеться одне з двох: або при flush() Hibernate скаже щось на кшталт “transient instance must be saved before current operation” (бо ви прикріпили незбережену позицію до замовлення), або позиція не отримає ID і не опиниться в БД. І в обох випадках тест добрий: він ловить проблему на рівні data-model, а не на рівні «у мене в сервісі чомусь не працює placeOrder».

6. orphanRemoval: видалення сиріт

orphanRemoval — це наступний рівень після каскаду. Каскад допомагає зберігати або видаляти «по ланцюжку» під час операцій із батьком. А orphanRemoval означає: якщо елемент прибрали з колекції батька, він вважається «сиротою» і має бути видалений з бази. Це важлива бізнес-семантика для замовлення: якщо ви видалили позицію із замовлення, вона не повинна лишатися «висіти» в таблиці order_item.

Головна методична пастка: перевірити orphanRemoval «в пам’яті» дуже легко, але це нічого не доводить. Ви прибрали елемент зі списку — список став меншим. Вітаю, ви довели роботу ArrayList, а не роботу ORM. Тому для orphanRemoval обов’язково потрібен flush, і краще clear, а перевірка має йти через факт у БД.

Нам знадобиться послідовність кроків: зберегти замовлення з однією позицією, зафіксувати ID позиції, прибрати позицію із замовлення, зробити flush, очистити контекст і перевірити, що entityManager.find(OrderItem.class, itemId) повертає 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; // Перевіряємо факт у БД через find(...)
    @Autowired CustomerOrderRepository orderRepository; // Зберігаємо й читаємо замовлення
    @Autowired ProductRepository productRepository; // Створюємо товар для позиції

    @Test
    void removingItemFromOrderDeletesItFromDb() {
        // Підготовка: створюємо товар
        Product product = productRepository.save(new Product("SKU-1", "Green Tea"));

        // Підготовка: створюємо замовлення і позицію
        CustomerOrder order = new CustomerOrder("ORD-1", "a@b.com");
        OrderItem item = new OrderItem(product, 1, new BigDecimal("10.00"));
        order.addItem(item);

        // Дія: зберігаємо замовлення і отримуємо ID позиції
        orderRepository.save(order);
        entityManager.flush(); // Важливо: ID у item зʼявляється після flush

        Long itemId = item.getId();

        // Дія: видаляємо позицію із замовлення через helper (він має розірвати звʼязок з обох боків)
        order.removeItem(item); // Важливо: helper-метод синхронізує обидва боки

        // Перевірка через БД: фіксуємо зміни і читаємо "поза контекстом"
        entityManager.flush();
        entityManager.clear();

        // Перевірка: "сирота" має зникнути з таблиці
        assertThat(entityManager.find(OrderItem.class, itemId)).isNull();
    }
}

Чому тут важливий helper-метод removeItem()? Бо в двосторонньому зв’язку ви зобов’язані синхронізувати і колекцію, і зворотне посилання. Правильний removeItem зазвичай робить дві речі: видаляє з order.getItems() і ставить item.setOrder(null). Якщо ви зробите лише одне, поведінка може стати «дивною»: у пам’яті ви думаєте, що видалили, а Hibernate на flush побачить інший стан графа.

І ще один нюанс, який корисно усвідомити: orphanRemoval — це не «ввічливе прохання». Це прямий контракт: розірвали зв’язок — запис має зникнути. Якщо це не відповідає вашій предметній області, orphanRemoval краще не вмикати. Але якщо відповідає — тест на orphanRemoval перетворюється на ваш «ремінь безпеки».

Міні-кейс OneToOne: ProductStockItem

Зв’язок OneToOne виглядає красиво на діаграмі, але в базі він тримається на дуже приземлених речах: унікальності зовнішнього ключа та дисципліні «хто володіє зв’язком». У нашому проєкті StockItem — це облік залишків, і на один Product має бути рівно один StockItem. У тесті ми хочемо довести, що StockItem справді знаходиться через товар після повторного читання.

Приклад у спрощеному вигляді (ми не тестуємо бізнес-логіку залишків, лише мапінг):

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: читаємо "наче з реальної БД"
    @Autowired ProductRepository productRepository; // Створюємо товар
    @Autowired StockItemRepository stockItemRepository; // Створюємо запис залишків, повʼязаний із товаром

    @Test
    void stockItemIsReachableFromProductAfterReload() {
        // Підготовка: створюємо товар
        Product product = productRepository.save(new Product("SKU-1", "Green Tea"));

        // Підготовка: створюємо залишки для товару (володіння звʼязком залежить від вашої моделі)
        stockItemRepository.save(new StockItem(product, 10, 0));

        // Перевірка через БД: фіксуємо і читаємо знову
        entityManager.flush();
        entityManager.clear();

        // Перевірка: після повторного читання StockItem має бути доступний з Product
        Product reloaded = productRepository.findById(product.getId()).orElseThrow();
        assertThat(reloaded.getStockItem().getAvailableQuantity()).isEqualTo(10);
    }
}

Так, тут ми використовуємо окремий репозиторій StockItemRepository. Це нормально в мапінг-тестах, бо нам потрібно створити коректний стан бази. Якщо у вашій моделі налаштовано каскад від Product до StockItem, тоді тест можна спростити і зберігати лише товар; але частіше в реальному житті каскад для OneToOne потрібно вмикати дуже свідомо, тому обидва варіанти допустимі — головне, щоб тест доводив реальну поведінку після flush + clear.

7. Типові помилки під час мапінг-тестів

Помилка №1: тест без повторного читання.
Такий тест зазвичай доводить лише стан Java-об’єктів, які ви щойно створили. ORM при цьому міг взагалі нічого не записати в БД (або записати не так), а ви цього не побачили, бо persistence context «тримає» об’єкт і повертає його ж. Якщо хочеться перевірити мапінг, потрібно змусити SQL піти (flush), а потім читати знову після очищення контексту (clear).

Помилка №2: зміна лише inverse side у двосторонньому зв’язку.
Початківці часто додають item у order.getItems() і думають, що item.setOrder(order) «якось зробить Hibernate». Hibernate, звісно, зробить. Але не те, що ви хотіли. У результаті FK може не встановитися або буде встановлений пізніше й неочевидно. Тому helper-методи addItem()/removeItem() у доменних сутностях — не забаганка викладача, а спосіб зробити поведінку стабільною.

Помилка №3: змішати в одному тесті cascade, orphanRemoval та інші твердження.
Великий «універсальний» тест здається зручним до першого падіння. Потім ви дивитеся на нього і не розумієте: у вас зламався каскад, видалення, зв’язок чи взагалі товар не зберігся? Мапінг-тести мають бути маленькими: один тест — один сенс. Це пришвидшує налагодження і робить тестову базу зрозумілою навіть через місяць.

Помилка №4: orphanRemoval перевіряється лише за розміром колекції.
Прибрати елемент із колекції — це дія в пам’яті. orphanRemoval — це дія в базі. Між ними стоїть flush. Якщо в тесті немає flush, ви не перевірили orphanRemoval. Якщо в тесті немає перевірки факту в БД (наприклад, entityManager.find(...) == null), ви не перевірили orphanRemoval. Ви перевірили List.remove().

Помилка №5: «каскад увімкнемо всюди, щоб менше думати».
Це дуже людське бажання: менше писати коду, менше репозиторіїв, більше магії. Але каскад — це про життєвий цикл. Каскадувати ManyToOne на довідник (ProductCategory) зазвичай погано, бо категорії живуть своїм життям. Каскадувати OneToMany від замовлення до позицій зазвичай добре, бо позиції — це частина замовлення. Тести якраз допомагають побачити цю межу і зафіксувати її як контракт.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ