1. Prueba de regresión en ORM: el contrato
Cuando te enfrentas por primera vez a un problema de ORM, el cerebro naturalmente quiere «arreglarlo y olvidarlo». Pero Hibernate es como un gato: si una vez encuentra un resquicio, volverá allí una y otra vez, especialmente después de una «refactorización inofensiva». Por eso, una prueba de regresión es nuestra disciplina de memoria: convertimos el riesgo encontrado en una verificación que no permitirá que vuelva silenciosamente.
En las aplicaciones normales, la regresión a menudo se parece a «antes fallaba, ahora no falla». En la capa de persistencia es más interesante: podemos tener un resultado comercial correcto, pero un precio incorrecto para obtenerlo. Por ejemplo, la lista de pedidos se devuelve correctamente, pero de repente hace 57 consultas en lugar de 2. O la ficha del producto aún funciona, pero vuelve a arrastrar todo un grafo de relaciones, como si no fuera un backoffice, sino una aspiradora.
En una prueba de regresión ORM, es importante que el contrato casi siempre sea doble. Por un lado, verificas el resultado funcional (los datos son correctos). Por el otro, verificas la señal de persistencia (carga/SQL/flush) que es la verdadera causa de la mayoría de los «por qué va lento en producción».
A continuación, un práctico «mapa del terreno» que indica qué señales registramos precisamente en esta conferencia:
| Riesgo (qué puede volver) | Qué queremos registrar | Cómo lo medimos en la prueba |
|---|---|---|
| N+1 / selects secundarios sobrantes | Que no haya más consultas de las esperadas | recuento de consultas mediante Hibernate Statistics |
| Deslizamiento de proyección de vuelta a carga de entidades | Que en el caso de lectura no se carguen entidades en absoluto | entityLoadCount == 0 y el tipo de resultado sea DTO/record |
| «Lo cambié en memoria, pero en la base de datos parece que no cambió» (o al revés) | Que el momento en que el cambio se vuelve visible en la BD sea predecible | flush() + clear() + releer, y a veces entityUpdateCount |
La idea clave: una prueba de regresión ORM no debe pretender ser una «prueba universal para todo». Debe ser como una buena señal de tráfico: breve, concreta, y que explique exactamente dónde puedes acabar en la cuneta.
2. Recuento de consultas en el contrato de fetching
Si antes no comprobabas el número de consultas, es normal: la mayoría de la gente primero aprende «para que funcione». Pero en el mundo de Hibernate, «funciona» es el umbral mínimo, y luego comienza la vida adulta: «funciona de forma predecible» y «funciona con un precio normal». El recuento de consultas es una de las formas más sencillas y honestas de hacer observable el fetching.
Al mismo tiempo, es importante no caer en la trampa: «ahora voy a afirmar que hay exactamente 1 consulta, y seré feliz». Y luego añades un campo a la vista, y se convierten en 2 — y la prueba empieza a fallar no porque hayas roto la arquitectura, sino porque la vida es más compleja que una unidad. Por eso, normalmente registramos el recuento de consultas solo allí donde realmente es un contrato. Un ejemplo típico es un escenario que ya ha sufrido N+1 y se ha corregido mediante JOIN FETCH, EntityGraph, batch fetching o proyección.
Otro detalle: para que la prueba realmente detecte N+1, debes «tocar» aquellos campos y relaciones que toca el caso de uso real. Si la prueba simplemente llama al método del repositorio y no lee nada del resultado, las relaciones perezosas pueden no inicializarse — y simplemente no se producirá SQL. La prueba resultará «optimista», como una persona que ha comprobado que la alarma de incendios existe, pero nunca ha pulsado el botón de prueba.
Es útil imaginarse mentalmente una prueba de regresión de fetching como una pequeña cadena de montaje:
flowchart TD
A[Preparamos el fixture] --> B[flush + clear]
B --> C[Reiniciamos las estadísticas]
C --> D[Ejecutamos el caso de lectura]
D --> E[Tocamos los campos/relaciones necesarios]
E --> F[Verificamos los datos]
F --> G[Verificamos el recuento de consultas]
En esta cadena, los pasos que más a menudo se olvidan son «reiniciar las estadísticas» y «tocar las relaciones necesarias». Son precisamente los que separan una prueba que detecta una regresión real de una prueba que «siempre pasa con éxito».
3. Hibernate Statistics en @DataJpaTest
Para hacer aserciones de recuento de consultas sin magia ni bibliotecas externas, nos bastan las Hibernate Statistics. Es un mecanismo integrado de Hibernate que sabe contar diferentes eventos: cargas de entidades, número de declaraciones preparadas, número de flushes, etc. No sustituye al log de SQL (el log sigue siendo necesario), pero para las pruebas Statistics es más cómodo: proporcionan números sobre los que se pueden hacer aserciones.
El matiz es que las estadísticas deben estar activadas. En una aplicación normal, a menudo las mantienes desactivadas para no generar ruido ni añadir sobrecarga. En las pruebas, especialmente las de regresión, activarlas es un compromiso muy razonable. En nuestro Commerce Persistence Lab es precisamente una herramienta típica de lab support.
A continuación se muestra un helper minimalista que es útil tener en el paquete com.example.commerce.labsupport. Es pequeño, transparente y no oculta la mecánica (es decir, no crea una «caja negra» que luego nadie entiende).
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) {
// Obtenemos las Hibernate Statistics del EntityManagerFactory para medir el comportamiento del ORM en las pruebas
this.statistics = emf.unwrap(SessionFactory.class).getStatistics();
// En las pruebas es razonable activar las statistics de forma forzada para no depender de perfiles/configuraciones del entorno
this.statistics.setStatisticsEnabled(true);
}
public void clear() {
// Reiniciamos los contadores justo antes del escenario a medir para no afirmar el «ruido» del fixture
statistics.clear();
}
public long statements() {
// Número de sentencias SQL preparadas (un proxy cómodo para «cuántas consultas fueron a la BD»)
return statistics.getPrepareStatementCount();
}
public long entityLoads() {
// Cuántas entidades se cargaron como managed entities (importante para regresiones de DTO/proyección)
return statistics.getEntityLoadCount();
}
public long entityUpdates() {
// Cuántas entidades se actualizaron (útil para la observabilidad del ciclo de flush)
return statistics.getEntityUpdateCount();
}
}
Observa el estilo: no intentamos construir un «marco universal para pruebas». Nos bastan tres métodos: activar, limpiar y obtener el número de sentencias. Si quieres registrar también las cargas de entidades o las actualizaciones, añadirás métodos más tarde, pero siempre en pequeñas porciones.
Y aquí tienes un ejemplo de cómo en @DataJpaTest activar de forma garantizada la generación de estadísticas mediante propiedades (para no depender de una configuración aleatoria de perfiles):
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest(properties = {
// Activamos Hibernate Statistics en el contexto de la prueba
"spring.jpa.properties.hibernate.generate_statistics=true"
})
class StatsSmokeTest { }
Sí, parece aburrido. Pero es como el cinturón de seguridad: no es divertido hasta que te salva la vida.
4. Regresión de fetching: N+1 y pedidos
Lo mejor es escribir una prueba de regresión de fetching sobre un escenario que realmente «ha dolido». En Commerce Persistence Lab el candidato clásico es la lectura de la lista de pedidos para el backoffice, donde se muestra el número de pedido, el estado y el correo electrónico del cliente. Es una situación muy real: el desarrollador escribe findAll(), luego en el servicio hace order.getCustomer().getEmail(), y voilà — N+1.
En esta conferencia no volveremos a discutir cómo curar N+1 (ya se hizo en el módulo sobre fetching). Pero mostraremos cómo fijar el contrato «no más de una consulta para la selección de pedidos con clientes» mediante una prueba. Como ejemplo, supongamos que tenemos un repositorio de consultas PurchaseOrderQueryRepository que hace la lectura correctamente (por ejemplo, mediante 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 fija el contrato: customer se carga en la misma consulta, sin N+1
List<PurchaseOrder> findAllWithCustomer();
}
Ahora la prueba. El sentido de la prueba: ejecutamos el caso de lectura, hacemos lo mismo que haría el modelo de vista (leer el correo electrónico) y comprobamos que el número de sentencias SQL no se ha hinchado. El código siguiente es deliberadamente corto; la preparación del fixture la «dejamos fuera» (en un proyecto real podría ser un pequeño 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() {
// Reiniciamos las estadísticas justo antes de la medición para no incluir consultas del fixture/inicialización
stats.clear();
// Ejecutamos el caso de lectura
var orders = orderQuery.findAllWithCustomer();
// «Clic de prueba»: tocamos la relación perezosa igual que lo hace el caso de uso real
orders.forEach(o -> o.getCustomer().getEmail());
// Contrato: un SQL para la lista de pedidos + cliente (sin cargas adicionales)
assertEquals(1, stats.statements());
}
}
Aquí hay dos momentos «meticulosos» importantes que realmente hacen que la prueba sea adulta. En primer lugar, limpiamos las estadísticas justo antes de la medición; de lo contrario, incluirán consultas de la preparación de datos y estarás afirmando ruido. En segundo lugar, leemos explícitamente getCustomer().getEmail(). Este es nuestro «clic de prueba»: si alguien elimina JOIN FETCH, Hibernate comenzará a cargar perezosamente customer con consultas separadas, y el recuento de consultas aumentará.
Si te parece que assertEquals(1, ...) es demasiado estricto, piensas correctamente. En la realidad, a veces es más razonable fijar un límite superior, por ejemplo «no más de 2». Pero es útil empezar con un contrato lo más claro posible en un caso de lectura concreto; de lo contrario, la prueba se convierte en filosofía, y la filosofía no falla en CI (y en eso reside su debilidad).
5. Regresión de proyección: DTO sin carga de entidades
Con las proyecciones suele ocurrir una historia clásica. Haces con cuidado una proyección DTO para la lista de productos, todo se vuelve rápido y bonito. Luego alguien (quizás tú mismo dentro de un mes, en estado «estoy seguro de lo que hago») decide: «Vamos a devolver la entidad, es más cómodo, solo voy a añadir un campo». Y, de forma imperceptible para todos, la vuelve a cargar la entidad con todos sus beneficios: columnas sobrantes, cargas perezosas accidentales, sobrecarga de dirty checking (sí, incluso en la lectura) y, de nuevo, un precio impredecible.
Por lo tanto, una prueba de regresión de proyección no trata de «los datos llegaron». Trata de que no hemos cargado ninguna entidad, sino que hemos obtenido exactamente el modelo de lectura que habíamos diseñado.
Supongamos que en el catálogo hay un record que usamos como fila de tabla:
package com.example.commerce.catalog.dto;
// Modelo de lectura para lista/tabla: aquí no debe haber relaciones perezosas ni entidades managed
public record ProductRow(Long id, String sku, String name) { }
Y un repositorio de consultas que devuelve precisamente este modelo de lectura:
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: debemos obtener DTO, no una entidad managed
Optional<ProductRow> findRowById(@Param("id") Long id);
}
Ahora la prueba. Y aquí es donde funciona muy bien la estadística de recuento de cargas de entidades: si hacemos una constructor projection, Hibernate no debe cargar entidades como objetos managed. Por lo tanto, podemos esperar que entityLoadCount sea cero. Añadimos un par de métodos a HibernateStats (sí, extendemos un poco el helper, pero sigue siendo transparente y breve):
import org.hibernate.stat.Statistics;
public long entityLoads() {
// Cuántas entidades se cargaron como managed entities (para DTO/proyección esperamos 0)
return statistics.getEntityLoadCount();
}
Y la prueba:
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() {
// Importante: reiniciar las estadísticas, de lo contrario los contadores incluirán «ruido»
stats.clear();
// Ejecutamos el caso de lectura, que debe devolver un DTO
var row = productQuery.findRowById(1L).orElseThrow();
// Contrato principal: ninguna carga de entidad en una constructor projection
assertEquals(0, stats.entityLoads());
// Señal adicional: cuántas SQL se enviaron en este caso de lectura
assertEquals(1, stats.statements());
}
}
Observa la psicología de esta prueba: no solo comprobamos que «el producto se encontró», sino precisamente lo que es importante para la arquitectura de lecturas. Sí, assertEquals(1, statements()) también es útil, pero es secundario. Lo principal es que el caso de lectura no materializó el grafo de entidades.
Y otra idea que es útil tener en mente: las pruebas de proyección son especialmente valiosas precisamente porque protegen contra la refactorización «cómoda». Cuando devuelves una entidad, el código en el servicio a menudo se acorta… y luego pagas el impuesto en forma de caos SQL. La prueba aquí actúa como un adulto aburrido que dice: «No, no se puede».
6. Regresión de flush: el momento de visibilidad de los cambios
Flush es uno de esos mecanismos de Hibernate que al principio parecen «cocina interna aburrida», y luego de repente explican la mitad de los misterios del tipo «por qué una consulta de lectura provocó un UPDATE». En las pruebas de regresión, flush es interesante no como teoría, sino como un medio para hacer honesto el resultado de la verificación: queremos estar seguros de que estamos comprobando el estado en la base de datos, y no «en la memoria dentro del contexto de persistencia».
El antipatrón más común en las pruebas de escritura es este: cargamos la entidad, cambiamos un campo, la releemos al instante y nos aseguramos de que el campo ha cambiado. Esta prueba no demuestra casi nada, porque estás mirando el mismo objeto managed o la caché de primer nivel. En ese momento, Hibernate puede no haber enviado aún el SQL — y la prueba sigue siendo «verde».
Por lo tanto, el patrón básico de regresión de flush es: cambiamos la entidad, hacemos flush(), hacemos clear(), luego releemos y comprobamos.
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() {
// Cargamos la entidad (se vuelve managed en el contexto de persistencia actual)
var product = products.findById(1L).orElseThrow();
// Cambiamos el campo: esto es solo un cambio de objeto en memoria
product.setName("Updated");
// Sincronizamos forzadamente los cambios con la BD
em.flush();
// Desechamos los objetos managed para que la siguiente lectura vaya con seguridad a la BD
em.clear();
// Releemos y comprobamos el estado ya «con ojos de la base de datos»
assertEquals("Updated", products.findById(1L).orElseThrow().getName());
}
}
Esta pequeña construcción hace que la prueba sea honesta. flush() obliga a Hibernate a sincronizar los cambios con la base de datos, y clear() descarta los objetos managed del contexto de persistencia. La lectura final ya no puede «adivinar la respuesta correcta», debe ir a la base de datos.
Si quieres hacer que el comportamiento de flush sea aún más observable (y un poco menos «mágico»), puedes registrar que el UPDATE realmente ocurrió precisamente en el momento del flush. Para ello, la estadística entityUpdateCount es perfecta. El escenario es: cambiamos el campo, comprobamos que aún no hay actualizaciones, llamamos a flush, comprobamos que ha aparecido la actualización. Resulta casi como una «prueba del momento de la verdad».
import com.example.commerce.catalog.entity.Product; // Entidad Product (ajusta el paquete a tu proyecto)
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() {
// Cargamos la entidad managed en el contexto de persistencia
var product = em.find(Product.class, 1L);
// Reiniciamos los contadores antes del fragmento a medir
stats.clear();
// Cambiamos el campo: el SQL UPDATE aún no está obligado a ir a la BD
product.setName("Updated");
// Contrato: en el setter no se producen actualizaciones en la BD
assertEquals(0, stats.entityUpdates());
// Flush — el momento en que Hibernate debe sincronizar los cambios con la BD
em.flush();
// Contrato: después del flush debe aparecer un UPDATE
assertEquals(1, stats.entityUpdates());
}
}
Aquí captamos una idea muy importante del curso: Hibernate no realiza SQL «a cada movimiento de mano». El setter es solo un cambio de objeto. El SQL aparece en el ciclo de flush. Esta prueba no trata tanto del rendimiento como de la previsibilidad: si mañana empiezan a ocurrir actualizaciones «antes de tiempo» (por ejemplo, debido a un flush inesperado before query en otro lugar), pruebas como esta ayudarán a entender más rápido dónde ha cambiado el comportamiento.
7. Errores típicos en las pruebas de regresión
Error n.º 1: medir el recuento de consultas olvidando «tocar» las relaciones perezosas.
Si llamaste al método del repositorio y comprobaste inmediatamente stats.statements(), es posible que no hayas iniciado la carga perezosa. La prueba se vuelve ciega a N+1: el problema se manifestará en el código real (cuando accedas a getCustomer() o getItems()), pero la prueba estará alegremente verde. El estilo correcto es, después de la lectura, realizar las mismas operaciones sobre los datos que hace el caso de uso real.
Error n.º 2: contar consultas, pero no reiniciar las estadísticas antes del escenario.
Hibernate Statistics es un contador, no un telépata. Si no has hecho stats.clear(), incluirá consultas del fixture, de comprobaciones aleatorias, de inicializaciones perezosas en otro lugar. Como resultado, la prueba se volverá inestable: a veces falla, a veces pasa, y empiezas a sospechar de física cuántica. Normalmente basta con una regla: «clear justo antes de la medición».
Error n.º 3: comprobar la escritura sin flush() y sin clear(), y luego sorprenderse de «por qué la prueba no detecta errores».
Esta prueba solo comprueba el hecho de que el objeto managed cambió un campo. No demuestra que el SQL haya ido a la BD, y no protege de problemas del ciclo de flush. Si escribes una prueba de regresión sobre el comportamiento de persistencia, la comprobación final debe leer los datos como los leería un nuevo contexto de persistencia: mediante clear() y una nueva lectura.
Error n.º 4: fijar un número exacto de consultas allí donde el contrato es realmente «no más de N».
Las expectativas demasiado rígidas hacen que las pruebas sean frágiles. A veces tu caso de lectura requiere honestamente dos consultas (por ejemplo, una consulta count separada para la paginación), y eso es normal. En esos lugares, es mejor fijar un límite superior o comprobar una señal más significativa (por ejemplo, que no haya aparecido N+1), en lugar de convertir la prueba en un «cronómetro con micrómetro».
Error n.º 5: intentar protegerlo todo con una sola prueba.
Una prueba que comprueba simultáneamente fetching, proyección y flush suele convertirse rápidamente en un escenario largo e incomprensible. Es difícil de leer y explica mal qué se ha estropeado exactamente al fallar. Las regresiones ORM se protegen mejor con pruebas estrechas: un riesgo — un contrato — un método de prueba legible.
GO TO FULL VERSION