CodeGym /Cursos /Spring Test /Carga perezosa en @DataJpaTest

Carga perezosa en @DataJpaTest

Spring Test
Nivel 17, Lección 2
Disponible

1. Carga perezosa: significado y riesgos

Se puede guardar el grafo perfectamente y aun así llevarse una sorpresa al leer. Tan pronto como Article carga perezosamente Category y attachments, la cuestión cambia: ya no importa solo lo que hay en la base de datos, sino también dónde más vive el contexto de persistencia en el momento de acceder a la relación.

Dicho de forma humana, la carga perezosa es cuando JPA dice: «Los datos relacionados no te los daré ahora. Pero si lo pides muy bien, intentaré cargarlos más tarde». Suena como un servicio ideal: no arrastrar inmediatamente la categoría y los adjuntos si solo estamos leyendo el artículo por id. Especialmente en nuestro ContentHub, donde un artículo puede tener varios adjuntos y no queremos traerlos siempre «por si acaso».

El problema es que «intentaré cargarlos más tarde» funciona solo mientras el contexto de persistencia esté presente (en términos de Hibernate, mientras viva la Session). En cuanto sales de la transacción, limpias/cierras el contexto o te llevas la entidad «a otro sitio», de repente resulta que ya no hay quién cargue los datos, y te encuentras con la clásica LazyInitializationException. Y sí, se presenta como si JPA hubiera decidido ponerte a prueba la resistencia al estrés.

Lazy no solo es peligroso porque puede fallar al salir del contexto: mientras el contexto está vivo, también puede inflar silenciosamente el SQL. Pero primero analicemos el límite mismo donde aparece la LazyInitializationException.

Definición

La carga perezosa es una estrategia de carga de relaciones en la que las entidades relacionadas se leen de la base de datos no de inmediato, sino al primer acceso al campo/colección.

LazyInitializationException es un error típico de Hibernate que significa: «Has intentado acceder a una relación perezosa, pero el contexto que podría cargarla ya no está disponible».

2. Lazy en Hibernate: proxies y carga diferida

En esta sección es importante no convertirnos en un curso sobre el funcionamiento interno del ORM, pero al menos entender la mecánica superficialmente. Hibernate no «mantiene el campo vacío category = null». En su lugar, coloca en el campo un objeto proxy que se parece a Category, pero que internamente guarda la «dirección a la que acudir para obtener la categoría real». Lo mismo ocurre con las colecciones: en lugar de un ArrayList normal, puede haber una colección «inteligente» de Hibernate que, al primer .size(), va a la base de datos.

En nuestro proyecto, esto suele verse así: la categoría del artículo es ManyToOne, y los adjuntos son OneToMany. Y los hacemos explícitamente perezosos porque no siempre necesitamos levantar todo el grafo.

Mini-ejemplo 1: ManyToOne perezoso (Article -> Category)

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

@Entity
class Article {

    // Importante: LAZY significa que Category no se cargará al leer Article.
    // En lugar del objeto real, Hibernate colocará un proxy y lo cargará al acceder.
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    private Category category;
}

Si cargas Article, esto no garantiza que la categoría ya se haya leído realmente de la base de datos. Solo garantiza que Hibernate sabe cómo leerla si se lo pides.

Mini-ejemplo 2: OneToMany perezoso (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 {

    // Importante: las colecciones LAZY son una fuente estándar de sorpresas.
    // Mientras no toquemos la colección, es posible que no se ejecute SQL para los adjuntos.
    @OneToMany(mappedBy = "article", fetch = FetchType.LAZY)
    private List<ArticleAttachment> attachments = new ArrayList<>();
}

La colección parece una lista normal, pero la «vida» aparece en el momento del primer acceso real. Y ese momento de acceso es la principal fuente de sorpresas.

Mini-ejemplo 3: «segunda consulta» como efecto secundario del acceso

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

// En este momento, Category aún puede ser un objeto proxy.
// Al acceder por primera vez a los campos de Category, Hibernate puede hacer un SQL adicional.
String categoryCode = article.getCategory().getCode(); // aquí puede ir un SQL

// Similar con la colección: size() puede disparar un SELECT sobre los adjuntos.
int attachmentCount = article.getAttachments().size(); // y aquí también

Parece que solo estás leyendo campos de Java, pero en realidad estás lanzando lecturas de la base de datos. Si el contexto está vivo, todo funciona «mágicamente». Si el contexto ya ha muerto, obtienes una excepción.

3. @DataJpaTest y la ilusión de seguridad de lazy

@DataJpaTest casi siempre ejecuta cada prueba dentro de una transacción. Es cómodo: las pruebas están aisladas, los datos no «fluyen» entre métodos y después de la prueba todo se revierte. Pero hay un efecto secundario: dentro de una misma prueba, el contexto de persistencia suele vivir mucho tiempo y, por lo tanto, las relaciones perezosas a menudo parecen seguras.

Es como si siempre comieras en un café con un camarero que está al lado, listo para traerte más salsa, un tenedor, una servilleta y apoyo moral. Te acostumbras a que «si necesitas algo, lo pides». Y luego, de repente, te encuentras en un picnic en el bosque y allí no hay ningún camarero.

Y aquí aparece el principal riesgo metodológico: si en una prueba de datos simplemente escribes article.getCategory().getCode() y la prueba es verde, eso no demuestra que tu código real nunca vaya a fallar con una relación perezosa. Solo demuestra que dentro de la transacción de la prueba la carga es posible.

Para verificar el límite real, necesitamos poder crear en la prueba una situación de «ya no hay contexto».

4. Lazy dentro del contexto activo

Primero hagamos lo que suele hacer un principiante: cargar un artículo y leer tranquilamente la categoría y los adjuntos. Esto es útil como punto de control porque muestra el comportamiento normal de lazy dentro de un contexto vivo.

A continuación, un fragmento simplificado de la prueba. La idea: creamos un fixture mínimo, guardamos, releemos y comprobamos que el acceso a las relaciones funciona.

Mini-ejemplo 1: esqueleto de @DataJpaTest

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

@DataJpaTest
class ArticleLazyLoadingDataJpaTest {

    // Normalmente aquí estarán:
    // - @Autowired ArticleRepository articleRepository;
    // - @Autowired EntityManager entityManager; (o TestEntityManager)
    //
    // Importante: @DataJpaTest por defecto ejecuta las pruebas en una transacción.
}

Mini-ejemplo 2: acceso positivo a la categoría dentro de la transacción

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

@Test
void category_is_accessible_inside_transaction() {
    // Leemos la entidad dentro de la transacción de prueba.
    Article article = articleRepository.findById(articleId).orElseThrow();

    // Dentro del contexto de persistencia activo, la relación perezosa se puede cargar.
    assertThat(article.getCategory().getCode()).isEqualTo("java");
}

Si tienes activado el log de SQL, lo más probable es que veas que la consulta a la categoría no ocurre en el findById, sino precisamente en el getCode(). Pero aunque el log esté desactivado, el hecho principal aquí es: «dentro del contexto activo, todo está bien».

Mini-ejemplo 3: acceso positivo a la colección de adjuntos

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

@Test
void attachments_are_accessible_inside_transaction() {
    // Leemos la entidad dentro de la transacción de prueba.
    Article article = articleRepository.findById(articleId).orElseThrow();

    // size()/iteración sobre la colección puede llevar a SQL, pero dentro del contexto es seguro.
    assertThat(article.getAttachments()).hasSize(2);
}

Una vez más: estas pruebas son útiles, pero casi siempre son «demasiado amables». No crean las condiciones en las que ocurre el fallo en la aplicación real.

5. Límite del contexto: managed vs detached

Para entender por qué ocurre LazyInitializationException, es útil tener en mente dos estados de la entidad: gestionada (managed) y desprendida (detached). Esto no es filosofía, es casi mecánica cotidiana.

Una entidad gestionada es aquella que el contexto de persistencia mantiene bajo control. Hibernate lo sabe todo sobre ella: rastrea los cambios, puede cargar relaciones perezosas, puede hacer dirty checking y puede, inesperadamente (para un principiante), ejecutar SQL.

Una entidad desprendida es una entidad «fuera del contexto». Tiene el aspecto de un objeto Java normal, pero el ORM ya no puede atenderla. Y si contiene una referencia perezosa no inicializada (proxy), al intentar acceder a ella Hibernate dice: «Ya no tengo acceso a la base de datos, no sé adónde ir».

Aquí tienes una pequeña tabla de referencia, no para memorizar, sino para no confundir las sensaciones.

Estado de la entidad Quién «sostiene» el objeto ¿Se puede cargar lazy? Lo que suele ocurrir
managed contexto de persistencia sí «la magia funciona»
detached nadie no a menudo LazyInitializationException

Ahora lo más importante: en @DataJpaTest podemos convertir artificialmente el objeto en desprendido, incluso sin salir de la prueba, simplemente limpiando el contexto de persistencia.

Mini-ejemplo: clear() como «límite»

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

// Limpiamos el contexto de persistencia: las entidades cargadas se vuelven desprendidas.
entityManager.clear();

// Si category no se inicializó antes de clear(), el intento de acceso puede fallar.
article.getCategory().getCode(); // aquí puede ocurrir LazyInitializationException

Sí, la transacción de la prueba sigue activa, pero el objeto concreto ya no es gestionado. Para los proxies perezosos, esto suele significar «estoy solo y tengo miedo».

6. Prueba de LazyInitializationException

Ahora hagamos una prueba que realmente muestre el problema. Su propósito no es «romper el código por placer», sino ver que si el objeto sale del contexto de persistencia, la relación perezosa puede convertirse en una mina.

Una disciplina muy importante: mientras preparas el escenario, no toques la relación perezosa «accidentalmente». Cualquier llamada temprana como article.getCategory().toString() o el registro del objeto puede inicializar la relación inesperadamente, y la prueba dejará de ser honesta.

Mini-ejemplo 1: importación de LazyInitializationException

import org.hibernate.LazyInitializationException;

// Esta es una excepción específica de Hibernate: a menudo se espera en las pruebas de lazy.

Esta excepción es específica de Hibernate. En la práctica, es la que más a menudo atrapamos cuando hablamos de lazy.

Mini-ejemplo 2: comprobamos el fallo después de limpiar el contexto

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

@Test
void category_is_not_accessible_after_detach() {
    // Importante: antes de clear() NO accedemos a article.getCategory(),
    // de lo contrario la relación podría inicializarse y la prueba perdería su sentido.
    Article article = articleRepository.findById(articleId).orElseThrow();

    // Creamos el límite: hacemos el objeto detached.
    entityManager.clear();

    // Después de detach, Hibernate no puede cargar el proxy -> esperamos LazyInitializationException.
    assertThatThrownBy(() -> article.getCategory().getCode())
            .isInstanceOf(LazyInitializationException.class);
}

Esta prueba hace algo importante: fija el límite. Dentro del contexto, el acceso era normal; después de desprender, es potencialmente peligroso.

Mini-ejemplo 3: la misma idea para la colección

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

@Test
void attachments_are_not_accessible_after_detach() {
    // De nuevo: no tocamos attachments hasta clear() para no inicializar la colección antes de tiempo.
    Article article = articleRepository.findById(articleId).orElseThrow();

    // Separamos el objeto del contexto de persistencia.
    entityManager.clear();

    // Cualquier acceso real a la colección (size/iteración) puede intentar ir a la BD.
    assertThatThrownBy(() -> article.getAttachments().size())
            .isInstanceOf(LazyInitializationException.class);
}

Si ves este escenario en las pruebas, no significa «JPA es malo». Significa que has encontrado un punto donde tu código o la arquitectura de lectura deben decidir cuándo y dónde quieres tener acceso a los datos relacionados.

7. Patrón de prueba: flush -> clear -> read -> clear

En este momento surge a menudo confusión: «Espera, ya hicimos clear antes para una lectura honesta de la base de datos. Ahora hacemos otro clear por lazy… ¿Son dos clear diferentes?» Sí, y es normal. Simplemente tienen propósitos diferentes.

El primer flush -> clear suele ser necesario para no verificar un objeto que aún no se ha escrito realmente en la base de datos. Es honestidad con respecto a la BD.

El segundo clear después de la lectura es honestidad con respecto al límite del contexto de persistencia. Es una comprobación de «qué pasará si el objeto sale fuera».

Es útil tener en mente este escenario (como un minidiagrama de flujo) para no tener que reinventarlo cada vez.

flowchart TD
    A[Preparar entidades] --> B[flush]
    B --> C[clear]
    C --> D[Volver a leer Article desde el repositorio]
    D --> E{"¿Queremos verificar lazy?"}
    E -->|Sí| F["clear / detach"]
    F --> G[Acceso a la lazy-relationship]
    G --> H[Esperamos exception o demostramos safe-load]
    E -->|No| I[Assertions habituales]

Este esquema es especialmente útil para los principiantes porque ayuda a no mezclar los dos significados diferentes de «limpiar».

Mini-ejemplo: patrón de prueba «bifásico»

// 1) Aseguramos que los cambios realmente se hayan ido a la BD.
entityManager.flush();

// 2) Limpiamos el contexto para que la siguiente lectura sea "honesta" (no desde la caché de 1er nivel).
entityManager.clear();

// 3) Leemos de nuevo como lo haría el código normal a través del repositorio.
Article article = articleRepository.findById(articleId).orElseThrow();

// 4) Y de nuevo clear — ahora para verificar el límite managed/detached.
entityManager.clear(); // creamos el límite

En una prueba real, entre estas líneas habrá más código, pero la lógica es exactamente esta: primero releemos honestamente de la base de datos, luego «despegamos» honestamente el objeto del contexto.

8. Estrategias: cuándo falla lazy

Cuando atrapas una LazyInitializationException, la primera reacción del principiante suele ser: «Vale, hagamos FetchType.EAGER en todas partes y el problema desaparecerá». Sí, desaparecerá… más o menos como desaparece el problema de la falta de tiempo si dejas de dormir. Técnicamente sobrevives, pero la calidad de vida se resiente.

Necesitamos estrategias que resuelvan el problema en el punto adecuado, no que «desactiven la pereza como concepto».

Estrategia 1: trabajar con las relaciones dentro de la transacción

Si tu aplicación lee una entidad y la mapea inmediatamente a un DTO, la opción más natural es hacerlo dentro del límite de @Transactional. Entonces lazy puede cargarse en el momento del mapeo y no te llevas fuera una entidad «semicargada» con proxies no inicializados.

Es importante no convertir esto en la idea de «transacción para todo». La idea es más simple: si sabes con certeza que el DTO requiere la categoría y la lista de adjuntos, haz el mapeo allí donde el contexto esté garantizadamente vivo.

Estrategia 2: método de repositorio que cargue previamente la relación necesaria (fetch join)

A veces es más simple y honesto decir directamente: «Para este caso de uso, necesito el artículo junto con la categoría». Entonces creas un método de repositorio separado que lea el artículo con join fetch. Esto no es necesariamente «sobre optimización», es ante todo sobre predecibilidad: garantizas que después de la lectura la categoría ya está en memoria.

Mini-ejemplo para la categoría:

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);
// Comentario: join fetch obliga a Hibernate a cargar a.category en la misma consulta,
// para que no haya "sorpresas" al acceder a la relación después de salir de los límites del contexto.

Análogamente, se puede hacer un método para los adjuntos. Solo es importante recordar: attachments es una colección, y un fetch join sobre una colección cambia la forma del resultado (pueden aparecer filas duplicadas), por lo que estos métodos deben escribirse con cuidado y conciencia. No estamos entrando en optimización, la idea es simple: fetch join es «cargar de inmediato lo que se va a necesitar».

Estrategia 3: no sacar la entidad fuera en absoluto (proyecciones / DTO read-model)

En nuestro ContentHub, esta es la filosofía básica: la API funciona a través de DTO. A nivel de repositorio o servicio, se puede construir un modelo de lectura de modo que hacia fuera vayan los datos ya preparados, no las entidades con campos perezosos.

Incluso si aún no escribes proyecciones complejas, es importante entender la idea: LazyInitializationException casi siempre ocurre en el punto de unión entre «sacamos la entidad» y «recordamos tarde que necesitábamos datos relacionados».

Advertencia sobre EAGER

A veces EAGER está realmente justificado, pero debe ser una decisión consciente. Si simplemente haces todo eager, es muy fácil llegar a una situación en la que «un artículo → una categoría → algo más → algo más» se convierta en un grafo de carga impredecible. Y luego alguien solo quería mostrar una lista de artículos, y el ORM en ese momento decidió que no estaría mal calentar toda la biblioteca del mundo.

9. Errores típicos en data-tests con lazy

Error n.º 1: «Si la prueba es verde, lazy es seguro».
A menudo, la primera prueba en la que lees article.getCategory().getCode() funciona perfectamente. Y empiezas a confiar. Pero esa prueba verifica el comportamiento dentro de la transacción de prueba, donde el contexto de persistencia vive al lado como un compañero fiel. Para que la prueba sea honesta, hay que crear el límite mediante clear()/detach y comprobar el comportamiento ya «desde fuera».

Error n.º 2: inicializar accidentalmente la relación perezosa antes de crear el límite.
Es un clásico: haces un System.out.println(article) o registras el objeto, y su toString() accede inesperadamente a getCategory() e inicializa el proxy. Después haces clear() pensando que estás comprobando el fallo, pero en realidad la relación ya se había cargado y la prueba «no ve el problema». En estas pruebas, es mejor evitar el registro superfluo y mantener el código lo más directo posible.

Error n.º 3: intentar curar todo con FetchType.EAGER porque «es más sencillo».
EAGER a menudo hace que el problema de LazyInitializationException sea menos visible. Pero no hace que las lecturas sean predecibles y casi siempre aumenta la carga de trabajo de la base de datos. En un proyecto de aprendizaje, esto es especialmente peligroso: puedes «esconder» accidentalmente toda una clase de defectos y luego no entender por qué en un proyecto real todo va lento.

Error n.º 4: mezclar en una misma prueba demasiadas causas de fallo.
Si en un mismo escenario compruebas relaciones, cascadas, orphan removal y además intentas atrapar una LazyInitializationException, cuando falle estarás mucho tiempo adivinando qué se rompió exactamente. Las pruebas de lazy aman especialmente la pureza: un riesgo, un límite, un resultado esperado.

Error n.º 5: esperar de clear() un «cierre mágico de la transacción».
entityManager.clear() limpia el contexto de persistencia y convierte los objetos en desprendidos, pero no es lo mismo que «cerrar la aplicación» o «finalizar la solicitud». Por lo tanto, es importante entender el sentido de la técnica: no estamos «simulando toda la producción», estamos creando un límite suficientemente estricto para ver que lazy necesita un contexto vivo. Si pierdes el sentido, la prueba se convierte en brujería.

1
Tarea
Spring Test, nivel 17, lección 2
Bloqueada
Categoría lazy dentro de la transacción de prueba
Categoría lazy dentro de la transacción de prueba
Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION