JavaRush /Курси /Hibernate deep-dive /Регресійні тести: fetching, DTO, flush

Регресійні тести: fetching, DTO, flush

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

1. Регресійний тест в ORM: контракт

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

У звичайних застосунках регресія часто виглядає так: «раніше падало — тепер не падає». У persistence-layer усе цікавіше: у нас може бути правильний бізнес-результат, але неправильна ціна його отримання. Наприклад, список замовлень віддається коректно, але раптом робить 57 запитів замість 2. Або картка товару все ще працює, але знову тягне весь граф зв’язків, ніби це не бекофіс, а пилосос.

В ORM-регресійному тесті важливо, що контракт майже завжди подвійний. З одного боку, ви перевіряєте функціональний результат — дані правильні. З іншого — ви перевіряєте persistence-сигнал (завантаження, SQL, flush), який і є справжньою причиною більшості «чому в продакшені гальмує».

Нижче — зручна «карта місцевості», де видно, які сигнали ми фіксуємо в цій лекції:

Ризик (що може повернутися) Що ми хочемо зафіксувати Чим вимірюємо в тесті
N+1 / зайві secondary selects Запитів не стало більше, ніж очікуємо кількість запитів через Hibernate Statistics
Зісковзування з projection назад у entity-loading У read-сценарії entity взагалі не завантажуються entityLoadCount == 0 і тип результату — DTO/record
«Змінив у пам’яті, а в базі наче нічого не змінилося» (або навпаки) Момент, коли зміна стає видимою в БД, передбачуваний flush() + clear() + повторне читання, а іноді entityUpdateCount

Ключова думка: ORM-регресійний тест не повинен намагатися бути «універсальним тестом усього». Він має бути як хороший дорожній знак: короткий, конкретний і пояснює, де саме ви можете вилетіти в кювет.

2. Query count у fetching-контракті

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

При цьому важливо не потрапити в пастку: «зараз я заасерджу, що запитів рівно 1, і буду щасливий». А потім ви додасте у view одне поле, і стане 2 — і тест почне падати не тому, що ви зламали архітектуру, а тому, що життя складніше за одиницю. Тому ми зазвичай фіксуємо кількість запитів лише там, де вона справді є контрактом. Типовий приклад — сценарій, який уже ловив N+1 і був виправлений через JOIN FETCH, EntityGraph, batch fetching або projection.

Ще одна тонкість: щоб тест справді ловив N+1, ви маєте «помацати» ті поля та зв’язки, які використовує реальний сценарій. Якщо тест просто викликає метод репозиторію й нічого не читає з результату, ледачі зв’язки можуть не ініціалізуватися — і SQL просто не станеться. Тест вийде «оптимістичний», як людина, яка перевірила, що пожежна сигналізація є, але жодного разу не натиснула кнопку тесту.

Зручно уявити fetching-регресійний тест як невеликий конвеєр:

flowchart TD
    A[Підготували фікстуру] --> B[flush + clear]
    B --> C[Скинули статистику]
    C --> D[Виконали read-сценарій]
    D --> E[Помацали потрібні поля/зв'язки]
    E --> F[Перевірили дані]
    F --> G[Перевірили кількість запитів]

У цьому конвеєрі найчастіше забувають саме кроки «скинули статистику» і «помацали потрібні зв’язки». Саме вони відділяють тест, який ловить реальну регресію, від тесту, який «успішно проходить завжди».

3. Hibernate Statistics у @DataJpaTest

Щоб робити перевірки кількості запитів без магії та без зовнішніх бібліотек, нам достатньо Hibernate Statistics. Це вбудований механізм Hibernate, який уміє рахувати різні події: завантаження entity, кількість підготовлених statement’ів, кількість flush і так далі. Він не замінює SQL-лог, але для тесту Statistics зручніші: вони дають цифри, які можна асердити.

Нюанс у тому, що statistics мають бути ввімкнені. У звичайному застосунку ви часто тримаєте їх вимкненими, щоб не створювати шум і не додавати накладні витрати. У тестах, особливо регресійних, увімкнути їх — дуже розумний компроміс. У нашому Commerce Persistence Lab це якраз типовий інструмент lab support.

Нижче — мінімалістичний helper, який зручно тримати в пакеті com.example.commerce.labsupport. Він маленький, прозорий і не ховає механіку, тобто не робить «чорну скриньку», яку потім ніхто не розуміє.

package com.example.commerce.labsupport;

import jakarta.persistence.EntityManagerFactory;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.springframework.stereotype.Component;

@Component
public class HibernateStats {

    private final Statistics statistics;

    public HibernateStats(EntityManagerFactory emf) {
        // Дістаємо Hibernate Statistics з EntityManagerFactory, щоб вимірювати поведінку ORM у тестах
        this.statistics = emf.unwrap(SessionFactory.class).getStatistics();

        // У тестах розумно примусово ввімкнути statistics, щоб не залежати від профілів і налаштувань оточення
        this.statistics.setStatisticsEnabled(true);
    }

    public void clear() {
        // Скидаємо лічильники прямо перед вимірюваним сценарієм, щоб не асердити «шум» від фікстури
        statistics.clear();
    }

    public long statements() {
        // Кількість підготовлених SQL-операторів — зручний proxy для «скільки запитів пішло в БД»
        return statistics.getPrepareStatementCount();
    }

    public long entityLoads() {
        // Скільки entity було завантажено як managed entities (важливо для DTO/projection-регресій)
        return statistics.getEntityLoadCount();
    }

    public long entityUpdates() {
        // Скільки entity було оновлено (корисно для спостережуваності flush-cycle)
        return statistics.getEntityUpdateCount();
    }
}

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

А ось приклад того, як у @DataJpaTest гарантовано ввімкнути генерацію statistics через властивості, щоб не залежати від випадкової конфігурації профілів:

import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;

@DataJpaTest(properties = {
        // Увімкнути Hibernate Statistics у контексті тесту
        "spring.jpa.properties.hibernate.generate_statistics=true"
})
class StatsSmokeTest { }

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

4. Fetching-регресія: N+1 і замовлення

Регресійний fetching-тест найкраще писати на сценарії, який справді болів. У Commerce Persistence Lab класичний кандидат — читання списку замовлень для бекофісу, де ви показуєте номер замовлення, статус і e-mail клієнта. Це дуже життєва ситуація: розробник пише findAll(), потім у сервісі викликає order.getCustomer().getEmail(), і voilà — N+1.

Ми не будемо в цій лекції знову обговорювати, чим лікувати N+1 — це вже було в модулі про fetching. Але ми покажемо, як зафіксувати контракт «не більше одного запиту на вибірку замовлень із клієнтами» через тест. Як приклад припустімо, що в нас є query-репозиторій PurchaseOrderQueryRepository, який робить читання правильно, наприклад через JOIN FETCH.

package com.example.commerce.orders.query;

import com.example.commerce.orders.entity.PurchaseOrder;
import java.util.List;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;

public interface PurchaseOrderQueryRepository extends Repository<PurchaseOrder, Long> {

    @Query("""
           select po
           from PurchaseOrder po
           join fetch po.customer c
           order by po.id
           """) // JOIN FETCH фіксує контракт: customer підвантажується тим самим запитом, без N+1
    List<PurchaseOrder> findAllWithCustomer();
}

Тепер тест. Сенс тесту: ми запускаємо read-сценарій, робимо те саме, що зробила б view-модель, читаємо e-mail, і перевіряємо, що кількість SQL statement’ів не розпухла. Код нижче спеціально короткий; підготовку фікстури ми «виносимо за кадр» — у реальному проєкті це може бути маленький factory/helper.

import com.example.commerce.labsupport.HibernateStats;
import com.example.commerce.orders.query.PurchaseOrderQueryRepository;
import org.junit.jupiter.api.Test; 
import org.springframework.beans.factory.annotation.Autowired;

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

class OrderFetchingRegressionTest {

    @Autowired PurchaseOrderQueryRepository orderQuery;
    @Autowired HibernateStats stats;

    @Test
    void findAllWithCustomer_doesNotTriggerNPlusOne() {
        // Скидаємо statistics прямо перед вимірюванням, щоб не враховувати запити фікстури та ініціалізації
        stats.clear();

        // Виконуємо read-сценарій
        var orders = orderQuery.findAllWithCustomer();

        // «Тест-клік»: чіпаємо ледачий зв’язок так само, як це робить реальний use case
        orders.forEach(o -> o.getCustomer().getEmail());

        // Контракт: один SQL на список замовлень і клієнта, без додаткових догрузок
        assertEquals(1, stats.statements());
    }
}

Тут є два важливі «педантичні» моменти, які насправді роблять тест дорослим. По-перше, ми очищаємо statistics прямо перед вимірюванням, інакше туди потраплять запити від підготовки даних, і ви будете асердити шум. По-друге, ми явно читаємо getCustomer().getEmail(). Це наш «тест-клік»: якщо хтось прибере JOIN FETCH, Hibernate почне ліниво підвантажувати customer окремими запитами, і query count виросте.

Якщо вам здається, що assertEquals(1, ...) — це занадто жорстко, ви мислите правильно. У реальності іноді розумніше фіксувати верхню межу, наприклад «не більше 2». Але починати корисно з максимально зрозумілого контракту на одному конкретному read-сценарії, інакше тест перетворюється на філософію, а філософія не падає в CI.

5. Projection-регресія: DTO без entity-loading

З проєкціями зазвичай трапляється класична історія. Ви акуратно зробили DTO-проєкцію для списку товарів, усе стало швидко й красиво. Потім хтось, можливо ви через місяць у стані «я точно пам’ятаю, що роблю», вирішує: «А давайте повернемо entity, так зручніше, я ж лише одне поле додам». І непомітно для всіх у проєкт знову приїжджає entity-loading з усіма бонусами: зайві колонки, випадкові lazy loads, накладні витрати dirty checking, і знову непередбачувана ціна.

Тому projection-regression тест — це не про «дані прийшли». Він про те, що ми не завантажили жодної entity, а отримали рівно той read-model, який задумали.

Припустімо, у каталозі є record, який ми використовуємо як рядок таблиці:

package com.example.commerce.catalog.dto;

// Модель читання для списку/таблиці: тут не повинно бути ледачих зв'язків і managed-сутностей
public record ProductRow(Long id, String sku, String name) { }

І query-репозиторій, який повертає саме цю модель читання:

package com.example.commerce.catalog.query;

import com.example.commerce.catalog.dto.ProductRow;
import com.example.commerce.catalog.entity.Product;
import java.util.Optional;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.Repository;
import org.springframework.data.repository.query.Param;

public interface ProductQueryRepository extends Repository<Product, Long> {

    @Query("""
           select new com.example.commerce.catalog.dto.ProductRow(p.id, p.sku, p.name)
           from Product p
           where p.id = :id
           """) // Constructor projection: маємо отримати DTO, а не managed entity
    Optional<ProductRow> findRowById(@Param("id") Long id);
}

Тепер тест. І ось тут красиво працює статистика entity load count: якщо ми робимо constructor projection, Hibernate не повинен завантажувати entity як managed objects. Отже, entityLoadCount можна очікувати нульовим. Додамо кілька методів у HibernateStats — так, трохи розширимо helper, але він усе ще прозорий і короткий:

import org.hibernate.stat.Statistics;

public long entityLoads() {
    // Скільки entity було завантажено як managed entities (для DTO/projection очікуємо 0)
    return statistics.getEntityLoadCount();
}

І тест:

import com.example.commerce.catalog.query.ProductQueryRepository;
import com.example.commerce.labsupport.HibernateStats;
import org.junit.jupiter.api.Test; 
import org.springframework.beans.factory.annotation.Autowired;

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

class ProductProjectionRegressionTest {

    @Autowired ProductQueryRepository productQuery;
    @Autowired HibernateStats stats;

    @Test
    void findRowById_loadsNoEntities() {
        // Важливо: скинути statistics, інакше в лічильники потрапить «шум»
        stats.clear();

        // Виконуємо read-сценарій, який має повернути DTO
        var row = productQuery.findRowById(1L).orElseThrow();

        // Головний контракт: жодного entity load при constructor projection
        assertEquals(0, stats.entityLoads());

        // Додатковий сигнал: скільки SQL пішло на цей read-сценарій
        assertEquals(1, stats.statements());
    }
}

Зверніть увагу на психологію цього тесту: ми перевіряємо не лише «знайшовся товар», а саме те, що важливо для архітектури читання. Так, assertEquals(1, statements()) теж корисний, але він вторинний. Головне — що read-сценарій не матеріалізував entity graph.

І ще одна думка, яку корисно тримати в голові: projection-тести особливо цінні саме тому, що вони захищають від «зручного» рефакторингу. Коли ви повертаєте entity, код у сервісі часто стає коротшим… а потім ви платите за це податок у вигляді SQL-хаосу. Тест тут виступає в ролі нудного дорослого, який каже: «Ні, не можна».

6. Flush-регресія: момент видимості змін

Flush — один із тих механізмів Hibernate, які спочатку здаються «нудною внутрішньою кухнею», а потім раптом пояснюють половину загадок на кшталт «чому запит на читання викликав UPDATE». У регресійних тестах flush цікавий не як теорія, а як засіб зробити результат перевірки чесним: ми хочемо бути впевнені, що перевіряємо стан в базі, а не «в пам’яті всередині persistence context».

Найчастіший анти-патерн тестів на запис виглядає так: завантажили entity, змінили поле, одразу перечитали й переконалися, що поле змінилося. Такий тест майже нічого не доводить, тому що ви дивитеся на той самий managed-об’єкт або на кеш першого рівня. Hibernate в цей момент може ще не відправити SQL — і тест усе одно «зелений».

Тому базовий патерн flush-регресії такий: змінюємо entity, робимо flush(), робимо clear(), а потім перечитуємо й перевіряємо.

import com.example.commerce.catalog.repository.ProductRepository;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test; 
import org.springframework.beans.factory.annotation.Autowired;

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

class FlushRegressionTest {

    @Autowired ProductRepository products;
    @Autowired EntityManager em;

    @Test
    void updateBecomesVisibleAfterFlushAndClear() {
        // Завантажуємо entity (вона стає managed у поточному persistence context)
        var product = products.findById(1L).orElseThrow();

        // Змінюємо поле: це поки що лише зміна об'єкта в пам'яті
        product.setName("Оновлено");

        // Примусово синхронізуємо зміни з БД
        em.flush();

        // Викидаємо managed-об'єкти, щоб повторне читання точно пішло в БД
        em.clear();

        // Повторно читаємо й перевіряємо стан уже «очима бази»
        assertEquals("Оновлено", products.findById(1L).orElseThrow().getName());
    }
}

Ця маленька конструкція робить тест чесним. flush() змушує Hibernate синхронізувати зміни з базою, а clear() викидає managed-об’єкти з persistence context. Фінальне читання вже не може «вгадати правильну відповідь», воно зобов’язане йти в базу.

Якщо ви хочете зробити flush-поведінку ще більш спостережуваною і трохи менш «магічною», можна зафіксувати, що UPDATE справді стався саме в момент flush. Для цього чудово підходить статистика entityUpdateCount. Сценарій такий: змінили поле, перевірили, що оновлень ще немає, викликали flush, перевірили, що оновлення з’явилося. Виходить майже як «тест на момент істини».

import com.example.commerce.catalog.entity.Product; // Сутність продукту (пакет підлаштуйте під ваш проєкт)
import com.example.commerce.labsupport.HibernateStats;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.Test; 
import org.springframework.beans.factory.annotation.Autowired;

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

class FlushMomentRegressionTest {

    @Autowired EntityManager em;
    @Autowired HibernateStats stats;

    @Test
    void updateHappensOnFlushNotOnSetter() {
        // Завантажуємо managed-сутність у persistence context
        var product = em.find(Product.class, 1L);

        // Скидаємо лічильники перед вимірюваним фрагментом
        stats.clear();

        // Змінюємо поле: SQL UPDATE ще не зобов'язаний піти в БД
        product.setName("Оновлено");

        // Контракт: на setter оновлення в БД не відбуваються
        assertEquals(0, stats.entityUpdates());

        // Flush — момент, коли Hibernate зобов'язаний синхронізувати зміни з БД
        em.flush();

        // Контракт: після flush має з'явитися один UPDATE
        assertEquals(1, stats.entityUpdates());
    }
}

Тут ми ловимо дуже важливу ідею курсу: Hibernate не робить SQL «на кожному русі руки». Setter — це просто зміна об’єкта. SQL з’являється у flush-cycle. Такий тест не стільки про продуктивність, скільки про передбачуваність: якщо завтра у вас почнуть відбуватися оновлення раніше часу, наприклад через неочікуваний flush before query в іншому місці, подібні тести допоможуть швидше зрозуміти, де саме змінилася поведінка.

7. Типові помилки під час regression‑тестів

Помилка №1: вимірюють кількість запитів, забувши «помацати» ледачі зв’язки.
Якщо ви викликали метод репозиторію й одразу перевірили stats.statements(), ви могли не ініціювати lazy loading. Тест стає сліпим до N+1: проблема проявиться в реальному коді, коли ви полізете в getCustomer() або getItems(), але тест буде бадьоро зеленим. Правильний стиль — після читання виконати ті самі звернення до даних, які робить реальний сценарій використання.

Помилка №2: рахують запити, але не скидають statistics перед сценарієм.
Hibernate Statistics — лічильник, а не телепат. Якщо ви не зробили stats.clear(), туди потраплять запити від фікстури, від випадкових перевірок, від ледачих ініціалізацій в іншому місці. У підсумку тест стане нестабільним: він то падає, то проходить, а ви починаєте підозрювати в цьому квантову фізику. Зазвичай достатньо одного правила: «clear прямо перед вимірюванням».

Помилка №3: перевіряють запис без flush() і без clear(), а потім дивуються, чому тест не ловить баги.
Такий тест перевіряє лише факт, що managed-об’єкт змінив поле. Він не доводить, що SQL пішов у БД, і не захищає від проблем flush-cycle. Якщо ви пишете regression-тест на persistence-поведінку, фінальна перевірка має читати дані так, як їх прочитав би новий persistence context: через clear() і повторне читання.

Помилка №4: фіксують точне число запитів там, де контракт насправді «не більше N».
Занадто жорсткі очікування роблять тести ламкими. Іноді ваш read-сценарій по-чесному вимагає два запити, наприклад окремий count-запит для пагінації, і це нормально. У таких місцях краще фіксувати верхню межу або перевіряти більш змістовний сигнал, наприклад, що не з’явилося N+1, а не перетворювати тест на «секундомір із мікрометром».

Помилка №5: намагаються одним тестом захистити все одразу.
Тест, який одночасно перевіряє fetching, projection і flush, зазвичай швидко перетворюється на довгий, незрозумілий сценарій. Його важко читати, і він погано пояснює, що саме зламалося під час падіння. ORM-регресії краще захищаються вузькими тестами: один ризик — один контракт — один читабельний тестовий метод.

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