1. El papel del registro SQL en @DataJpaTest
Si alguna vez has mirado un fallo en una prueba JPA y pensado: "Bueno… qué exactamente salió mal?", entonces ya estás listo para el registro SQL. En las pruebas de datos verificamos el comportamiento de persistencia, y este siempre se traduce en SQL al final. Y cuando una prueba falla, es útil ver no solo "Se produjo una excepción", sino también "qué consulta exacta se intentó ejecutar y por qué la base de datos dijo «no se puede»".
Imagina que JPA es un camarero muy diligente. Tú dices: «Tráeme un artículo, guárdalo, actualiza el título», y el camarero se va a la cocina (a la base de datos) y vuelve con el resultado. A veces trae algo incorrecto (error de mapeo), a veces la cocina se niega a cocinar (violación de restricción), a veces el camarero ni siquiera fue a la cocina porque olvidaste pedirle que «entregara el pedido» (flush()). El registro SQL es la oportunidad de asomarte a la cocina y ver: qué se cocinó realmente y en qué momento el cocinero tiró el cucharón.
Una idea útil: el registro SQL no es necesario para «convertirse en DBA en 10 minutos», sino para dejar de adivinar en las pruebas. En JPA hay mucha magia oculta, y es mejor no odiar la magia, sino hacerla observable.
Un pequeño esquema de lo que queremos «iluminar»:
flowchart TD
T["Prueba JUnit (@DataJpaTest)"] --> PC["Contexto de persistencia entidades gestionadas"]
PC --> |"flush()"| SQL["Sentencias SQL (insert/update/select)"]
SQL --> DB["(Base de datos)"]
DB -->|restricciones / errores| EX["Excepción"]
DB -->|datos| R["Conjunto de resultados"]
R --> T
2. Dos fuentes de SQL en Spring Boot
Cuando los principiantes activan la salida SQL por primera vez, a menudo lo hacen «como salga», y luego se sorprenden de por qué la consola se convierte en una cascada de texto. En Spring Boot suele haber dos caminos populares: rápido y tosco (spring.jpa.show-sql=true) y otro un poco más maduro (registro mediante categorías de Hibernate). Ambos son útiles si entiendes qué esperas de ellos.
spring.jpa.show-sql=true es como encender una bombilla que dice "muéstrame el SQL ahora mismo". Muestra las consultas de manera bastante directa, a menudo con un prefijo como Hibernate:. Es cómodo para un primer contacto e investigaciones rápidas. Pero este modo tiene particularidades: a menudo escribe no donde van los registros normales (puede escribir en stdout), se controla peor por nivel, y el formato a veces no es el más legible.
El registro de Hibernate mediante logging.level... es un enfoque más «de mando», porque controlas el nivel de logs como todos los demás logs de la aplicación. Además, puedes activar por separado las propias consultas (SQL), por separado los valores de los parámetros (bind parameters), y por separado los errores. Este enfoque suele ser más agradable cuando quieres «activar SQL solo para una clase de prueba» y luego desactivarlo tranquilamente.
Hay otro aspecto importante: las cadenas SQL a menudo se ven como ... values (?, ?, ?) — con interrogaciones en lugar de valores. Esto es normal: JDBC usa sentencias preparadas. A veces es suficiente ver el hecho mismo del INSERT/UPDATE/SELECT. Pero otras veces necesitas entender qué valores realmente se enviaron a los parámetros (especialmente cuando atrapas un null en una columna not‑null). Entonces se activa el registro de bind de parámetros. Es potente, pero ruidoso — y en producción real es peligroso hacerlo (podrías registrar accidentalmente datos sensibles). En las pruebas normalmente podemos permitirnos un poco más de franqueza.
Para el camino práctico que sigue, tomaremos la variante basada en logger mediante logging.level.org.hibernate.SQL=DEBUG: se integra mejor con los logs normales de la prueba y es cómodo activarlo de forma puntual para una sola clase. show-sql, los logs de bind y application-test.yml son útiles como alternativas, pero no deben ocupar la mitad de la atención.
3. Activación del registro SQL para una sola prueba
Para una investigación normal en @DataJpaTest, basta con una ruta por defecto: activar Hibernate SQL como un logger normal directamente en la anotación de la prueba. Así las consultas van por el mismo canal que el resto de logs de prueba, es fácil activarlas localmente y también quitarlas.
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
// Default recomendado: SQL va como parte de los logs normales de esta clase de prueba
@DataJpaTest(properties = {
"logging.level.org.hibernate.SQL=DEBUG",
"spring.jpa.properties.hibernate.format_sql=true"
})
class ArticleRepositoryDataJpaTest {
}
format_sql=true aquí solo hace que la salida sea más legible. Esto ya es suficiente para relacionar una línea de la prueba con un insert/update/select y entender rápidamente dónde exactamente JPA accedió a la base de datos.
4. Cómo leer el registro: «línea de prueba → SQL → efecto en la BD»
Activar el registro SQL es solo la mitad de la victoria. La otra mitad es aprender a leerlo de modo que responda a la pregunta «por qué falló la prueba», en lugar de convertirse en ruido sin sentido. La buena noticia es que en las pruebas de datos los patrones son muy repetitivos. Si entiendes dónde en la prueba ocurre un flush, dónde un clear, y dónde una relectura, empiezas literalmente a “ver” el SQL con los ojos.
Es útil tener en mente una tabla simple «acción en la prueba → qué suele ocurrir a nivel SQL»:
| Qué haces en la prueba | Qué (normalmente) verás en el registro SQL | Por qué ocurre |
|---|---|---|
| entityManager.persist(article) | A menudo nada (o muy poco) | El objeto se vuelve managed, pero el SQL puede retrasarse hasta flush() |
| repository.save(article) | Puede no haber nada hasta flush() | save() no está obligado a escribir inmediatamente en la BD; JPA puede acumular cambios |
| entityManager.flush() | insert / update | Esto es «llevar el pedido a la cocina»: los cambios se envían realmente a la BD |
| Modificaste campos de una entidad managed | Nada de inmediato | JPA recuerda que el objeto está sucio (dirty) |
| flush() después del cambio | update ... | JPA hace dirty checking y genera un UPDATE |
| entityManager.find(...) sin clear() | A menudo no hay select | JPA puede devolver el mismo objeto desde la caché de 1er nivel |
| clear() + find(...) | select ... | El contexto está vacío, por lo que es necesario leer realmente de la BD |
A continuación, draftArticle("...") es una abreviatura para el mismo Article mínimamente válido, donde los campos obligatorios y Category ya están preparados por un helper. Para el registro SQL, lo importante no es la preparación en sí, sino la huella de insert/update/select.
Ahora fijemos esto con una prueba pequeña pero ilustrativamente «paso a paso». Está escrita especialmente para que en el código se vea qué esperamos exactamente ver en el registro.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
// Activamos la salida SQL localmente para que esta prueba se lea como un escenario + «rayos X»
@DataJpaTest(properties = "spring.jpa.show-sql=true")
class ArticleSqlLogDataJpaTest {
@Autowired TestEntityManager entityManager;
@Test
void sqlLog_showsInsertUpdateSelect() {
// La fixture crea un artículo válido por defecto
var article = draftArticle("java-basics");
// Persist por sí mismo puede no generar SQL — por eso hacemos flush() inmediatamente
entityManager.persist(article);
entityManager.flush(); // esperamos INSERT
// Cambiamos la entidad managed: el UPDATE solo ocurrirá al hacer flush()
article.setTitle("Updated title");
entityManager.flush(); // esperamos UPDATE
// Queremos un SELECT «honesto» desde la BD, no desde el contexto de persistencia
entityManager.clear();
entityManager.find(article.getClass(), article.getId()); // esperamos SELECT
}
}
Sí, aquí hay «magia» en draftArticle(...) — usamos una fixture de prueba para no tener que detallar los campos obligatorios del artículo cada vez. En los registros (de forma simplificada) normalmente verás algo muy parecido a:
Hibernate: insert into articles (...) values (?, ?, ?, ...)
Hibernate: update articles set title=? ... where id=?
Hibernate: select a1_0.id, a1_0.title, a1_0.slug ... from articles a1_0 where a1_0.id=?
Tres líneas de registro — y ya tenemos una «radiografía» del comportamiento. Si la prueba falla, entiendes de inmediato en qué paso está el problema: en el insert, update o select.
Otro micro-consejo útil: deja pequeños comentarios en la prueba como «esperamos INSERT». No es por estética. Hace que la prueba sea legible como un guion y facilita enormemente la correspondencia entre «línea de prueba → línea de SQL».
Si el registro estándar no es suficiente
A veces se necesitan otros modos, pero es mejor activarlos ya bajo un problema concreto, no por defecto:
— spring.jpa.show-sql=true es la forma más rápida de echar un vistazo al SQL, pero se controla de forma más tosca y a menudo escribe en stdout;
— el registro de parámetros como logging.level.org.hibernate.orm.jdbc.bind=TRACE es útil cuando estás buscando un null o valores de bind incorrectos; el nombre de la categoría depende de la versión de Hibernate, y este modo es muy ruidoso;
— application-test.yml es cómodo si el mismo registro SQL es necesario para un conjunto completo de pruebas, no para una investigación local.
5. Casos de fallos en pruebas de datos mediante el registro SQL
Ahora viene una parte que parece de detectives. Pero sin sombreros ni lupas — solo JUnit, JPA y tu voz interior que susurra: «¿Por qué ha fallado justo ahora?». En cada caso mantendremos la prueba corta a propósito, para que el registro se lea fácilmente. Cuando una prueba se convierte en un «escenario universal para todo», el registro SQL se vuelve ilegible. Por eso seremos disciplinados, aunque tengamos muchas ganas de ser creativos.
Caso 1: unique en slug — el error aparece «donde no se espera»
Clásico de ContentHub: slug debe ser único. Guardamos un artículo con slug = "same-slug", todo bien. Luego guardamos un segundo con el mismo slug — y esperamos que el error aparezca «en la segunda línea de persist». Pero la mayoría de las veces aparece… en flush(). Porque es flush() el que realmente envía el SQL a la base de datos.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DataJpaTest(properties = "spring.jpa.show-sql=true")
class ArticleSlugUniqueSqlLogTest {
@Autowired TestEntityManager entityManager;
@Test
void duplicateSlug_failsOnFlush() {
// Inserción n.º 1 se realiza con éxito
entityManager.persist(draftArticle("same-slug"));
entityManager.flush(); // INSERT n.º 1
// Inserción n.º 2 con el mismo slug debe explotar en flush(), no en persist()
entityManager.persist(draftArticle("same-slug"));
assertThatThrownBy(entityManager::flush) // INSERT n.º 2 -> boom
// Aquí nos importa el hecho mismo del fallo en el segundo INSERT;
// el tipo exacto de excepción depende del proveedor y envoltorios.
.isInstanceOf(Exception.class);
}
}
Si el registro SQL está activado, verás aproximadamente esta historia:
Hibernate: insert into articles (...) values (?, ?, ?, ...)
Hibernate: insert into articles (...) values (?, ?, ?, ...)
-- luego la excepción por violación de unicidad
Y aquí el registro te da lo más valioso: la confirmación de que el problema está realmente en el segundo insert, no en algún otro lugar. Incluso si el stacktrace es enorme y se envuelve en varias excepciones, el registro SQL muestra la secuencia real de eventos.
Un matiz importante: el texto del error puede diferir según la base de datos. En unos será “duplicate key”, en otros “unique index violation”. No te obsesiones con la redacción. Tu tarea como autor de la prueba es entender qué consulta violó el invariante y demostrar que el invariante realmente está impuesto por la base de datos.
Caso 2: not null — inserción de un artículo «casi correcto» y un misterioso null
El segundo escenario popular: el artículo parece «casi normal», pero olvidaste un campo que en la base de datos no puede ser null. Por ejemplo, title. En Java es fácil pasarlo por alto, porque el objeto “se creó”, los setters funcionan, el compilador no se queja. Pero la base de datos no es psicóloga, no cree en el potencial.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@DataJpaTest(properties = "spring.jpa.show-sql=true")
class ArticleNotNullSqlLogTest {
@Autowired TestEntityManager entityManager;
@Test
void nullTitle_failsOnInsertFlush() {
// Creamos un objeto válido y rompemos exactamente una regla: title se vuelve null
var article = draftArticle("java-basics");
article.setTitle(null);
entityManager.persist(article);
// El error llegará precisamente en flush(), porque es ahí donde se envía el INSERT a la BD
assertThatThrownBy(entityManager::flush)
.isInstanceOf(Exception.class);
}
}
El registro SQL ayuda de forma simple: ves que realmente hubo un insert. Esto es importante, porque a veces los novatos piensan: “JPA es inteligente, seguramente no insertó nada”. Pero sí insertó — y la base de datos lo rechazó.
Si has activado el registro de parámetros (bind), puedes incluso ver qué parámetro se fue como null. Esto es especialmente útil cuando el objeto es grande y hay muchas restricciones not‑null, y solo con el stacktrace no siempre está claro qué campo resultó realmente vacío.
Es aquí donde empiezas a apreciar draftArticle(...) como fixture: debe crear un objeto lo más válido posible por defecto. Y la prueba debe romper exactamente una regla, para que la investigación sea breve.
Caso 3: «¿Dónde está mi UPDATE?» — la prueba falla por un flush() omitido
Este escenario es especialmente traicionero, porque no trata de «una restricción de la base de datos», sino de nuestra disciplina con flush. Actualizaste un campo en una entidad managed y estás seguro de que “bueno, ha cambiado, así que en la base de datos ha cambiado”. Luego haces un clear() y vuelves a leer — y ves el valor antiguo. La prueba falla y te sientes engañado. Pero en realidad olvidaste pedir al camarero que llevara los cambios a la cocina.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest(properties = "spring.jpa.show-sql=true")
class ArticleUpdateSqlLogTest {
@Autowired TestEntityManager entityManager;
@Test
void updateIsNotInDbWithoutFlush() {
var article = draftArticle("java-basics");
entityManager.persist(article);
entityManager.flush(); // INSERT
// Cambiamos el campo en la entidad managed...
article.setTitle("Changed only in memory");
// ...y acto seguido «perdemos» los cambios, porque limpiamos el contexto ANTES de flush()
entityManager.clear(); // limpiamos el contexto antes de flush()
// Leemos honestamente desde la BD: ahí sigue el título antiguo, porque no hubo UPDATE
var reloaded = entityManager.find(article.getClass(), article.getId());
assertThat(reloaded.getTitle()).isEqualTo("Java Basics");
}
}
Si miras el registro SQL, la investigación ocurre por sí sola. Verás un insert, luego un select — y ningún update entre ellos. Esa es la respuesta a la pregunta «por qué la base de datos no vio los cambios».
Hibernate: insert into articles (...) values (?, ?, ?, ...)
Hibernate: select ... from articles ... where id=?
Se vuelve evidente: para que el update llegue a la BD, había que hacer un segundo flush() después de cambiar el campo, y dejar el clear() después de él.
Este caso es un excelente ejemplo de cómo el registro SQL transforma un «parece que JPA se rompió» en «ajá, me salté un paso en el escenario».
6. El registro SQL no es una aserción
Cuando ves el registro SQL por primera vez, aparece la tentación: «¡Oh! Vamos a verificar que hubo exactamente un INSERT y exactamente un SELECT». Parece lógico, pero en la mayoría de las pruebas de datos comunes es una mala idea. El SQL es un detalle de implementación de tu capa de persistencia, y puede cambiar por cosas que no rompen el comportamiento de negocio: cambio de dialecto, actualización de Hibernate, cambio en la estrategia de generación de nombres, incluso pequeños cambios en el mapeo.
Por ahora, nuestro objetivo es aprender a hacer que el comportamiento de JPA sea observable y honesto, no vincular las pruebas a un texto específico de consultas. Por lo tanto, el registro SQL es una herramienta de diagnóstico y comprensión, y las aserciones las construimos sobre cosas más estables: la relectura de datos, el hecho de la violación de una restricción, la presencia de un registro y la corrección de los campos.
Una buena fórmula interna para hoy es: «Leo el SQL con los ojos, pero verifico el comportamiento a través del repositorio y la relectura». El registro ayuda a explicar el fallo, no a convertirse en el objetivo de la prueba.
7. Errores típicos al leer el registro SQL
Casi todo el mundo tiene al principio dos reacciones al registro SQL: «¡Oh, ahora lo entiendo todo!» o «¿Qué es este ruido? ¡No entiendo nada!». Normalmente el problema no eres tú ni JPA, sino algunos hábitos típicos que hacen que el registro sea inútil. Si los detectas de antemano, investigarás los fallos mucho más tranquilamente y, lo que es importante, más rápido.
Error n.º 1: activar el registro SQL para todo el suite de pruebas y ahogarse.
Si activas show-sql o el registro SQL de Hibernate globalmente, la consola se convierte en una serie de 300 temporadas: interesante, pero no te da tiempo de vida. Es mejor activar el registro SQL de forma puntual para una clase de prueba o durante un breve periodo de investigación. Así lees unas pocas líneas y las relacionas inmediatamente con los pasos del escenario.
Error n.º 2: esperar SQL después de persist() y sorprenderse de que no haya.
persist() hace que el objeto sea managed, pero no está obligado a enviar el insert inmediatamente. JPA puede acumular cambios hasta el flush(). Por lo tanto, «silencio en el registro» después de persist() suele ser una situación normal, no un bug. Si quieres ver el momento de la escritura real, pon flush() en el lugar del escenario donde esperas un comportamiento en la BD.
Error n.º 3: olvidar clear() y esperar un select que no aparecerá.
Si haces un find() y no ves un select — no significa que JPA “no lea la base de datos”. Quizás ya conoce ese objeto y lo devuelve desde el contexto de persistencia. Para una relectura honesta, haz clear() y luego lee de nuevo. Entonces el select normalmente aparecerá y el registro comenzará a coincidir con tu escenario mental.
Error n.º 4: leer la excepción sin relacionarla con un paso SQL concreto.
En JPA y Spring, las excepciones a menudo se envuelven en capas: JDBC → Hibernate → Spring. Como resultado, el stacktrace es largo y “dónde falló” no es evidente. El registro SQL soluciona esto, pero solo si miras conscientemente: qué consulta fue la última antes del error, y en qué flush (o lectura) se invocó.
Error n.º 5: intentar «verificar el SQL» en lugar de verificar el resultado en la base de datos.
El registro SQL puede cambiar incluso si tu comportamiento sigue siendo correcto. Por lo tanto, es mejor mantener el registro como herramienta de explicación, y la verificación como una relectura honesta mediante clear y una nueva lectura. Si la prueba comienza a depender del texto del SQL, se vuelve frágil y fallará por cosas que no son en absoluto una regresión del producto.
GO TO FULL VERSION