1. H2-пастка: зелений тест — ще не гарантія, що все гаразд
Якщо ви тільки починаєте писати тести для рівня доступу до даних, дуже легко потрапити в психологічну пастку: «тест зелений — значить, я молодець, база задоволена, Hibernate щасливий». Насправді зелений колір означає лише одне: у цьому оточенні, з цією базою та з цими налаштуваннями сценарій успішно пройшов. А тепер зверніть увагу: якщо база «в тесті» і база «в продакшені» — різні, то ви фактично перевірили інший застосунок.
Давайте введемо термін, який у плані заняття названо прямо: H2-пастка — це ситуація, коли тест проходить на H2 (зазвичай in-memory), але під час запуску на PostgreSQL ламається або міграція, або native query, або обмеження, або конкурентний сценарій. Причому ламається не «тому що у вас руки криві», а тому що H2 — це не «легкий режим PostgreSQL», а окрема СУБД зі своїми правилами.
Уявіть, що ви тренуєте футбольну команду в спортзалі на паркеті. Усе красиво: передачі швидкі, мʼяч котиться ідеально, люди не падають. А потім команда виходить на мокре поле. Формально гра та сама, але реальність інша: зчеплення інше, поведінка мʼяча інша, і раптом з’ясовується, що «ідеальний стиль» не працює. H2 — це такий собі «зал на паркеті». Він корисний для відпрацювання техніки, але він не зобов’язаний збігатися з «мокрим полем PostgreSQL».
У нашому курсі shop-data-jpa PostgreSQL — джерело істини для проєкту. Ми проєктували схему, міграції, обмеження, optimistic locking і сценарії placeOrder() / cancelOrder() під PostgreSQL. Тому тести, які мають давати надійний сигнал, повинні бути принаймні близькі до реальності PostgreSQL. H2 може бути прискорювачем зворотного звʼязку, але не повинна бути єдиним «суддею», який вирішує, чи правильний у вас рівень доступу до даних.
2. Чим H2 зручна в тестах
Перш ніж ми почнемо лаяти H2 (ввічливо, без токсичності), важливо визнати: H2 популярна не через змову між розробниками, а тому що вона справді зручна. Вона швидко запускається, часто не потребує Docker, окремих портів чи окремого процесу, і дає відчуття «я зараз за 5 секунд перевірю репозиторій і піду пити чай». І це чудове відчуття, якщо ви розумієте межі.
Найчастіше H2 з’являється в проєкті двома способами. Перший — ви явно додали залежність com.h2database:h2 у testImplementation, і Spring Boot починає з радістю піднімати вбудовану базу для тестів. Другий — ви використовуєте @DataJpaTest, і він за замовчуванням любить підміняти ваш DataSource на вбудований, якщо такий доступний. Новачок у цей момент може щиро думати: «я ж тестую JPA, значить я тестую свою базу даних». А насправді він тестує JPA+Hibernate поверх H2, і діалект та поведінка вже не ті.
Сильна сторона H2 в тому, що вона дає швидкий і дешевий сигнал. Якщо ви перевіряєте, що ProductRepository.findByStatus(...) повертає потрібні рядки, що @ManyToOne працює, що orphanRemoval справді видаляє дочірню сутність, то H2 часто досить хороша як перевірка механіки. Особливо якщо задача тесту не залежить від специфіки PostgreSQL і не перевіряє деталі SQL-діалекту.
Але у H2 є слабка сторона: вона створює хибне відчуття сумісності. У голові з’являється небезпечна формула: «Раз воно працює на H2, значить і на PostgreSQL буде». Це приблизно як сказати: «Раз код запускається на моєму ноутбуці, значить він запускатиметься на сервері». Іноді — так. Гарантії немає.
Ще одна тонкість: багато хто намагається «вилікувати» відмінності, вмикаючи H2 у режимі сумісності з PostgreSQL (MODE=PostgreSQL). Це справді може прибрати частину проблем, особливо синтаксичних, але не перетворює H2 на PostgreSQL. Це радше підкручений акцент, а не зміна мови. Деякі розбіжності зникнуть, деякі залишаться, а деякі навіть стануть менш помітними — що, чесно кажучи, ще небезпечніше: тести виглядають правдоподібно, але перевіряють не те.
3. Яка база реально в тесті
Є дуже проста навичка, яка різко підвищує якість тестування: у будь-якій незрозумілій ситуації спочатку з’ясовуємо, яка база реально працює. Це звучить банально, але на практиці економить години життя. Бо сперечатися з тестом «чому на CI падає, а локально ні» безглуздо, якщо у вас у двох місцях різні бази даних.
У сервісному тесті (@SpringBootTest) ви можете отримати DataSource і прочитати метадані з’єднання. Це не хакінг, а нормальна діагностика. У момент, коли ви побачите H2 або PostgreSQL у виводі, половина магії випарується.
Короткий діагностичний тест:
import java.sql.Connection;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class DatabaseProductNameTest {
@Autowired
DataSource dataSource; // Джерело з’єднань, яке реально підняв Spring у тесті
@Test
void printsDatabaseName() throws Exception {
// Відкриваємо реальне JDBC-з’єднання з контексту застосунку
try (Connection c = dataSource.getConnection()) {
// Швидка діагностика: "PostgreSQL" або "H2"
System.out.println(c.getMetaData().getDatabaseProductName()); // Приклад: PostgreSQL
}
}
}
Так, це println у тесті. Так, в ідеальному світі ми не друкуємо зайвого. Але як тимчасовий «ліхтарик» це працює чудово. І за вимогами курсу, якщо вже ми друкуємо — ми чесно підписуємо приблизний вивід поруч.
Іноді корисно подивитися не лише на «назву продукту», а й на JDBC URL, тому що він часто одразу показує, який профіль і яка конфігурація застосувалися:
import java.sql.Connection;
import javax.sql.DataSource;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class DatabaseUrlTest {
@Autowired
DataSource dataSource; // Важливо: це той DataSource, який реально використовується в тесті
@Test
void printsJdbcUrl() throws Exception {
// URL зазвичай миттєво показує правду: mem H2 або реальний PostgreSQL
try (Connection c = dataSource.getConnection()) {
System.out.println(c.getMetaData().getURL()); // Приклад: jdbc:postgresql://... або jdbc:h2:mem:...
}
}
}
Навіщо це потрібно саме нам, у shop-data-jpa? Тому що проєкт уже досить близький до бойового: у нас є Flyway, обмеження, optimistic locking і сценарії, чутливі до реальної поведінки PostgreSQL. Якщо тести випадково з’їхали на H2 (або навпаки), ви можете отримати «зелений» набір, який бреше, або «червоний» набір, який падає не через код, а через несумісність оточення.
І ще одна практична думка: дуже погана звичка — писати тести так, ніби база все зробить сама. Нормальна звичка — знати, який DataSource ви підняли. Упевненість у тесті починається не з assertEquals, а з розуміння оточення.
4. Діалект і SQL
Коли ми використовуємо Spring Data JPA, виникає ілюзія: «ми ж пишемо на Java, значить база неважлива». Ця ілюзія тримається рівно до першого native query або до першої PostgreSQL-специфічної міграції. Після цього база раптом стає дуже важливою, тому що ви починаєте говорити з нею її рідною мовою.
Загалом, відмінності між H2 та PostgreSQL особливо помітні у трьох місцях: у DDL-міграціях, у native SQL-запитах і в поведінці ідентифікаторів і регістру (доволі часте джерело болю).
Найкласичніший приклад — регістр імен таблиць та колонок. PostgreSQL за замовчуванням приводить неквотовані ідентифікатори до нижнього регістру. H2 за замовчуванням любить верхній. Це означає, що міграція create table stock_item (...) у PostgreSQL створить stock_item, а в H2 (у деяких режимах) може виявитися STOCK_ITEM. І ваш native query from stock_item раптом почне «не бачити таблицю». Ви дивитиметеся на екран і думатимете: «Я ж щойно її створив, ось вона!». А вона є — тільки в іншому регістрі.
Ось такий метод репозиторію виглядає невинно:
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface StockItemRepository extends JpaRepository<StockItem, Long> {
// Важливо: це native SQL, тобто прямий контракт із конкретною СУБД та її діалектом
@Query(value = """
select *
from stock_item
where available_quantity < :limit
""", nativeQuery = true)
List<StockItem> findLowStock(int limit); // limit — параметр native-запиту (:limit)
}
На PostgreSQL це, швидше за все, працюватиме так, як ви очікуєте. На H2 — може працювати, а може й ні, залежно від режиму та налаштувань. І ось це «може» — і є проблема: тест стає залежним від того, наскільки ретельно ви підлаштували H2 під поведінку PostgreSQL.
Щоб не бути голослівними, давайте зведемо типові «SQL-розломи» у компактну таблицю. Це не список «усіх відмінностей на світі», а лише карта місцевості, де найчастіше трапляються сюрпризи.
| Зона | PostgreSQL | H2 | Чому це пастка в тестах |
|---|---|---|---|
| Імена таблиць/колонок | неквотовані імена → нижній регістр | часто → верхній регістр (за замовчуванням) | native query може «не бачити» таблицю, хоча вона є |
| DDL зі специфікою PostgreSQL | bigserial, timestamptz, jsonb, частковий індекс | може не підтримуватися | міграції починають «ламатися» або потребують окремого варіанта |
| Функції/оператори | багато специфічних функцій і розширень | інший набір функцій | запит «працює на H2» не означає «працює на Postgres» |
| INSERT ... RETURNING | підтримується | зазвичай — ні або інакше | репозиторій або міграція може залежати від RETURNING |
| Поведінка планувальника запитів | одна реальність | інша реальність | тести продуктивності на H2 майже завжди — самообман |
Найболючіший висновок для навчального проєкту: щойно у вас з’являється Flyway як джерело істини, ви починаєте цінувати те, що міграції справді відтворювані. Якщо ваш тестовий контур — H2, а міграції написані під PostgreSQL, у вас з’являється вибір із трьох поганих варіантів: ви або спрощуєте міграції до «спільного знаменника», або підтримуєте дві версії міграцій (одна під PostgreSQL, інша під H2), або вимикаєте Flyway у тестах. І кожен із цих варіантів зменшує цінність тестів як джерела істини.
Тому логіка курсу проста: H2 може бути корисною для швидких перевірок репозиторіїв, але все, що зав’язано на міграції та реальний SQL-діалект, потребує оточення, подібного до PostgreSQL. Інакше тест починає перевіряти «схожу реальність», а нам потрібна справжня.
5. Схема, типи і генерація id
Навіть якщо у вас немає жодного native query (хоча в нашому проєкті він є — звіт про низький залишок), відмінності можуть проявитися на рівні схеми та типів. І це неприємний клас проблем, тому що він виглядає як «та у мене ж усе однакове: String, BigDecimal, LocalDateTime…». Але однакові Java-типи не гарантують однакової SQL-реальності.
Візьмемо дуже наш приклад: гроші. У міні-магазині ціна товару (Product.price) і сума замовлення (CustomerOrder.totalAmount) живуть як BigDecimal. У PostgreSQL ви зазвичай зберігаєте це як numeric(19, 2) або близький варіант. H2 теж уміє decimal, але нюанси округлення, порівняння, приведення типів і навіть поведінки DDL можуть відрізнятися. У підсумку тест може пройти, тому що H2 «пробачила» якусь деталь (наприклад, scale), а PostgreSQL потім почне поводитися інакше, і ви отримаєте неочікувані розбіжності в обчисленнях або збереженні.
Тепер приклад, який особливо важливий для нас як для JPA-проєкту: генерація id. У PostgreSQL у реальних проєктах часто використовують sequences. У курсі ми обговорювали стратегії SEQUENCE і IDENTITY, і на PostgreSQL це не теорія: Hibernate реально генерує SQL під вибрану стратегію. H2 теж може емулявати sequences/identity, але поведінка і «дрібні деталі» (назви, порядок викликів, особливості DDL) можуть відрізнятися.
Якщо ви хочете побачити, наскільки база «реальна», іноді корисно не лише дивитися на Java-код, а й на міграцію. Навіть невеликий шматок DDL уже показує, наскільки ви прив’язані до реальності PostgreSQL:
create table product (
id bigserial primary key, -- Postgres-специфіка: bigserial створює sequence і дефолт для id
sku varchar(64) not null unique, -- Обмеження унікальності має працювати однаково, але деталі помилок можуть відрізнятися
price numeric(19, 2) not null -- Гроші: важливі точність і scale
);
Ці три рядки виглядають простими, але в них заховано все: bigserial (PostgreSQL-специфіка), unique як обмеження унікальності, numeric(19,2) (точність грошей). На H2 ви або переписуєте це, або вмикаєте режим сумісності, або отримуєте падіння міграції. І в кожному випадку ви вже живете у світі «в тестах одне, у PostgreSQL — інше».
Ще один важливий кут — обмеження (обмеження). У курсі ми багато разів говорили: Bean Validation не замінює обмеження в БД. Відповідно, тести, які перевіряють DataIntegrityViolationException (наприклад, duplicate sku), мають бути максимально близькі до реальної бази. H2 може поводитися інакше в дрібницях: десь обмеження спрацює в інший момент, десь повідомлення буде інше, десь порядок перевірок відрізняється. І якщо ви будуєте тести занадто «прив’язані до тексту помилки», ви отримаєте крихку конструкцію.
Тут є практичне правило: якщо ви тестуєте бізнесову реакцію на обмеження, перевіряйте семантику, а не «рядок у повідомленні». Семантика — це що duplicate sku не проходить і призводить до зрозумілої помилки на сервісному рівні. Рядок повідомлення — це випадкова деталь конкретної СУБД і конкретної версії драйвера.
І так, я знаю, що хочеться написати assertTrue(e.getMessage().contains("SKU")). Це дуже людське бажання. Просто воно робить тест таким же надійним, як прогноз погоди за болем у коліні.
6. Транзакції і конкурентність
Транзакції — одна з найнебезпечніших зон для «хибної впевненості». Бо і H2, і PostgreSQL «підтримують транзакції», але підтримувати транзакції — це як «уміти водити машину»: формально і так, і так, але якість керування може відрізнятися драматично.
Для нашого проєкту це особливо важливо через дві теми курсу: rollback і optimistic locking. Rollback ми вже перевіряємо сервісними тестами: якщо placeOrder() падає посередині через нестачу залишку, замовлення не повинно частково зберегтися. Optimistic locking (@Version у StockItem) захищає від lost update і має призводити до конфлікту версії, якщо другий апдейт іде зі застарілого стану.
На H2 такі сценарії іноді «не відтворюються» так, як на PostgreSQL, або відтворюються інакше. Причина проста: інша реалізація блокувань, інша модель конкурентного доступу, інша поведінка ізоляції. У підсумку ви можете написати тест, який зелений на H2, але в реальності він не довів того, що ви думали, ніби він довів.
Типовий приклад у голові новачка виглядає так: «Я двічі викликав сервіс, значить я перевірив конкурентність». Але конкурентність — це не «два виклики», це два незалежні транзакційні контексти, які читають і записують один і той самий рядок. Якщо база поводиться інакше, ваш тест стає «псевдоконкурентним» і втрачає сенс. Він може перевіряти лише те, що ваш код кидає виняток (або не кидає), але не те, що база коректно відпрацювала версійний конфлікт.
Тут важливо мислити прагматично: H2 корисна, коли ми перевіряємо «форму мапінгу та базову механіку репозиторію». Але коли ми перевіряємо поведінку, яка залежить від реальної транзакційної моделі та реальної схеми PostgreSQL, нам потрібен контур, де PostgreSQL — не «ідея», а реально працююча база.
І ще один момент, який часто недооцінюють: Flyway + транзакції. На PostgreSQL міграції, обмеження і транзакції живуть в одному світі. На H2 цей світ може бути схожий, але не зобов’язаний. А ми зараз якраз у фіналі курсу, де хочемо, щоб тести були «передбачуваним доказом», а не «заспокійливою листівкою».
7. Акуратна робота з H2
Після всього сказаного легко піти в крайність: «Давайте викинемо H2 і ніколи про неї не згадуватимемо». Це теж не найзріліший висновок. Більш зрілий висновок звучить так: H2 — це інструмент швидкого сигналу, а PostgreSQL — це інструмент істини.
У нашому проєкті shop-data-jpa зручно мислити тестовий контур як два шари. Перший шар — швидкі тести, які перевіряють, що мапінги живі, репозиторії не зламані, derived queries і прості JPQL-запити працюють. Для такого шару H2 може бути виправдана, бо вона дає швидкість і дешевий зворотний зв’язок. Другий шар — тести, які мають підтверджувати бойову поведінку: міграції Flyway застосовуються, обмеження реально працюють, native queries коректні, optimistic locking відтворюється. Цей шар має виконуватися в оточенні, максимально близькому до PostgreSQL.
Дуже важливо не змішувати ці два шари в голові. Якщо ви свідомо використовуєте H2 для швидких перевірок, ви не повинні робити з цього висновок: «все, рівень доступу до даних готовий». Це просто означає: «схоже, ми не зламали базову механіку». А ось «готовий» починається там, де тести запускаються на базі, подібній до PostgreSQL, і підтверджують критичні сценарії.
Ще одна корисна дисципліна — називати речі чесно. Якщо у вас є тести, які завідомо працюють на H2, можна явно позначати це в конфігурації чи профілях або хоча б у логіці найменування тестів. Це не бюрократія, це турбота про майбутнього себе. Бо через місяць ви забудете, чому тест «чомусь» зелений, а баг у PostgreSQL усе одно існує.
І так, закономірне питання: «Гаразд, а як підняти PostgreSQL прямо в тестах так, щоб це було ізольовано, відтворювано і не залежало від моєї локальної бази?» — це якраз наступний інженерний крок. Тут ми лише фіксуємо проблему і критерій якості: H2 не замінює PostgreSQL, тому що це не PostgreSQL. А щоб тестувати PostgreSQL-правду, треба тестувати на оточенні, подібному до PostgreSQL.
8. Типові помилки під час використання H2 у тестах рівня доступу до даних
Помилка №1: вважати, що H2 — це «легкий режим PostgreSQL».
H2 — окрема СУБД. Навіть якщо ви ввімкнули режим сумісності, це все одно не робить її PostgreSQL. Якщо тест критичний до діалекту, міграцій і обмежень, H2 може дати красивий, швидкий і неправильний сигнал.
Помилка №2: не знати, яка база реально працює в тесті.
Дуже часта ситуація: студент думає, що тест іде на PostgreSQL з Docker Compose, а насправді @DataJpaTest підмінив DataSource на вбудовану H2. Лікується банально: іноді потрібно просто запитати DatabaseProductName і побачити правду на власні очі.
Помилка №3: тестувати native queries лише на H2 і робити висновок про PostgreSQL.
Native query — це прямий контракт із конкретною базою, схемою та діалектом. Якщо ви проганяєте native query на H2, ви перевіряєте H2. У кращому випадку ви перевіряєте «синтаксис схожий», але не «поведінку в PostgreSQL».
Помилка №4: зав’язуватися на текст повідомлень винятків і SQLState.
Повідомлення про помилки і коди стану — це область, де різні бази та драйвери можуть сильно відрізнятися. У тестах рівня доступу до даних краще перевіряти тип винятку і бізнесову семантику реакції сервісу, а не «який рядок написала база».
Помилка №5: намагатися тримати два світи — окремі міграції під H2 і під PostgreSQL.
Це швидко перетворюється на міні-ад: ви правите міграцію для PostgreSQL, забуваєте поправити H2-версію, тести на H2 зелені, а справжня схема поїхала. У підсумку міграції перестають бути джерелом істини, а стають джерелом випадковостей.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ