1. Regression‑тест в ORM: контракт
Когда вы впервые сталкиваетесь с ORM-проблемой, мозг естественно хочет «починить и забыть». Но Hibernate — как кот: если один раз нашёл лазейку, будет возвращаться туда снова и снова, особенно после «безобидного рефакторинга». Поэтому регрессионный тест — это наша дисциплина памяти: мы превращаем найденный риск в проверку, которая не даст ему тихо вернуться.
В обычных приложениях регрессия часто выглядит как «раньше падало — теперь не падает». В persistence-layer всё интереснее: у нас может быть правильный бизнес-результат, но неправильная цена его получения. Например, список заказов отдаётся корректно, но внезапно делает 57 запросов вместо 2. Или карточка товара всё ещё работает, но снова тащит целый граф связей, как будто это не backoffice, а пылесос.
В ORM-регрессионном тесте важно, что контракт почти всегда двойной. С одной стороны, вы проверяете функциональный результат (данные правильные). С другой — вы проверяете persistence-сигнал (загрузка/SQL/flush), который и является истинной причиной большинства «почему в проде тормозит».
Ниже — удобная «карта местности», какие сигналы мы фиксируем именно в этой лекции:
| Риск (что может вернуться) | Что мы хотим зафиксировать | Чем измеряем в тесте |
|---|---|---|
| N+1 / лишние secondary selects | Запросов не стало больше, чем ожидаем | query count через Hibernate Statistics |
| Соскальзывание с projection обратно в entity-loading | В read-case не грузятся entities вообще | entityLoadCount == 0 и тип результата — DTO/record |
| «Изменил в памяти, а в базе как будто не изменилось» (или наоборот) | Момент, когда изменение становится видимым в БД, предсказуем | flush() + clear() + reread, а иногда entityUpdateCount |
Ключевая мысль: ORM-регрессионный тест не должен пытаться быть «универсальным тестом всего». Он должен быть как хороший дорожный знак: короткий, конкретный, и объясняет, где именно вы можете улететь в кювет.
2. Query count в fetching‑контракте
Если вы раньше не проверяли количество запросов, это нормально: большинство людей сначала учится «чтобы работало». Но в Hibernate-мире «работает» — это минимальный порог, а дальше начинается взрослая жизнь: «работает предсказуемо» и «работает с нормальной ценой». Query count — это один из самых простых и честных способов сделать fetching наблюдаемым.
При этом важно не попасть в ловушку: «сейчас я заассерчу, что запросов ровно 1, и буду счастлив». А потом вы добавите во view одно поле, и станет 2 — и тест начнёт падать не потому, что вы сломали архитектуру, а потому что жизнь сложнее единицы. Поэтому мы обычно фиксируем query count только там, где он действительно является контрактом. Типичный пример — сценарий, который уже ловил N+1 и был исправлен через JOIN FETCH, EntityGraph, batch fetching или projection.
Ещё одна тонкость: чтобы тест реально ловил N+1, вы должны «потрогать» те поля и связи, которые трогает реальный use case. Если тест просто вызывает метод репозитория и ничего не читает из результата, ленивые связи могут не инициализироваться — и SQL просто не произойдёт. Тест получится «оптимистичный», как человек, который проверил, что пожарная сигнализация есть, но ни разу не нажал кнопку теста.
Удобно мысленно представить fetching-регрессионный тест как небольшой конвейер:
flowchart TD
A[Подготовили фикстуру] --> B[flush + clear]
B --> C[Сбросили statistics]
C --> D[Выполнили read-case]
D --> E[Потрогали нужные поля/связи]
E --> F[Проверили данные]
F --> G[Проверили query count]
В этом конвейере чаще всего самые забываемые шаги — «сбросили statistics» и «потрогали нужные связи». Именно они отделяют тест, который ловит реальную регрессию, от теста, который «успешно проходит всегда».
3. Hibernate Statistics в @DataJpaTest
Чтобы делать query count assertions без магии и без внешних библиотек, нам достаточно Hibernate Statistics. Это встроенный механизм Hibernate, который умеет считать разные события: загрузки сущностей, количество подготовленных statement’ов, количество flush и так далее. Он не заменяет SQL-лог (лог всё равно нужен), но для теста Statistics удобнее: они дают цифры, которые можно ассертить.
Нюанс в том, что statistics должны быть включены. В обычном приложении вы часто держите их выключенными, чтобы не шуметь и не добавлять overhead. В тестах, особенно регрессионных, включить их — очень разумный компромисс. В нашем 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 statement'ов (удобный proxy для «сколько запросов ушло в БД»)
return statistics.getPrepareStatementCount();
}
public long entityLoads() {
// Сколько сущностей было загружено как managed entities (важно для DTO/projection регрессий)
return statistics.getEntityLoadCount();
}
public long entityUpdates() {
// Сколько сущностей было обновлено (полезно для наблюдаемости flush-cycle)
return statistics.getEntityUpdateCount();
}
}
Обратите внимание на стиль: мы не пытаемся построить «универсальный фреймворк для тестов». Нам достаточно трёх методов: включить, очистить, получить число statement’ов. Если вы хотите фиксировать ещё и загрузки сущностей или обновления — добавите методы позже, но по-прежнему маленькими порциями.
А вот пример, как в @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 классический кандидат — чтение списка заказов для backoffice, где вы показываете номер заказа, статус и 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-case, делаем то же, что сделала бы 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() {
// Сбрасываем статистику прямо перед измерением, чтобы не учитывать запросы фикстуры/инициализации
stats.clear();
// Выполняем read-case
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-case, иначе тест превращается в философию, а философия не падает в CI (и в этом её слабость).
5. Projection‑регрессия: DTO без entity‑loading
С projections обычно случается классическая история. Вы аккуратно сделали DTO-проекцию для списка товаров, всё стало быстро и красиво. Потом кто-то (возможно, вы через месяц, в состоянии «я точно помню, что делаю») решает: «А давайте вернём entity, так удобнее, я же всего одно поле добавлю». И незаметно для всех в проект снова приезжает entity-loading со всеми бонусами: лишние колонки, случайные lazy loads, dirty checking overhead (да-да, даже на чтении) и снова непредсказуемая цена.
Поэтому projection-regression тест — это не про «данные пришли». Он про то, что мы не загрузили ни одной entity, а получили ровно тот read-model, который задумали.
Предположим, в каталоге есть record, который мы используем как строку таблицы:
package com.example.commerce.catalog.dto;
// Read-model для списка/таблицы: здесь не должно быть ленивых связей и managed-сущностей
public record ProductRow(Long id, String sku, String name) { }
И query-репозиторий, который возвращает именно этот read-model:
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 не должен грузить сущности как managed objects. Значит, entityLoadCount можно ожидать нулевым. Добавим пару методов в HibernateStats (да, чуть расширим helper — но всё ещё прозрачно и коротко):
import org.hibernate.stat.Statistics;
public long entityLoads() {
// Сколько сущностей было загружено как 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-case, который должен вернуть DTO
var row = productQuery.findRowById(1L).orElseThrow();
// Главный контракт: никаких entity load при constructor projection
assertEquals(0, stats.entityLoads());
// Дополнительный сигнал: сколько SQL ушло на этот read-case
assertEquals(1, stats.statements());
}
}
Обратите внимание на психологию этого теста: мы проверяем не только «нашёлся товар», а именно то, что нам важно для архитектуры чтений. Да, assertEquals(1, statements()) тоже полезен, но он вторичен. Главное — что read-case не материализовал entity graph.
И ещё одна мысль, которую полезно держать в голове: projection-тесты особенно ценны именно потому, что они защищают от «удобного» рефакторинга. Когда вы возвращаете entity, код в сервисе часто становится короче… а потом вы платите за это налог в виде SQL-хаоса. Тест здесь выступает в роли скучного взрослого, который говорит: «Нет, нельзя».
6. Flush‑регрессия: момент видимости изменений
Flush — один из тех механизмов Hibernate, которые сначала кажутся “скучной внутренней кухней”, а потом внезапно объясняют половину загадок вида «почему запрос на чтение вызвал UPDATE». В регрессионных тестах flush интересен не как теория, а как средство сделать результат проверки честным: мы хотим быть уверены, что проверяем состояние в базе, а не «в памяти внутри persistence context».
Самый частый анти-паттерн тестов на запись выглядит так: загрузили entity, поменяли поле, тут же перечитали и убедились, что поле поменялось. Такой тест почти ничего не доказывает, потому что вы смотрите на тот же managed-объект или на first-level cache. Hibernate в этот момент может ещё не отправить SQL — и тест всё равно «зелёный».
Поэтому базовый паттерн flush-регрессии такой: меняем сущность, делаем 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() {
// Загружаем сущность (она становится managed в текущем persistence context)
var product = products.findById(1L).orElseThrow();
// Меняем поле: это пока только изменение объекта в памяти
product.setName("Updated");
// Принудительно синхронизируем изменения с БД
em.flush();
// Выбрасываем managed объекты, чтобы повторное чтение точно пошло в БД
em.clear();
// Повторно читаем и проверяем состояние уже «глазами базы»
assertEquals("Updated", 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("Updated");
// Контракт: на 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: измеряют query count, забыв “потрогать” ленивые связи.
Если вы вызвали метод репозитория и сразу проверили stats.statements(), вы могли не инициировать lazy loading. Тест становится слепым к N+1: проблема проявится в реальном коде (когда вы полезете в getCustomer() или getItems()), но тест будет бодро зелёным. Правильный стиль — после чтения выполнить те же обращения к данным, которые делает реальный use case.
Ошибка №2: считают запросы, но не сбрасывают statistics перед сценарием.
Hibernate Statistics — счётчик, а не телепат. Если вы не сделали stats.clear(), туда попадут запросы от фикстуры, от случайных проверок, от ленивых инициализаций в другом месте. В итоге тест станет нестабильным: он то падает, то проходит, а вы начинаете подозревать в этом квантовую физику. Обычно достаточно одного правила: «clear прямо перед измерением».
Ошибка №3: проверяют запись без flush() и без clear(), а потом удивляются “почему тест не ловит баги”.
Такой тест проверяет только факт, что managed-объект поменял поле. Он не доказывает, что SQL ушёл в БД, и не защищает от проблем flush-cycle. Если вы пишете regression-тест на persistence-поведение, финальная проверка должна читать данные так, как их прочитал бы новый persistence context: через clear() и повторное чтение.
Ошибка №4: фиксируют точное число запросов там, где контракт на самом деле “не больше N”.
Слишком жёсткие ожидания делают тесты ломкими. Иногда ваш read-case по-честному требует два запроса (например, отдельный count-запрос для пагинации), и это нормально. В таких местах лучше фиксировать верхнюю границу или проверять более смысловой сигнал (например, что не появилось N+1), а не превращать тест в “секундомер с микрометром”.
Ошибка №5: пытаются одним тестом защитить всё сразу.
Тест, который одновременно проверяет fetching, projection и flush, обычно быстро превращается в длинный, непонятный сценарий. Он сложно читается и плохо объясняет, что именно сломалось при падении. ORM-регрессии лучше защищаются узкими тестами: один риск — один контракт — один читаемый тестовый метод.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ