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(...) написать — прокси из этого не получится, как ни старайся.

Именно поэтому ORM-баги крайне редко ловятся unit-тестами. Они слишком “плоские”: у них нет БД, нет транзакции, нет SQL, а значит — нет той среды, в которой живут наши реальные проблемы: N+1, lazy loading, неожиданный flush, merge() с копированием состояния, optimistic lock конфликты, stale persistence context после bulk.

Нормальная инженерная мысль здесь не “unit-тесты плохие”, а “мы должны правильно выбирать инструмент”. Как молотком неудобно резать хлеб (но удобно забивать гвоздь), так и моками неудобно ловить ORM-баги (но удобно проверять, что сервис не делает лишних вызовов и правильно ветвится).

3. Два теста — два мира

Лучше всего эта тема ощущается на контрасте: один тест “про Java-объект”, второй — “про реальный JPA/Hibernate контекст”. Оба теста могут быть честными и полезными, просто они отвечают на разные вопросы. Первый отвечает: “мой код вообще присваивает значение?”. Второй — “мой код работает в мире Hibernate, прокси, транзакции и 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 — оставляет stale состояние, merge() — тащит лишние SELECT, optimistic locking — не ловится, soft delete — видимость ломает списки. Поэтому хорошая интеграционная проверка должна смотреть не только на “результат”, но и на “следы на снегу” — то есть на наблюдаемое persistence-поведение.

Удобно думать про это как про два слоя контракта. Первый слой — функциональный: “заказ сохранился”, “товар нашёлся”, “остаток уменьшился”. Второй слой — persistence-ориентированный: “не было N+1”, “после bulk состояние не осталось stale”, “getReferenceById() не взорвался при доступе к полю”, “после clear() данные перечитываются из БД”, “конкурентный конфликт проявляется как ожидаемое исключение”.

Ниже — таблица. Она не заменяет детали, но помогает понять, почему unit-тесты “слепые” по отношению к этим рискам.

Тип риска в ORM Как он проявляется в реальности Что наблюдать в интеграционном тесте Почему unit-тест это не поймает
Lazy-проблемы LazyInitializationException или неожиданные запросы при доступе к полю исключение при доступе вне контекста, или факт SQL при доступе в моках нет persistence context и proxy
N+1 много SELECT вместо одного число запросов / SQL trace в unit-тесте нет SQL и статистики
flush-семантика SQL уходит раньше, чем вы думали DB-видимое состояние после flush + reread без БД и транзакции flush не существует
merge() возвращается другой managed-instance, возможны лишние чтения различие source/result, наличие SELECT в unit-тесте merge — “просто метод”, а не поведение ORM
Bulk side effects данные в БД изменились, но managed-объекты остались старые stale state до clear(), корректность после reread в unit-тесте нет persistence context, нечему “протухать”
Optimistic locking конфликт версий при UPDATE ожидаемое исключение при конкурентном update без реальной БД и version-column конфликта нет
Constraints схемы уникальность/foreign key ловятся БД исключение при 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, то тест должен быть про query profile, а не про всё сразу.

Очень помогает стиль, который можно назвать “Given–When–Then, но без фанатизма”: сначала создаём/готовим минимальные данные, потом делаем одно действие, потом проверяем и функциональный результат, и persistence-сигнал. В ORM-тестах этот “сигнал” часто выглядит как “очистил контекст — перечитал — проверил”, либо как “ожидаю исключение”, либо как “смотрю на количество SQL”. Важно, что это не украшение, а часть договора.

И ещё одна практическая вещь, которая делает тесты поддерживаемыми: имя теста должно быть как заголовок бага в трекере. Не testSave(), а что-то в стиле getReferenceById_resolvesSkuInsideJpaContext. Это звучит длинно, но зато через три месяца вы открываете тест и сразу понимаете, что он защищает. ORM-регрессии любят возвращаться тихо, поэтому тест должен орать своим именем: “Я тут стою, я не просто так”.

7. Типичные ошибки при ORM-тестировании

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

Ошибка №1: тестируют getter/setter и называют это “тестом Hibernate”.
Такой тест полезен ровно настолько же, насколько полезно проверять, что у чашки есть ручка. Да, ручка есть. Но чай от этого не становится горячим. ORM-баги живут не в присваивании полей, а в том, как эти поля маппятся, когда и почему уходят в SQL, что происходит при flush и что остаётся в persistence context.

Ошибка №2: мокают репозитории и считают, что “интеграция проверена”.
Моки хороши, когда вы проверяете ветвление логики и взаимодействия между компонентами. Но Hibernate не проявляет себя в моках, потому что там нет ни прокси, ни сессии, ни SQL, ни транзакции, ни поведения конкретной БД. Это всё равно что тестировать плавание, стоя на коврике для йоги (коврик прекрасный, но вода-то где?).

Ошибка №3: проверяют результат через тот же managed-объект и забывают про clear()/перечитывание.
Hibernate очень заботливый: он может показать вам данные из first-level cache, и вы подумаете “ура, сохранилось”. Но вы проверили не базу, а память. Как только в реальном приложении появится другой запрос или другая транзакция, внезапно выяснится, что в БД совсем не то, что вы видели в тесте. Поэтому интеграционный тест почти всегда должен уметь “переключаться на взгляд базы”, а не на взгляд persistence context.

Ошибка №4: в одном тесте пытаются поймать fetching, flush и locking одновременно.
В итоге тест превращается в детектив: “кто убил производительность?”. А убийца может быть любой. Лучше иметь три маленьких теста, где каждый фиксирует один риск, чем один огромный, который сложно понять и ещё сложнее чинить.

Ошибка №5: тестовые данные не детерминированы и “живут своей жизнью”.
Если фикстура создаётся случайными значениями, если данные общие на весь модуль, если порядок выполнения тестов влияет на результат — вы получаете flaky-поведение. А flaky ORM-тест — это худший вид теста: он не защищает, а раздражает. Тест должен быть скучно-предсказуемым, как бухгалтерская таблица (да, звучит не романтично, зато работает).

1
Задача
Hibernate deep-dive, 28 уровень, 0 лекция
Недоступна
POJO-тест и JPA-интеграционный тест для одного домена
POJO-тест и JPA-интеграционный тест для одного домена
1
Задача
Hibernate deep-dive, 28 уровень, 0 лекция
Недоступна
Интеграционный тест на уникальность SKU
Интеграционный тест на уникальность SKU
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ