1. @DataJpaTest як основний формат
@DataJpaTest — це не «якась анотація для тестів», а радше дуже влучний компроміс між швидкістю та реалістичністю. Він піднімає лише JPA-частину застосунку (entity mappings, repositories, EntityManager, транзакції), але не тягне за собою web-layer, security, інтеграції та інші важкі речі. Для deep-dive по Hibernate це майже ідеальна «лабораторна установка»: швидко, досить реалістично й без зайвого шуму.
Уявіть, що @SpringBootTest — це запуск цілого торговельного центру заради того, щоб перевірити, чи зачиняються двері в підсобку. А @DataJpaTest — це запуск лише підсобки, але зі справжніми дверима, справжнім ключем і справжніми табличками «Не входити». Нам сьогодні потрібен саме другий варіант.
Щоб не плутатися, корисно тримати в голові порівняння форматів:
| Формат | Що піднімаємо | Для чого добре | За що платимо |
|---|---|---|---|
| Unit test (без Spring) | чистий Java-код | бізнес-правила, алгоритми | не бачить ORM/SQL/транзакції взагалі |
| @DataJpaTest | JPA slice: entity + repository + EntityManager + транзакції | мапінг, репозиторії, отримання даних, проєкції, soft delete, семантика flush | не піднімає сервіси й «весь світ» |
| @SpringBootTest | майже весь застосунок | end-to-end інтеграційні сценарії | повільніше, більше шуму й залежностей |
@DataJpaTest — це наш режим за замовчуванням для сценаріїв репозиторіїв, відображення, отримання даних, проєкцій і flush. Але щойно поведінка тримається на двох незалежних транзакціях — наприклад, у locking або під час гонки двох сервісних викликів, — одного JPA-зрізу вже замало. Там важливий не лише репозиторій, а оркестрація кількох unit of work, і такі тести зазвичай живуть на рівні сервісу в ширшому контексті.
Технічно @DataJpaTest робить дві ключові речі. По-перше, він створює вузький ApplicationContext, де є все потрібне для роботи JPA. По-друге, він зазвичай запускає тести в транзакції та відкочує її наприкінці (ми скоро це розберемо докладно). Саме тому цей формат чудово підходить для ORM-regression підходу: ви можете багаторазово створювати й змінювати дані та не боятися, що база «забрудниться» після кожного тесту.
Невелика схема, щоб візуально закріпити ідею тестового зрізу:
flowchart TD
A[Ваш тест] --> B["@DataJpaTest — контекст застосунку"]
B --> C[Відображення сутностей]
B --> D[Репозиторії Spring Data]
B --> E[EntityManager / сесія Hibernate]
B --> F[Менеджер транзакцій]
B --> G["Джерело даних + БД"]
B -. НЕ піднімаємо .-> H[Контролери / веб]
B -. НЕ піднімаємо .-> I[Безпека]
B -. НЕ піднімаємо .-> J[Зовнішні інтеграції]
B -. НЕ піднімаємо .-> K[Сервіси за замовчуванням]
І ось тут починаються типові запитання новачків: «Чому я не можу заавтовайрити ProductService у @DataJpaTest?». Відповідь проста: тому що ви попросили Spring підняти зріз, а не весь торт. Якщо сервіс вам потрібен для конкретного тесту — це або @SpringBootTest, або свідоме імпортування потрібного біна. Але з цим треба бути обережним, щоб не розмити зріз.
2. Транзакції та rollback у @DataJpaTest
Найкорисніше й водночас найпідступніше у @DataJpaTest — те, що кожен тестовий метод зазвичай виконується в транзакції, а після завершення тесту ця транзакція відкочується. Це дає чудову ізоляцію: тести не залежать один від одного й не залишають слідів. Але є нюанс: якщо транзакція відкочуватиметься, то commit не відбудеться, а отже багато речей у світі ORM не стаються самі, доки ви явно про це не попросите.
Уявіть, що ви репетируєте виставу, але щоразу наприкінці режисер плескає в долоні й каже: «Чудово, а тепер усі повертаємося на початкові позиції, ніби нічого не було». Це зручно для повторення, але якщо ви хочете перевірити, що двері справді замикаються на замок, вам доведеться доторкнутися до ручки просто під час репетиції, а не сподіватися, що «в кінці воно саме закриється».
У контексті JPA це означає: щоб побачити реальну поведінку БД (унікальні обмеження, FK, ефекти на кшталт trigger-like, soft delete SQL), вам часто потрібен flush(), тому що без нього Hibernate може тримати зміни лише в persistence context.
Дуже частий патерн тесту на обмеження схеми виглядає так:
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import static org.junit.jupiter.api.Assertions.assertThrows;
@Test
void duplicateSku_isRejectedOnFlush() {
// Зберігаємо перший товар із конкретним SKU
productRepository.save(product("SKU-DUP", "Mouse"));
// Важливо: примусово надсилаємо SQL у БД, щоб реально зайняти унікальний індекс
productRepository.flush();
// Другий товар із тим самим SKU має впасти саме на рівні БД
assertThrows(DataIntegrityViolationException.class, () -> {
productRepository.save(product("SKU-DUP", "Keyboard"));
// Ще один flush — щоб помилка унікальності проявилася зараз, а не колись потім
productRepository.flush();
});
}
Це якраз той випадок, який неможливо чесно зловити на рівні setSku(): база бачить дублікат лише тоді, коли SQL дійде до неї.
Зверніть увагу на «подвійний flush». Перший ми робимо, щоб перший рядок реально пішов у БД. Другий — щоб друга спроба теж дійшла до рівня бази й натрапила на унікальний індекс. Якщо ви не зробите flush, тест може «випадково пройти», тому що фактичної перевірки унікальності на боці бази ще не відбулося.
Тепер ще один нюанс: rollback — це не магічна кнопка «почистити все», а просто відкат конкретної транзакції. Якщо ви в тесті навіщось увімкнете @Commit або вимкнете rollback, то раптово отримаєте «брудну» БД і дуже цікавий атракціон під назвою «чому тест падає лише після третього запуску».
3. Реалістична БД: PostgreSQL замість in-memory
У звичайних навчальних проєктах часто беруть H2, бо так простіше. І так, простіше. Настільки простіше, що ви ризикуєте вивчити не Hibernate і PostgreSQL, а «Hibernate і чарівний світ бази, де майже все пробачають». Для deep-dive курсу це погана угода: ми хочемо бачити поведінку, максимально близьку до реальності, інакше ми фіксуватимемо тестами не ті баги й не ті рішення.
У in-memory-баз є типові «підміни реальності», які особливо критичні для ORM:
| Область | Що часто «згладжує» H2 | Чим це небезпечно в Hibernate deep-dive |
|---|---|---|
| Діалект SQL | деякі запити спрацьовують випадково | у проді PostgreSQL може поводитися інакше |
| Типи даних | дати/час, precision/scale, json-подібні речі | отримуємо хибні успіхи або хибні провали |
| Обмеження | інколи обмеження та індекси працюють «не так» | тест не ловить справжню проблему схеми |
| Locking | блокування й timeouts не схожі на PostgreSQL | locking-сценарії стають декоративними |
| Плани виконання | EXPLAIN і індекси — не ті | мислити в термінах продуктивності не виходить |
Навіть якщо зараз вам здається: «мені б просто перевірити репозиторій», — пам’ятайте: наш курс не про «репозиторій працює», а про передбачувану поведінку середовища виконання Hibernate. Ми хочемо, щоб тести були корисні як мікроскоп: показували, де Hibernate робить зайве, де він обманює нас persistence contextʼом, де спливають обмеження схеми і де поведінка залежить від справжньої бази.
Тому правило дня дуже просте: якщо ми пишемо ORM-regression тести, то база має бути наближеною до бойової. Для нашого курсу це PostgreSQL.
4. @DataJpaTest + PostgreSQL без підміни БД
За замовчуванням @DataJpaTest намагається бути «зручним» і може підмінити вам DataSource на embedded database, якщо вона доступна. Це корисно для більшості проєктів, але для нас — шкідлива магія. Тому ми зазвичай робимо дві речі: явно забороняємо підміну DataSource і явно вмикаємо тестовий профіль із налаштуваннями PostgreSQL.
На рівні анотацій тесту це часто виглядає так:
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.test.context.ActiveProfiles;
@ActiveProfiles("test") // Активуємо тестовий профіль із налаштуваннями datasource/flyway/jpa
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // Забороняємо автозаміну на embedded БД
@DataJpaTest
class ProductRepositoryTest { }
Тут важливо прочитати це як людську угоду зі Spring: «Дорогий Spring, будь ласка, не намагайся “допомогти” мені, підміняючи базу на H2. Я дорослий. Я хочу PostgreSQL. Так, я впевнений».
Далі нам потрібен application-test.yml (або .properties) у src/test/resources, щоб тести знали, куди підключатися. Мінімальний ескіз:
spring:
datasource:
# У тестах підключаємося до реального PostgreSQL, а не до in-memory-бази
url: jdbc:postgresql://localhost:5432/commerce_test
username: commerce
password: commerce
У навчальному стенді зазвичай цей PostgreSQL підіймається через Docker Compose (як і весь застосунок). І тут добрий тон — мати окрему базу для тестів, наприклад commerce_test, щоб випадково не стерти собі дані розробки й не ловити «чому тести видалили мені все, що я вчора робив руками».
Якщо ви запускаєте тести й бачите, що вони все одно намагаються стартувати H2, майже завжди причина банальна: залежність H2 десь затесалася в classpath (іноді «допоміг» шаблон IDE або стара залежність Gradle). У deep-dive-проєкті краще тримати classpath суворим: PostgreSQL — значить PostgreSQL.
5. Flyway у тестах: схема як у застосунку
Якщо ви хочете тестувати шар даних по-справжньому, то тестувати потрібно не лише Java-анотації, а й схему, за якою реально працює застосунок. У нашому проєкті схемою керує лише Flyway, і це правило має зберігатися в тестах. Не «Hibernate сам створить таблиці», не ddl-auto=create-drop, а саме міграції.
Чому це важливо? Тому що в ORM-баги часто вплетена схема: унікальні обмеження, FK, індекси, nullable/не-nullable поля, довжини колонок — усе це частина контракту. Якщо тести піднімають «схему мрії», а прод живе на реальній схемі, ви перевіряєте не те.
Мінімальне налаштування тестового профілю, яке допомагає тримати дисципліну, зазвичай містить ddl-auto=validate. Воно не створює таблиці, а перевіряє: «а схема взагалі збігається з тим, що ви замапили?»:
spring:
jpa:
hibernate:
# Важливо: не створюємо схему «з голови», а валідуюємо мапінг проти схеми з міграцій
ddl-auto: validate
flyway:
# Важливо: міграції мають застосовуватися і в тестах, інакше тестуємо не реальну схему
enabled: true
Так, міграції в тестах можуть бути трохи повільніші, ніж «порожня H2 і create-drop». Але це усвідомлена ціна за те, що тести стають надійними доказами, а не «приблизно перевірили, приблизно працює».
Якщо в якийсь момент ви зловите помилку мапінгу, яку бачить лише PostgreSQL+Flyway, це буде неприємно рівно один раз. Зате далі ви почнете вірити своїм тестам — а це в persistence-шарі безцінно.
6. Фікстури: мінімум, детермінізм і сенс
Фікстура — це не «залити в базу побільше всього, раптом знадобиться». Фікстура — це мінімальний набір даних, який потрібен, щоб відтворити конкретний сценарій. Ми пишемо не енциклопедію даних, а маленьку сценку: «є товар із таким SKU», «є замовлення з двома позиціями», «є залишок, який конфліктує за версією». І все. Чим менше у фікстурі зайвого, тим легше зрозуміти тест і тим менше шансів, що він почне ламатися через випадкові залежності.
Є два основні підходи до фікстур у data-тестах, і обидва корисні, просто для різного:
| Підхід | Як виглядає | Коли зручно | Що пам’ятати |
|---|---|---|---|
| Створювати через репозиторії | repository.save(...) | коли важливий ORM-шлях: каскади, відображення, flush | не забувати flush() і clear() |
| SQL фікстури | @Sql("/fixtures/...sql") | коли важливі точні ID або хитрі умови схеми | SQL стає частиною тесту й потребує дисципліни |
Для курсу Hibernate deep-dive частіше приємніше створювати фікстуру через репозиторій, тому що ми хочемо «відчути ORM», а не обходити його. Але іноді SQL чесніший, особливо якщо ви хочете вставити рядок із конкретним id=1 і перевірити поведінку getReferenceById(1) на заздалегідь відомому значенні.
Щоб тести не перетворювалися на нескінченний ручний конструктор сутностей, корисно мати маленькі helper-методи. Наприклад, для Product:
private Product product(String sku, String name) {
// Простий і прозорий helper: лише те, що потрібно для сценарію
Product p = new Product();
p.setSku(sku);
p.setName(name);
return p;
}
Це всього 6 рядків, але вони заощаджують вам десятки рядків шуму в кожному тесті. Головне правило тут просте: helper має залишатися прозорим. Якщо helper починає створювати Product + ProductDetails + категорії + ще половину домену «про всяк випадок», — це вже не helper, а міні-мікросервіс із виробництва тестової плутанини.
Окремо про детермінізм. Значення мають бути «читабельними»: SKU-DUP-1, ORDER-TEST-1, customer@example.com. Не тому, що компʼютеру так легше, а тому, що вам так легше читати падіння тесту. Логи ORM і SQL — це ваш інструмент, і фіксовані значення допомагають швидко побачити, «який саме рядок» бере участь у сценарії.
7. flush() і clear(): база, а не пам’ять
Зараз буде момент, який ламає інтуїцію багатьом початківцям. Усередині транзакції Hibernate тримає сутності в persistence context (first-level cache). Це означає, що ви можете «прочитати» сутність і отримати її не з бази, а з пам’яті — і тест буде щасливий, але ви так і не доведете, що база справді оновилася так, як ви думали.
Тому в data-тестах є класичний прийом: flush() + clear() + reread. Flush змушує Hibernate надіслати SQL у базу, clear викидає managed-об’єкти з контексту, reread знову читає з бази.
У мінімальному вигляді це виглядає так:
// 1) Зберігаємо зміни в persistence context
productRepository.save(product);
// 2) Примусово надсилаємо SQL у БД (інакше зміни можуть залишитися лише в пам’яті)
productRepository.flush();
// 3) Очищаємо persistence context, щоб наступне читання було саме з БД
entityManager.clear();
// 4) Перечитуємо сутність, доводячи реальний стан БД
Product reloaded = productRepository.findById(product.getId()).orElseThrow();
Це чотири рядки, але вони перетворюють тест із «я подивився на об’єкт» на «я довів поведінку бази». У deep-dive курсі це майже завжди те, що нам потрібно.
Якщо ви ловите себе на думці «та навіщо цей clear, і так же працює», згадайте мету: ми пишемо не тест «мені здається, збереглося», а тест «я впевнений, що SQL уже пішов, і я читаю саме з БД». У persistence-layer це принципова різниця.
8. Приклади @DataJpaTest у Commerce Lab
Давайте зберемо все у невеликі приклади на нашому домені. Вони навмисно короткі: ідея не в тому, щоб написати ідеальну бібліотеку тестів, а в тому, щоб побачити правильні базові кроки.
Зберегли товар і перечитали з PostgreSQL
Цей тест корисний як «димовий»: він доводить, що репозиторій, транзакція і міграції працюють разом, а ми читаємо справді з бази.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
@Test
void savesAndReloadsProduct() {
// Arrange: створюємо мінімальну сутність для сценарію
Product p = product("SKU-TEST-1", "Mouse");
// Act: зберігаємо (це ще може бути лише «в пам’яті», доки не зробимо flush)
productRepository.save(p);
// Важливо: надсилаємо SQL у БД в межах транзакції тесту
productRepository.flush();
// Важливо: прибираємо сутність із persistence context, щоб читання було з БД, а не з кешу
entityManager.clear();
// Assert: перечитуємо з БД і перевіряємо, що дані справді збереглися
assertEquals("Mouse", productRepository.findById(p.getId()).orElseThrow().getName());
}
Зверніть увагу: ми не покладаємося на те, що «в кінці тесту буде commit». Його не буде — буде rollback. Тому flush — свідомий крок.
Унікальний sku: обмеження схеми
У домені Product.sku має бути унікальним. Це частина контракту, і нам важливо, щоб тест ловив порушення не на рівні «десь у коді перевірили», а на рівні реального обмеження бази.
import org.junit.jupiter.api.Test;
import org.springframework.dao.DataIntegrityViolationException;
import static org.junit.jupiter.api.Assertions.assertThrows;
@Test
void rejectsDuplicateSku() {
// Перший товар — фіксуємо в БД, щоб унікальний індекс реально зайнявся
productRepository.saveAndFlush(product("SKU-DUP-1", "Mouse"));
// Другий товар із тим самим SKU має впасти на рівні БД
assertThrows(DataIntegrityViolationException.class, () -> {
// saveAndFlush: спеціально змушуємо помилку проявитися в межах цього виклику
productRepository.saveAndFlush(product("SKU-DUP-1", "Keyboard"));
});
}
Тут saveAndFlush() — не «правильніший save», а просто зручний спосіб змусити SQL реально піти й отримати від БД справжню помилку.
Soft delete і видимість після clear()
Якщо Product у нас soft-deleted (Hibernate 7.2 @SoftDelete), то видалення — це не фізичний DELETE, а зміна прапорця або стану. Перевіряти це «всередині persistence context» небезпечно: ви можете бачити об’єкт у пам’яті й не зрозуміти, як він буде видимий під час нового читання.
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Test
void softDeletedProduct_isHiddenAfterClear() {
// Створюємо і зберігаємо сутність так, щоб вона точно була в БД
Product p = productRepository.saveAndFlush(product("SKU-SD-1", "ToBeDeleted"));
// Видаляємо через EntityManager: у soft delete це буде UPDATE/flag, а не фізичний DELETE
entityManager.remove(p);
// Важливо: надсилаємо SQL у БД, щоб «видалення» справді відбулося
entityManager.flush();
// Важливо: очищаємо persistence context, щоб наступна перевірка читала з БД
entityManager.clear();
// Перевіряємо, що сутність більше не видна через репозиторій після soft delete
assertTrue(productRepository.findById(p.getId()).isEmpty());
}
Зверніть увагу, скільки сенсу ховається в цих кількох рядках. Ми робимо delete, змушуємо SQL піти (flush), викидаємо контекст (clear) і перевіряємо видимість через новий запит. Це «правильна» модель тесту для soft delete: не «я видалив об’єкт», а «я підтверджую правило видимості даних після видалення».
9. Типові помилки в @DataJpaTest
Помилка №1: вважати @DataJpaTest «звичайним тестом репозиторію» і ігнорувати транзакцію.
Новачок часто думає, що save() одразу пише в базу й можна тут же перевіряти обмеження, як у справжньому застосунку. Але в @DataJpaTest наприкінці буде rollback, а commit — ні. Тому без flush() багато речей не проявляються. Правильна звичка така: коли тест перевіряє базу, він має явно синхронізувати контекст із БД через flush() або saveAndFlush().
Помилка №2: перевіряти результат через той самий managed-об’єкт, не роблячи clear().
Це один із найпідступніших самообманів в ORM-тестах. Ви змінили поле, зробили save(), потім подивилися entity.getName() — і тест пройшов. Тільки ви перевірили не базу, а пам’ять. У deep-dive-підході майже завжди потрібне перечитування після clear(), щоб довести реальний стан БД.
Помилка №3: величезні фікстури «на всі випадки життя».
Коли тестові дані готуються один раз великим seedʼом, тести стають крихкими й незрозумілими. Падіння перетворюється на розслідування: «а який із 200 рядків вплинув на результат?». Набагато спокійніше жити, коли один тест приносить із собою рівно той мінімум даних, який йому потрібен, і ці дані названі за сенсом сценарію.
Помилка №4: використовувати H2 “для швидкості”, а потім дивуватися, що в PostgreSQL усе інакше.
Швидкість важлива, але в deep-dive курсі ми купуємо не швидкість, а точність. H2 може дати хибне відчуття стабільності й пропустити реальні проблеми схеми, типів, діалекту або locking. Якщо мета — regression suite для persistence layer, реалізм важливіший за «щоб бігало за 0,2 секунди».
Помилка №5: дозволити @DataJpaTest підмінити DataSource, не помітивши цього.
Якщо забути @AutoConfigureTestDatabase(replace = NONE), можна отримати ситуацію, де частина тестів раптово працює на embedded-базі. Це особливо неприємно, тому що зовні все «зелене». Тому дисципліна проста: ми явно забороняємо replace і явно підключаємо application-test.yml під PostgreSQL.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ