JavaRush /Курси /Hibernate deep-dive /ORM regression suite

ORM regression suite

Hibernate deep-dive
Рівень 28 , Лекція 4
Відкрита

1. Вступ

Якщо ви колись бачили проєкт, де тести лежать «як шкарпетки після прання» (ніби чисті, але чому один у папці service, другий у util, а третій узагалі в OldTests2), то ви вже знаєте, навіщо потрібна структура. ORM-regression suite — це не просто багато тестів. Це організований набір домовленостей, які захищають вас від повернення вже впійманих ORM-граблів.

Проблеми Hibernate підступні тим, що вони часто повертаються нишком. Сьогодні ви акуратно виправили N+1 через JOIN FETCH, а завтра хтось зробив нібито нешкідливий рефакторинг, змінив метод репозиторію — і все знову почало стріляти по ногах, але вже на проді. Regression suite потрібен, щоб такі речі ловилися не користувачами, а зеленою або червоною лампочкою в тестах.

Корисна ментальна модель тут така: кожен ORM-regression тест — це маленький контракт на поведінку persistence layer, у якому є не лише бізнес-результат («замовлення завантажилося»), а й технічне очікування («завантажилося без N+1», «bulk-операція вимагає clear() перед перевіркою», «merge() повертає managed-copy, а не оживляє початковий об’єкт»). Якщо домовленості не записані тестами, вони перетворюються на «знання в голові однієї людини», а це найненадійніше сховище на планеті.

2. Розбиття за фічами та ризиками

Найчастіша помилка під час організації ORM-тестів — «давайте зробимо папку repository і туди все складемо». Це виглядає логічно перші два дні, а потім виходить один гігантський тестовий клас на 800 рядків, де поруч лежать soft delete, merge, N+1 і «перевірка унікальності SKU». Таке неможливо читати, а отже — неможливо підтримувати.

У Commerce Persistence Lab у нас уже є сильна архітектурна підказка: підхід package-by-feature. Це означає, що й тести розумно розкладати за тими самими фічами, щоб мозок не перемикався між різними доменними завданнями. Далі, вже всередині фічі, ми розкладаємо тести за типом ORM-ризику: fetching, write semantics, bulk side effects, soft delete visibility, locking.

Невеликий орієнтир у вигляді дерева (це не догма, а «читабельний мінімум»):


src/test/java/com/example/commerce
├─ catalog
│  ├─ fetching
│  │  └─ ProductFetchingRegressionTest.java
│  ├─ projection
│  │  └─ ProductProjectionRegressionTest.java
│  ├─ bulk
│  │  └─ ProductBulkUpdateRegressionTest.java
│  └─ softdelete
│     └─ ProductSoftDeleteVisibilityTest.java
├─ orders
│  ├─ merge
│  │  └─ PurchaseOrderMergeRegressionTest.java
│  ├─ flush
│  │  └─ PurchaseOrderFlushBehaviorTest.java
│  └─ fetching
│     └─ PurchaseOrderCardFetchingTest.java
├─ inventory
│  └─ locking
│     ├─ InventoryOptimisticLockingIT.java
│     └─ InventoryPessimisticLockingIT.java
└─ labsupport
   ├─ HibernateStats.java
   ├─ ConcurrencySupport.java
   └─ JpaTestSupport.java

Зверніть увагу на тонкість: папка labsupport у тестах — це нормально, якщо там лежать прозорі помічники, а не «таємна магія». Ми не хочемо ховати Hibernate від студентів під килим. Ми хочемо, навпаки, зробити механічні кроки (типу clear + reread) повторюваними й незабутніми, але при цьому зрозумілими.

Назви тестів як домовленість

Називати тест testSave() — це як підписати коробку «речі». Так, формально не брешете, але коли вам терміново потрібен пульт від кондиціонера, таке маркування викликає філософську печаль. В ORM-regression suite назва тесту має відповідати на запитання: який контракт ми захищаємо.

У нашому курсі ми часто фіксуємо контракти на кшталт «read-case має вкладатися в 1 SQL», «після bulk потрібно clear()», «getReferenceById() не можна перевіряти як звичайний об’єкт». Якщо тест названо за цим змістом, він сам себе документує і перетворюється на читабельний артефакт проєкту, а не просто на «перевірку для галочки».

Приклад гарної назви тестового методу (з підкресленнями — це стиль, який у Java часто читається краще, ніж CamelCase із трьох речень):

import org.junit.jupiter.api.Test; 

class NamingExampleTest {

    @Test
    void findCardById_loadsWithSingleQuery() {
        // Контракт: читання картки має вкладатися в один SQL-запит
    }

    @Test
    void bulkRename_requiresClearBeforeReload() {
        // Контракт: після bulk-операції persistence context потрібно очистити перед перевіркою
    }

    @Test
    void merge_returnsManagedCopy_notSameInstance() {
        // Контракт: merge повертає managed-копію, а не «оживляє» початковий detached-об’єкт
    }
}

Ідея проста: тест читається як твердження. Ви не описуєте «як» (це в тілі тесту), ви описуєте «що має бути істинним». Це особливо важливо для ORM, тому що «як» часто неочевидно, а «що» — це ваш контракт.

3. Фікстури: мінімум і детермінізм

Фікстура в ORM-тестах — це не «зальємо побільше даних, щоб було схоже на прод». Це майже завжди пастка. Чим більше даних, тим більше випадкових перетинів, тим вищий шанс, що тест почне залежати від того, в якому порядку ви щось зберегли або який id «випав» сьогодні. Hibernate і так достатньо магічний, щоб не додавати йому ще магії фікстурами.

Практичний стиль для regression suite такий: кожен тест створює рівно ті сутності, які потрібні цьому контракту, і називає їх так, щоб було зрозуміло, навіщо вони існують. «Товар із дубльованим SKU», «замовлення з двома позиціями», «залишок, який конфліктує за версією». Якщо ви бачите в тесті Product p1 = new Product(); p1.setSku("X"); — це нудно й легко переплутати. Якщо ви бачите createProduct("SKU-DUP-1", "Mouse") — мозку легше.

Один із найпростіших способів — маленька фабрика тестових даних у тестовому пакеті. Вона не має бути розумною, їй не потрібно знати весь домен. Її завдання — зробити тести читабельними.

import com.example.commerce.catalog.entity.Product;

final class CatalogFixtures {

    static Product product(String sku, String name) {
        // Важливо: тестові дані мають бути «промовистими» (sku і name не випадкові)
        Product p = new Product();
        p.setSku(sku);   // Наприклад: "SKU-DUP-1"
        p.setName(name); // Наприклад: "Mouse"
        return p;
    }

    private CatalogFixtures() {
        // Утилітний клас: не дозволяємо створювати екземпляри
    }
}

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

Часто виникає запитання: «А чи можна замість створення сутностей у тесті використовувати @Sql?» Можна, але обережно. @Sql хороший, коли ви тестуєте саме SQL-видимість (обмеження схеми, специфічну форму запиту, особливості soft delete), і вам важливо контролювати дані на рівні рядків. Але для багатьох ORM-сценаріїв (merge/flush/dirty checking) створення сутностей через репозиторій дає природніший контекст, тому що ви проходите той самий шлях, що й застосунок.

4. Helpers без магії

В ORM-тестах є три дії, які ви виконуватимете постійно: синхронізувати зміни з БД, викинути managed-об’єкти з persistence context і перечитати дані заново. Якщо ви не зробите ці кроки усвідомленими, тести почнуть брехати самі собі: ви перевірятимете стан об’єкта в пам’яті й думатимете, що перевірили БД.

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

Наприклад, «класика жанру» — flushAndClear():

import jakarta.persistence.EntityManager;

final class JpaTestSupport {

    static void flushAndClear(EntityManager em) {
        // flush: проштовхуємо зміни в БД (на рівні SQL)
        em.flush();
        // clear: очищаємо persistence context, щоб reread справді йшов із БД, а не з кешу першого рівня
        em.clear();
    }

    private JpaTestSupport() {
        // Утилітний клас: не дозволяємо створювати екземпляри
    }
}

І використання в тесті стає читабельним: «зберіг → flushAndClear → reload». Це не магія, це просто винесення повторюваного ритуалу.

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

import com.example.commerce.catalog.entity.Product;
import com.example.commerce.catalog.repository.ProductRepository;
import jakarta.persistence.EntityManager;

final class ProductReload {

    static Product reload(EntityManager em, ProductRepository repo, Long id) {
        // Важливо: перед читанням очищаємо persistence context,
        // інакше є ризик отримати той самий managed-об’єкт із кешу першого рівня
        em.clear();

        // Читаємо через репозиторій, щоб у тесті було видно, як саме ми отримуємо дані
        return repo.findById(id).orElseThrow();
    }

    private ProductReload() {
        // Утилітний клас: не дозволяємо створювати екземпляри
    }
}

Такі reload-helperʼи зазвичай живуть поруч із тестами конкретної фічі, а в загальному labsupport краще тримати лише справді спільні речі на кшталт HibernateStats, ConcurrencySupport і JpaTestSupport.

Зверніть увагу, ми спеціально залишили em.clear() всередині helperʼа, тому що це механічна дія, але не сховали факт перечитування: у сигнатурі видно, що ми робимо reload через repository. Якщо helper починає робити «і bulk-операцію, і очищення, і перевірку, і друк логів» — це вже не helper, а мініфреймворк, який робить тести непрозорими.

5. Матриця regression suite

Коли тестів стає багато, у вас з’являється нова небезпека: покрити десять варіантів вибірки для каталогу й забути про bulk side effects, бо «не встигли». Щоб цього не відбувалося, зручно тримати в голові мінімальну матрицю: які фічі проєкту мають які типи ORM-контрактів.

Зробімо це у формі таблиці. Таблиця — хороший компроміс: вона структурована й при цьому не перетворюється на нескінченний список.

Пакет фічі Що перевіряємо тестами Приклад контракту
catalog вибірка / проєкції / видимість soft delete «картка товару завантажується без N+1», «список читає projection, а не entity», «soft-deleted товар не видно через findById»
catalog.bulk наслідки bulk-операцій / застарілий persistence context «після bulk update managed-об’єкт залишається застарілим до clear()»
orders поведінка flush / семантика merge / завантаження картки замовлення «зміна статусу видна після flush+clear», «merge() повертає managed-copy», «детальне завантаження замовлення не робить зайвих запитів»
inventory блокування (optimistic/pessimistic) «паралельний reserve дає конфлікт версії», «pessimistic lock коректно дотримується timeout/блокування»

Зверніть увагу: це не спроба перевірити все на світі. Це мінімально достатній набір, який утримує курс у реальності. Бо якщо ви не тестуєте stale persistence context після bulk — ви майже гарантовано колись отримаєте «усередині транзакції дані не ті», а це один із найнеприємніших багів: він виглядає як містика.

6. Конкурентні тести та рівні контексту

@DataJpaTest лишається форматом за замовчуванням для більшості ORM-регресій. Але тут уже видно виняток: конкурентний тест — це не лише JPA mapping і запит, а ще й дві незалежні транзакції, інколи на рівні сервісного методу.

Тому нормальна стратегія для suite така: усе, що стосується repository/query/mapping, живе в @DataJpaTest, а все, що про «дві транзакції змагаються за один рядок», може жити в тестах рівня @SpringBootTest, але без web-layer (він і не потрібен). Важливо не переплутати мету: нас і далі цікавить persistence layer, просто іноді зручніше тестувати його через сервіс, тому що саме сервіс задає межу транзакції.

При цьому структура папок залишається такою самою — за фічами. Наприклад, inventory/locking — логічне місце для optimistic/pessimistic сценаріїв. І це навіть зручно методично: ви відкриваєте пакет inventory.locking і розумієте, що тут лежить усе про конкурентність залишків.

7. Профілі, статистика і запуск

Набір тестів легко написати. Складніше зробити так, щоб він стабільно працював і не залежав від настрою оточення. Для ORM-regression suite важливі два чинники: спостережуваність і однакове середовище виконання.

Спостережуваність означає, що якщо ви тестуєте query count, у вас має бути інструмент, який цей query count рахує. У навчальному проєкті це зручно робити через HibernateStats — той самий невеликий helper над Hibernate Statistics. У тестовому середовищі статистику варто вмикати явно (через test-профіль), щоб не вийшло так, що в одного розробника вона ввімкнена, а в іншого — ні, і тести поводяться по-різному.

Приклад мінімального налаштування в application-test.yml (шматочок, без фанатизму):

spring:
  jpa:
    properties:
      hibernate:
        # Спостережуваність: вмикаємо статистику, якщо тести перевіряють кількість SQL-запитів
        generate_statistics: true

Ще один чинник — передбачуваний запуск. Навіть якщо ви не будуєте величезну платформу, корисно хоча б домовитися, що suite запускається однією командою (./gradlew test) і що тести не вимагають ручного «а тепер підніміть базу і зачекайте 40 секунд». У курсі ми орієнтуємося на PostgreSQL-середовище, тому зазвичай або використовується заздалегідь піднята локальна БД, або контейнеризований підхід. Але незалежно від механіки мета одна: тест має падати через ORM-поведінку, а не тому, що хтось забув запустити Docker.

8. Типові помилки під час організації ORM-regression suite

Помилка №1: один гігантський тестовий клас «на весь data-layer».
Це починається невинно: «ну поки в нас мало тестів». Через тиждень клас розростається, у ньому з’являються різні доменні фічі та різні типи ризиків, а потім будь-яка зміна перетворюється на гру «знайдіть місце, де це зламалося». Набагато спокійніше тримати тести невеликими й розкладати їх за feature-пакетами та за типом контракту.

Помилка №2: фікстури “як у проді”, бо так реалістичніше.
Велика фікстура майже завжди вбиває читабельність. Тест починає залежати від випадкових перетинів даних, від порядку вставки, від неявних унікальностей. Для regression suite важливіша детермінованість. Реалізм в ORM-світі досягається не кількістю рядків, а тим, що ви тестуєте на реальній БД і з реальними обмеженнями схеми.

Помилка №3: перевірка результату через той самий managed-об’єкт і віра, що це перевірка БД.
Hibernate уміє бути переконливим: ви змінили поле, прочитали поле — усе збігається, тест зелений. Але це може бути просто first-level cache, а не факт запису в базу. Якщо ви хочете перевірити «видимість у БД», вам майже завжди потрібні flush() і clear(), а потім reread через репозиторій.

Помилка №4: «helpers заради helpers», які ховають усю механіку.
Іноді хочеться зробити гарний базовий клас AbstractJpaTest, який «сам усе зробить». У результаті тест перетворюється на кілька рядків, але перестає бути навчальним та інженерним: ви більше не бачите, де транзакція, де flush, де clear, де reread. Helpers мають прибирати рутину, але не ховати зміст.

Помилка №5: конкурентні тести на sleep() і надії на удачу.
Якщо ви хочете відтворити optimistic conflict, потрібно координувати паралельний старт транзакцій, інакше тест стане flaky: іноді конфлікт буде, іноді — ні. Flaky-тест гірший за відсутність тесту, тому що він вчить команду ігнорувати червоний колір.

1
Опитування
Тести ORM, рівень 28, лекція 4
Недоступний
Тести ORM
Виявляємо ORM-помилки в тестах
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ