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. Тут важливий лише прикладний сенс: мапінг-тест має пройти цикл save → flush → clear → повторне читання, інакше надто легко перевірити гарний граф об’єктів у пам’яті замість факту в таблицях.
Для повторного читання зазвичай достатньо 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: Product → Category і перевірка FK
Почнімо з найзрозумілішого виду зв’язку, тому що він чудово показує сенс мапінгу як контракту. У домені міні-магазину категорія — це довідник, а товар на неї посилається. У таблицях це виглядає як зовнішній ключ product.category_id → category.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). Ми перевірили, що ланцюжок Order → items → product справді існує після повторного читання. Це якраз той випадок, коли «перевірити лише розмір» надто слабко: розмір може бути правильним навіть за частково зламаного зв’язку, а ось конкретне поле товару дає нам упевненість, що все зв’язалося по-справжньому.
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: Product → StockItem
Зв’язок 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 на довідник (Product → Category) зазвичай погано, бо категорії живуть своїм життям. Каскадувати OneToMany від замовлення до позицій зазвичай добре, бо позиції — це частина замовлення. Тести якраз допомагають побачити цю межу і зафіксувати її як контракт.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ