JavaRush /Курсы /Spring Test /Lazy loading в @DataJpaTest...

Lazy loading в @DataJpaTest

Spring Test
17 уровень , 2 лекция
Открыта

1. Lazy loading: смысл и риски

Граф можно сохранить идеально и всё равно получить сюрприз на чтении. Как только Article лениво тянет Category и attachments, вопрос меняется: важно не только что лежит в базе, но и где ещё жив persistence context в момент доступа к связи.

Если говорить по-человечески, lazy loading — это когда JPA говорит: «Связанные данные я тебе пока не дам. Но если очень попросишь — попробую догрузить позже». Это звучит как сервис мечты: не тянуть сразу категорию и вложения, если мы сейчас просто читаем статью по id. Особенно в нашем ContentHub, где у статьи может быть несколько вложений, и не хочется каждый раз тащить их «на всякий случай».

Проблема в том, что «попробую догрузить позже» работает только пока рядом жив persistence context (а в терминах Hibernate — пока жива Session). Как только вы вышли из транзакции, очистили/закрыли контекст или унесли сущность «в другое место» — внезапно выясняется, что догружать уже некому, и вы ловите классическую LazyInitializationException. И да, она выглядит так, будто JPA решила устроить вам проверку на стрессоустойчивость.

Lazy опасен не только тем, что может упасть после выхода за границу контекста: пока контекст жив, он ещё и способен тихо раздувать SQL. Но сначала разберём саму границу, на которой появляется LazyInitializationException.

определение

Lazy loading — это стратегия загрузки связей, при которой связанные сущности читаются из базы не сразу, а по первому обращению к полю/коллекции.

LazyInitializationException — типичная ошибка Hibernate, которая означает: «Вы попытались обратиться к lazy-связи, но контекст, который мог бы её догрузить, уже недоступен».

2. Lazy в Hibernate: прокси и догрузка

В этом разделе важно не превращаться в курс по внутренностям ORM, но хотя бы слегка понять механику. Hibernate не «держит пустое поле category = null». Вместо этого он подставляет в поле прокси-объект, который выглядит как Category, но внутри хранит «адрес, куда сходить за настоящей категорией». То же самое с коллекциями: вместо обычного ArrayList там может оказаться «умная» коллекция Hibernate, которая при первом .size() идёт в базу.

В нашем проекте это чаще всего выглядит так: категория у статьи — ManyToOne, а вложения — OneToMany. И мы явно делаем их ленивыми, потому что нам не всегда нужно поднимать весь граф.

Мини-пример 1: ленивый ManyToOne (Article -> Category)

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.ManyToOne;

@Entity
class Article {

    // Важно: LAZY означает, что Category не будет загружена при чтении Article.
    // Вместо реального объекта Hibernate подставит прокси и догрузит при обращении.
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Category category;
}

Если вы загрузили Article, это не гарантирует, что категория уже реально прочитана из базы. Это гарантирует только то, что Hibernate знает, как её прочитать, если вы попросите.

Мини-пример 2: ленивый OneToMany (Article -> attachments)

import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

@Entity
class Article {

    // Важно: LAZY коллекции — стандартный источник сюрпризов.
    // Пока мы не трогаем коллекцию, SQL на вложения может не выполняться.
    @OneToMany(mappedBy = "article", fetch = FetchType.LAZY)
    private List<ArticleAttachment> attachments = new ArrayList<>();
}

Коллекция выглядит как обычный список, но «жизнь» в ней появляется в момент первого реального доступа. И вот этот момент доступа и есть главный источник сюрпризов.

Мини-пример 3: «второй запрос» как побочный эффект обращения

Article article = articleRepository.findById(articleId).orElseThrow();

// В этот момент Category ещё может быть прокси-объектом.
// При первом обращении к полям Category Hibernate может сделать дополнительный SQL.
String categoryCode = article.getCategory().getCode(); // тут может уйти SQL

// Аналогично с коллекцией: size() может триггерить SELECT по вложениям.
int attachmentCount = article.getAttachments().size(); // и тут тоже

Вроде вы просто читаете Java-поля, а на самом деле запускаете чтение из базы. Если контекст жив — всё «магически работает». Если контекст уже умер — вы получаете исключение.

3. @DataJpaTest и иллюзия безопасности lazy

@DataJpaTest почти всегда выполняет каждый тест в транзакции. Это удобно: тесты изолированы, данные не «текут» между методами, и после теста всё откатывается. Но есть побочный эффект: внутри одного теста persistence context обычно живёт долго, и поэтому lazy-связи часто выглядят безопасными.

Это похоже на ситуацию, когда вы всегда едите в кафе с официантом, который стоит рядом и готов принести ещё соуса, вилку, салфетку и моральную поддержку. Вы привыкаете, что «если что — попрошу». А потом вдруг оказываетесь на пикнике в лесу, и официанта там почему-то нет.

И вот здесь появляется главный методический риск: если вы в data-тесте просто пишете article.getCategory().getCode() и тест зелёный, это ещё не доказывает, что ваш реальный код никогда не упадёт на ленивой связи. Он доказывает только то, что внутри транзакции теста догрузка возможна.

Чтобы проверить реальную границу, нам нужно уметь в тесте создавать ситуацию «контекста больше нет».

4. Lazy внутри активного контекста

Сначала сделаем то, что обычно делает новичок: загрузим статью и спокойно прочитаем категорию и вложения. Это полезно как контрольная точка, потому что показывает нормальное поведение lazy внутри живого контекста.

Ниже — упрощённый фрагмент теста. Смысл: мы создаём минимальный fixture, сохраняем, перечитываем и убеждаемся, что доступ к связям работает.

Мини-пример 1: каркас @DataJpaTest

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

@DataJpaTest
class ArticleLazyLoadingDataJpaTest {

    // Обычно здесь будут:
    // - @Autowired ArticleRepository articleRepository;
    // - @Autowired EntityManager entityManager; (или TestEntityManager)
    //
    // Важно: @DataJpaTest по умолчанию запускает тесты в транзакции.
}

Мини-пример 2: позитивный доступ к категории внутри транзакции

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void category_is_accessible_inside_transaction() {
    // Читаем сущность внутри тестовой транзакции.
    Article article = articleRepository.findById(articleId).orElseThrow();

    // Внутри активного persistence context lazy-связь можно догрузить.
    assertThat(article.getCategory().getCode()).isEqualTo("java");
}

Если у вас включён лог SQL, вы, скорее всего, увидите, что запрос к категории происходит не при findById, а именно при getCode(). Но даже если логи выключены, главный факт здесь такой: «внутри активного контекста всё ок».

Мини-пример 3: позитивный доступ к коллекции вложений

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

@Test
void attachments_are_accessible_inside_transaction() {
    // Читаем сущность внутри тестовой транзакции.
    Article article = articleRepository.findById(articleId).orElseThrow();

    // size()/итерация по коллекции может привести к SQL, но внутри контекста это безопасно.
    assertThat(article.getAttachments()).hasSize(2);
}

Ещё раз: эти тесты полезны, но они почти всегда «слишком добрые». Они не создают условия, в которых в реальном приложении случается падение.

5. Граница контекста: managed vs detached

Чтобы понять, почему происходит LazyInitializationException, полезно держать в голове два состояния сущности: managed и detached. Это не философия, это почти бытовая механика.

Managed-сущность — это та, которую держит в руках persistence context. Hibernate знает про неё всё: отслеживает изменения, может догружать lazy-связи, может делать dirty checking, может внезапно (для новичка) выполнить SQL.

Detached-сущность — это сущность «вне контекста». Она выглядит как нормальный Java-объект, но ORM уже не может её обслуживать. И если в ней лежит неинициализированная lazy-ссылка (прокси), то при попытке её раскрыть Hibernate говорит: «А у меня больше нет доступа к базе, я не знаю, куда бежать».

Вот маленькая таблица-памятка — не для зубрёжки, а чтобы в голове не путались ощущения.

Состояние сущности Кто «держит» объект Lazy можно догрузить? Что обычно происходит
managed persistence context да «магия работает»
detached никто нет часто LazyInitializationException

Теперь самое важное: в @DataJpaTest мы можем искусственно превратить объект в detached, даже не выходя из теста, просто очистив persistence context.

Мини-пример: clear() как «граница»

Article article = articleRepository.findById(articleId).orElseThrow();

// Очищаем persistence context: загруженные сущности становятся detached.
entityManager.clear();

// Если category не была инициализирована до clear(), попытка доступа может упасть.
article.getCategory().getCode(); // тут может быть LazyInitializationException

Да, транзакция теста всё ещё активна, но конкретный объект больше не managed. Для lazy-прокси это часто означает «я один, и мне страшно».

6. Тест на LazyInitializationException

Теперь сделаем тест, который действительно показывает проблему. Его смысл не в том, чтобы «сломать код ради удовольствия», а в том, чтобы увидеть: если объект ушёл за границу persistence context, lazy-связь может стать миной.

Очень важная дисциплина: пока вы готовите сценарий, не трогайте lazy-связь «случайно». Любой ранний вызов вроде article.getCategory().toString() или логирование объекта может внезапно инициализировать связь, и тест перестанет быть честным.

Мини-пример 1: импорт LazyInitializationException

import org.hibernate.LazyInitializationException;

// Это Hibernate-специфичное исключение: его часто ожидают в тестах на lazy.

Это исключение Hibernate-специфичное. На практике оно именно то, что мы чаще всего ловим, когда разговор о lazy.

Мини-пример 2: проверяем падение после очистки контекста

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Test
void category_is_not_accessible_after_detach() {
    // Важно: до clear() НЕ обращаемся к article.getCategory(),
    // иначе связь может проинициализироваться и тест потеряет смысл.
    Article article = articleRepository.findById(articleId).orElseThrow();

    // Создаём границу: делаем объект detached.
    entityManager.clear();

    // После detach Hibernate не может догрузить прокси -> ожидаем LazyInitializationException.
    assertThatThrownBy(() -> article.getCategory().getCode())
            .isInstanceOf(LazyInitializationException.class);
}

Этот тест делает важную вещь: он фиксирует границу. Внутри контекста доступ был нормальным, после detaching — потенциально опасен.

Мини-пример 3: та же идея для коллекции

import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@Test
void attachments_are_not_accessible_after_detach() {
    // Снова: не трогаем attachments до clear(), чтобы не инициализировать коллекцию раньше времени.
    Article article = articleRepository.findById(articleId).orElseThrow();

    // Оторвали объект от persistence context.
    entityManager.clear();

    // Любой реальный доступ к коллекции (size/итерация) может попытаться сходить в БД.
    assertThatThrownBy(() -> article.getAttachments().size())
            .isInstanceOf(LazyInitializationException.class);
}

Если вы увидели такой сценарий в тестах, это не значит «JPA плохая». Это значит: вы нашли точку, где ваш код или архитектура чтения должны принять решение, когда и где вы хотите иметь доступ к связанным данным.

7. Паттерн теста: flush -> clear -> read -> clear

В этот момент часто возникает путаница: «Подождите, мы уже делали clear раньше для честного чтения из базы. Теперь мы снова делаем clear ради lazy… Это что, два разных clear?» Да, и это нормально. Просто у них разные цели.

Первый flush -> clear обычно нужен, чтобы вы не проверяли объект, который ещё не был реально записан в базу. Это честность по отношению к БД.

Второй clear после чтения — это честность по отношению к границе persistence context. Это проверка «а что будет, если объект уйдёт наружу».

Удобно держать в голове такой сценарий (как мини-блок-схему), чтобы каждый раз не придумывать заново.

flowchart TD
    A[Подготовить сущности] --> B[flush]
    B --> C[clear]
    C --> D[Перечитать Article из репозитория]
    D --> E{"Хотим проверить lazy?"}
    E -->|Да| F["clear / detach"]
    F --> G[Доступ к lazy-связи]
    G --> H[Ожидаем exception или доказываем safe-load]
    E -->|Нет| I[Обычные assertions]

Эта схема особенно полезна новичкам, потому что помогает не смешивать два разных смысла «очистки».

Мини-пример: «двухфазный» тестовый паттерн

// 1) Добиваемся, что изменения реально ушли в БД.
entityManager.flush();

// 2) Очищаем контекст, чтобы следующее чтение было "честным" (не из 1st level cache).
entityManager.clear();

// 3) Читаем заново так, как это сделал бы обычный код через репозиторий.
Article article = articleRepository.findById(articleId).orElseThrow();

// 4) И ещё раз clear — уже для проверки границы managed/detached.
entityManager.clear(); // создаём границу

В реальном тесте между этими строками будет больше кода, но логика именно такая: сначала честно перечитали из базы, потом честно «оторвали» объект от контекста.

8. Стратегии: когда lazy падает

Когда вы поймали LazyInitializationException, первая реакция новичка часто такая: «Окей, давайте просто сделаем FetchType.EAGER везде, и проблема исчезнет». Да, исчезнет… примерно как исчезает проблема нехватки времени, если вы перестаёте спать. Технически вы выживете, но качество жизни пострадает.

Нам нужны стратегии, которые решают проблему в нужной точке, а не «выключают ленивость как концепт».

Стратегия 1: работать со связями внутри транзакции

Если ваше приложение читает сущность и тут же маппит её в DTO, самый естественный вариант — делать это внутри @Transactional-границы. Тогда lazy может догрузиться в момент маппинга, и вы не унесёте наружу «полу-пустую» сущность с неинициализированными прокси.

Важно не превращать это в идею «транзакция на всё подряд». Идея проще: если вы точно знаете, что DTO требует категорию и список вложений — сделайте маппинг там, где контекст гарантированно жив.

Стратегия 2: репозиторий-метод, который заранее загружает нужную связь (fetch join)

Иногда проще и честнее сказать прямо: «Для этого use-case мне нужна статья вместе с категорией». Тогда вы делаете отдельный метод репозитория, который читает статью с join fetch. Это не обязательно «про оптимизацию», это в первую очередь про предсказуемость: вы гарантируете, что после чтения категория уже в памяти.

Мини-пример для категории:

import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

@Query("""
    select a from Article a
    join fetch a.category
    where a.id = :id
    """)
Optional<Article> findByIdWithCategory(@Param("id") Long id);
// Комментарий: join fetch заставляет Hibernate загрузить a.category в том же запросе,
// чтобы не было "сюрпризов" при обращении к связи после выхода за границы контекста.

Аналогично можно сделать метод для вложений. Только важно помнить: attachments — коллекция, и fetch join на коллекцию меняет форму результата (могут появляться дубликаты строк), поэтому такие методы надо писать аккуратно и осознанно. Мы сейчас не уходим в оптимизацию, но мысль простая: fetch join — это «загрузить сразу то, что понадобится».

Стратегия 3: не тащить entity наружу вовсе (проекции / DTO read-model)

В нашем ContentHub это вообще базовая философия: API работает через DTO. На уровне репозитория или сервиса можно строить read-модель так, чтобы наружу уходили уже готовые данные, а не сущности с lazy-полями.

Даже если вы пока не пишете сложные проекции, важно понимать идею: LazyInitializationException почти всегда возникает на стыке «мы вытащили entity» и «мы поздно вспомнили, что нам нужны связанные данные».

Предупреждение про EAGER

Иногда EAGER действительно оправдан, но это должно быть осознанное решение. Если вы просто делаете всё eager, то очень легко получить ситуацию, когда «одна статья → одна категория → ещё что-то → ещё что-то» превращается в непредсказуемый граф загрузки. А потом кто-то просто хотел вывести список статей, а ORM в этот момент решил, что неплохо бы прогреть всю библиотеку мира.

9. Типичные ошибки в data-тестах с lazy

Ошибка №1: «Раз тест зелёный — значит lazy безопасен».
Часто первый тест, где вы читаете article.getCategory().getCode(), проходит идеально. И вы начинаете доверять. Но этот тест проверяет поведение внутри тестовой транзакции, где persistence context живёт рядом, как верный спутник. Чтобы тест был честным, нужно создавать границу через clear()/detach и проверять поведение уже «снаружи».

Ошибка №2: случайно инициализировать lazy-связь до создания границы.
Это классика: вы вывели System.out.println(article) или залогировали объект, а его toString() внезапно полез в getCategory() и инициализировал прокси. После этого вы делаете clear() и думаете, что проверяете падение, но на самом деле связь уже была загружена, и тест «не видит проблему». В таких тестах лучше избегать лишнего логирования и держать код максимально прямолинейным.

Ошибка №3: пытаться лечить всё FetchType.EAGER, потому что «так проще».
EAGER часто действительно делает проблему LazyInitializationException менее заметной. Но он не делает чтения предсказуемыми и почти всегда увеличивает объём работы базы. В учебном проекте это особенно опасно: вы можете случайно «спрятать» целый класс дефектов и потом не понимать, почему в реальном проекте всё тормозит.

Ошибка №4: смешивать в одном тесте слишком много причин падения.
Если в одном сценарии вы одновременно проверяете связи, каскады, orphan removal и ещё пытаетесь поймать LazyInitializationException, то при падении вы долго будете угадывать, что именно сломалось. Lazy-тесты особенно любят чистоту: один риск, одна граница, один ожидаемый результат.

Ошибка №5: ожидать от clear() «магического закрытия транзакции».
entityManager.clear() очищает persistence context и делает объекты detached, но это не то же самое, что «закрыть приложение» или «закончить запрос». Поэтому важно понимать смысл приёма: мы не «симулируем весь прод», мы создаём достаточно жёсткую границу, чтобы увидеть, что lazy требует живого контекста. Если вы теряете смысл, тест превращается в шаманство.

1
Задача
Spring Test, 17 уровень, 2 лекция
Недоступна
Lazy-категория внутри тестовой транзакции
Lazy-категория внутри тестовой транзакции
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ