JavaRush /Курси /Hibernate deep-dive /ORM-баги та інтеграційні тести

ORM-баги та інтеграційні тести

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

1. Що таке ORM-баг

Коли ми чуємо слово «баг», мозок зазвичай малює просту картинку: «я присвоїв status = CANCELLED, а в обʼєкті чомусь status = NEW». Це класична, майже затишна Java-помилка. ORM-баги поводяться інакше: вони часто проявляються не як «неправильне значення», а як неочікуваний SQL, раптовий UPDATE, лавина запитів або виняток, що з’являється лише під час реальної взаємодії з БД і транзакцією.

У Hibernate deep-dive ми постійно повторювали формулу: «код + persistence context + транзакція + база даних = реальна поведінка». Тому ORM-баги частіше схожі на проблеми на межі, а не на помилки внутрішньої логіки обʼєкта. Наприклад, ви можете ідеально змінювати поля в managed-entity, але отримати «чомусь не збереглося», бо реальний flush стався не там, де ви очікували. Або навпаки — ви «нічого не зберігали», а прилетів UPDATE, тому що dirty checking вирішив, що стан змінився.

Важливо пам’ятати просту різницю: звичайний баг у Java частіше виявляється перевіркою стану обʼєкта в пам’яті, а ORM-баг вимагає спостерігати за тим, що відбувається навколо обʼєкта — у persistence context, у SQL і в базі. Саме цього unit-тест за визначенням намагається не торкатися (і в цьому його сила), а інтеграційний тест, навпаки, має це охоплювати (і за це ми його любимо, хоч він і повільніший).

Щоб відчути різницю, уявіть парашут. Unit-тест — це коли ви перевіряєте, що тканина міцна, стропи не рвуться, карабін клацає. Усе важливо. Але питання «чи розкриється він під час стрибка» все одно залишається. Стрибок — це інтеграційний тест. ORM — це такий самий стрибок: доки не сходили в БД і не прожили транзакцію, ви не перевірили найцікавіше.

2. Unit-тест і реальність Hibernate

Unit-тест — чудова річ, особливо для логіки, яку можна відокремити від інфраструктури: розрахунків, мапінгу DTO, валідаторів, невеликих доменних правил. Але Hibernate — це runtime-система, яка поводиться по-різному залежно від контексту: чи відкрито persistence context, чи є транзакція, який flush mode, яка БД, які обмеження схеми, які індекси, який план виконання, яка стратегія id. У моках цієї «реальності» просто немає.

Найтиповіша пастка новачка виглядає так: «Я змокну репозиторій, і тест усе перевірить». Технічно ви перевірите, що сервіс викликає repository.save(...). Але це не перевірка ORM-поведінки — це перевірка того, що ви вмієте викликати метод. Hibernate в цей момент навіть не прокинувся: SQL не генерувався, транзакції не було, dirty checking не порівнював snapshots, flush не запускався. Ви перевірили «телефонний дзвінок», а не «розмову».

Ось невеликий приклад того, як виглядає «мок-реальність». Код не поганий як unit-тест, він просто розвʼязує іншу задачу.

import org.junit.jupiter.api.Test;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

class MockedRepositoryExampleTest {

    @Test
    void mockedRepositoryReturnsPlainObject() {
        // Важливо: це саме mock, Hibernate тут не бере участі
        ProductRepository repo = mock(ProductRepository.class);

        // Повертаємо звичайний об’єкт, а не Hibernate proxy
        when(repo.getReferenceById(1L)).thenReturn(new Product()); // не proxy, просто new

        Product product = repo.getReferenceById(1L);

        // Важливо: тут ви не перевірили Hibernate proxy, lazy-init і зв’язок з persistence context.
        // Цей тест перевіряє лише поведінку мока, а не ORM.
    }
}

У справжньому застосунку getReferenceById() (або EntityManager.getReference()) — це історія про proxy, про прив’язку до persistence context і про те, як доступ до поля раптово перетворюється на SQL. У моках цей зміст узагалі не відтворюється. Можна хоч тисячу when(...) thenReturn(...) написати — proxy з цього не вийде, як би ви не старалися.

Саме тому ORM-баги вкрай рідко ловляться unit-тестами. Вони занадто пласкі: у них немає БД, немає транзакції, немає SQL, а отже — немає того середовища, в якому живуть наші реальні проблеми: N+1, lazy loading, неочікуваний flush, merge() з копіюванням стану, конфлікти optimistic locking, stale persistence context після bulk.

Нормальна інженерна думка тут не «unit-тести погані», а «ми маємо правильно обирати інструмент». Як молотком незручно різати хліб (але зручно забивати цвях), так і моками незручно ловити ORM-баги (але зручно перевіряти, що сервіс не робить зайвих викликів і правильно гілкується).

3. Два тести — два світи

Найкраще ця тема відчувається на контрасті: один тест — «про Java-обʼєкт», другий — «про реальний JPA/Hibernate-контекст». Обидва тести можуть бути чесними й корисними, просто вони відповідають на різні запитання. Перший відповідає: «мій код узагалі присвоює значення?» Другий — «мій код працює у світі Hibernate, proxy, транзакції та SQL?».

Почнімо з того самого затишного unit-тесту. Він перевіряє лише поведінку простого обʼєкта. У ньому немає нічого «поганого» — він просто не про ORM.

import org.junit.jupiter.api.Test;

import static org.junit.jupiter.api.Assertions.assertEquals;

class PurchaseOrderPojoTest {

    @Test
    void changesStatusInMemory() {
        // Тут ми працюємо лише з POJO в пам’яті — без БД і Hibernate
        PurchaseOrder order = new PurchaseOrder();

        // Перевіряємо звичайну логіку присвоювання
        order.setStatus(OrderStatus.CANCELLED);

        assertEquals(OrderStatus.CANCELLED, order.getStatus());
    }
}

Цей тест пройде навіть якщо PurchaseOrder взагалі не є @Entity, якщо таблиця purchase_order не існує, якщо статус у БД зберігається неправильно, якщо в мапінгу є помилка, якщо ви забули колонку, якщо enum не збігається за значенням. Він перевіряє рівно те, що йому й належить: поле змінюється в пам’яті.

А тепер — тест з іншого світу. Тут зміст не в тому, щоб «просто отримати обʼєкт», а в тому, що getReferenceById() повертає посилання, і реальне читання відбувається за правилами Hibernate всередині контексту.

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.junit.jupiter.api.Assertions.assertEquals;

@DataJpaTest
class ProductReferenceTest {

    // Репозиторій піднімається у справжньому Spring/JPA-контексті
    @Autowired ProductRepository productRepository;

    @Test
    void getReferenceById_resolvesSkuInsideJpaContext() {
        // Важливо: тут зазвичай повернеться proxy, а не повністю завантажена сутність
        Product product = productRepository.getReferenceById(1L);

        // Важливо: доступ до поля може тригерити SQL, тому потрібні JPA-контекст і транзакція
        assertEquals("SKU-1", product.getSku()); // доступ до даних вимагає ORM-оточення
    }
}

Цей тест уже неможливо чесно виконати без JPA-контексту. І саме такі тести нам потрібні, коли ми перевіряємо ORM-поведінку. Не тому, що ми любимо важкі тести (ми не мазохісти), а тому, що Hibernate — не бібліотека «про поля», а система «про взаємодію з базою».

Якщо коротко підсумувати контраст: перший тест доводить, що ви вмієте присвоювати значення, а другий — що ви вмієте жити в реальності Hibernate. І в deep-dive-курсі нас цікавить саме друга реальність — тому що перша зазвичай ламається значно рідше (Java в цьому сенсі доволі чесна).

4. Спостережувана ORM-поведінка

Коли ми говоримо «інтеграційний ORM-тест», важливо не скотитися до розпливчастого «ну він же з базою — значить усе ок». З базою може бути й ок, але fetching — поганий, flush — неочікуваний, bulk — залишає застарілий стан, merge() — тягне зайві SELECT, optimistic locking — не ловиться, soft delete — ламає видимість у списках. Тому хороша інтеграційна перевірка має дивитися не лише на результат, а й на сліди на снігу — тобто на спостережувану поведінку persistence context.

Зручно думати про це як про два шари контракту. Перший шар — функціональний: «замовлення збереглося», «товар знайшовся», «залишок зменшився». Другий шар — persistence-орієнтований: «не було N+1», «після bulk стан не лишився застарілим», «getReferenceById() не вибухнув під час доступу до поля», «після clear() дані перечитуються з БД», «конкурентний конфлікт проявляється як очікуваний виняток».

Нижче — таблиця. Вона не замінює деталі, але допомагає зрозуміти, чому unit-тести «сліпі» щодо цих ризиків.

Тип ризику в ORM Як він проявляється в реальності Що спостерігати в інтеграційному тесті Чому unit-тест цього не спіймає
Lazy-проблеми LazyInitializationException або неочікувані запити під час доступу до поля виняток під час доступу поза контекстом або факт SQL під час доступу у моках немає persistence context і proxy
N+1 багато SELECT замість одного кількість запитів / SQL-трасування у unit-тесті немає SQL і статистики
flush-семантика SQL потрапляє в БД раніше, ніж ви думали стан, видимий у БД, після flush + повторного читання без БД і транзакції flush не існує
merge() повертається інший managed instance, можливі зайві читання різниця source/result, наявність SELECT у unit-тесті merge — «просто метод», а не поведінка ORM
Bulk side effects дані в БД змінилися, але managed-обʼєкти залишилися старими застарілий стан до clear(), коректність після повторного читання у unit-тесті немає persistence context, нічому «псуватися»
Optimistic locking конфлікт версій під час UPDATE очікуваний виняток під час конкурентного оновлення без реальної БД і version-column конфлікту немає
Constraints схеми унікальність / зовнішній ключ ловляться БД виняток під час flush, а не під час setSku() unit-тест не знає про constraints

Зверніть увагу: майже всюди фігурують слова «SQL», «контекст», «транзакція», «видимість у БД». Це і є причина, чому ми не можемо «просто змокнути репозиторій» і вважати це ORM-тестуванням. Hibernate не працює в режимі театру одного актора: йому потрібні сцена, декорації та глядачі. У нашому випадку сцена — це база даних.

До речі, тут дуже допомагає одна звичка з минулих модулів: якщо ви не зробили clear() і потім перечитали дані заново, ви часто перевіряєте не базу, а first-level cache. Тобто тест може зеленіти, навіть якщо в БД усе інакше. В інтеграційному тестуванні це класична пастка: тест не бреше, але відповідає не на те запитання.

5. Реальна БД в інтеграційних тестах

Є дуже підступна категорія проблем: «на моїй in-memory базі тести зелені, а на PostgreSQL — пожежа». І це не тому, що PostgreSQL шкідливий (хоча інколи здається, що він робить це з принципу). Просто база даних — це частина специфікації вашого persistence layer. У неї є власні обмеження, семантика транзакцій, блокування, індекси, послідовності, особливості типів і планів виконання.

Якщо ви тестуєте ORM-поведінку в надто гладкому середовищі, ви можете випадково отримати хибне відчуття безпеки. Особливо це стосується deep-dive-курсу, де ми постійно впираємося в реальні SQL-наслідки. Обмеження схеми — це не те, що «десь там на бойовому середовищі DBA додасть», а частина договору. Так само й locking — це не «ну в теорії може бути», а відтворюваний сценарій.

Щоб не йти в конфігурації, покажу лише саму ідею: унікальність sku — це не Java-правило, а правило БД. Його не можна чесно перевірити, не задіявши базу і flush.

Два продукти з однаковим sku спокійно переживуть setSku(). Проблема проявиться лише тоді, коли SQL справді дійде до бази — на flush або commit. Тому тут важлива сама думка: багато правил живуть нижче Java-коду, і unit-тест без БД їх просто не бачить.

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

6. Один сценарій — один сигнал

Коли люди вперше починають писати інтеграційні тести, є спокуса зробити один «великий тест на все»: і зберегти замовлення, і завантажити його, і перевірити товари, і ще заодно переконатися, що немає N+1, і нехай там десь посередині станеться flush. Такий тест виглядатиме героїчно… рівно до першого падіння. Бо ви сидітимете й гадатимете: «а що саме зламалося?».

Інтеграційний тест на ORM-поведінку — це не роман «Війна і мир», а короткий юридичний контракт на одну конкретну гарантію. Хороший тест відповідає на одне запитання і перевіряє один помітний сигнал. Якщо ви фіксуєте семантику getReferenceById() — перевіряйте саме її. Якщо фіксуєте факт, що читання «картки замовлення» не призводить до N+1, то тест має бути про профіль запитів, а не про все одразу.

Дуже допомагає стиль, який можна назвати «Given–When–Then, але без фанатизму»: спочатку готуємо мінімальні дані, потім робимо одну дію, а потім перевіряємо і функціональний результат, і persistence-сигнал. В ORM-тестах цей сигнал часто виглядає як «очистив контекст — перечитав — перевірив», або як «очікую виняток», або як «дивлюся на кількість SQL-запитів». Важливо, що це не прикраса, а частина договору.

І ще одна практична річ, яка робить тести підтримуваними: імʼя тесту має бути як заголовок бага в трекері. Не testSave(), а щось у стилі getReferenceById_resolvesSkuInsideJpaContext. Це звучить довго, але зате через три місяці ви відкриваєте тест і одразу розумієте, що він захищає. ORM-регресії люблять повертатися тихо, тому тест має кричати своїм іменем: «Я тут стою, я не просто так».

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

На початку шляху інтеграційні тести часто виходять або занадто поверховими, або занадто монструозними. Обидва варіанти не дають того ефекту, заради якого ми взагалі їх пишемо: відтворюваності ORM-проблем і захисту від регресій. Нижче — набір помилок, які трапляються найчастіше (і так, майже всі ми в них потрапляли, просто не всі це визнають).

Помилка № 1: тестують getter/setter і називають це «тестом Hibernate».
Такий тест корисний рівно настільки ж, наскільки корисно перевіряти, що в чашки є ручка. Так, ручка є. Але чай від цього не стає гарячим. ORM-баги живуть не в присвоюванні полів, а в тому, як ці поля мапляться, коли і чому йдуть у SQL, що відбувається під час flush і що залишається в persistence context.

Помилка № 2: мокають репозиторії і вважають, що «інтеграцію перевірено».
Моки хороші, коли ви перевіряєте гілкування логіки та взаємодію між компонентами. Але Hibernate не проявляє себе в моках, тому що там немає ні proxy, ні сесії, ні SQL, ні транзакції, ні поведінки конкретної БД. Це все одно що тестувати плавання, стоячи на килимку для йоги (килимок чудовий, але де вода?).

Помилка № 3: перевіряють результат через той самий managed-обʼєкт і забувають про clear()/перечитування.
Hibernate дуже турботливий: він може показати вам дані з first-level cache, і ви подумаєте «ура, збереглося». Але ви перевірили не базу, а пам’ять. Щойно в реальному застосунку з’явиться інший запит або інша транзакція, раптово з’ясується, що в БД зовсім не те, що ви бачили в тесті. Тому інтеграційний тест майже завжди має вміти дивитися на базу, а не лише на persistence context.

Помилка № 4: в одному тесті намагаються спіймати fetching, flush і locking одночасно.
У підсумку тест перетворюється на детектив: «хто вбив продуктивність?». А вбивцею може бути будь-хто. Краще мати три маленькі тести, де кожен фіксує один ризик, ніж один величезний, який важко зрозуміти й ще важче виправити.

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

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