Proyecto compartido. En el taller de Intermodular que abre esta semana has trabajado preparar el ci de la versión persistente. En Servidor continúas la implementación del mismo producto.
Se explica
25 minutos · explicación y demostración
Las búsquedas y escrituras ya funcionan manualmente. Hoy las comprobarás con tests de repositorio. @DataJpaTest prepara la parte de persistencia para la prueba; los datos y el motor utilizados deben estar controlados para que el resultado sea repetible.
Por qué los mocks no sirven en el repositorio
En la sesión 17 aprendiste a probar la capa de servicio sustituyendo sus colaboradores por dobles de prueba. Tenía todo el sentido del mundo: queríamos comprobar las reglas de negocio aisladas de cualquier infraestructura.
En la capa de acceso a datos, sin embargo, la situación es exactamente la contraria. La única responsabilidad de un repositorio es comunicarse con la base de datos.
Si escribes un test de repositorio usando un mock:
// UN TEST INÚTIL: estás probando tu propio mock, no la base de datos
when(repositorio.findByPrioridad("alta")).thenReturn(List.of(tarea1));
No estás demostrando nada. Los errores reales de un repositorio nunca son de lógica Java:
- Una errata en un nombre de método que genera un SQL con una columna incorrecta.
- Una discrepancia de tipos entre Java y PostgreSQL (por ejemplo, enviar un
LocalDatedonde la columna espera unTIMESTAMP). - Una violación de longitud de cadena (
VARCHAR(20)) ignorada. - Una consulta que devuelve duplicados porque faltó un
DISTINCTen elJOIN.
Para que un test de repositorio tenga valor profesional, debe ejecutarse contra una base de datos real.
Pruebas de rebanada (Slice Testing) con @DataJpaTest
@SpringBootTest carga el contexto completo de la aplicación. Por defecto utiliza un entorno web simulado; solo levanta un servidor real con RANDOM_PORT o DEFINED_PORT. Para aislar una consulta usaremos @DataJpaTest, que carga los componentes de persistencia necesarios.
Spring Boot ofrece una solución elegante: las pruebas de rebanada (Slice Tests).
@DataJpaTest
La anotación @DataJpaTest desactiva la autoconfiguración global de Spring y carga únicamente la infraestructura de persistencia:
- Las entidades marcadas con
@Entity. - Las interfaces que extienden
JpaRepository. - El
EntityManagery la configuración de DataSource de Hibernate. - No carga controladores, ni servicios, ni servidores web.
El resultado es un test que arranca en una fracción de segundo y prueba exclusivamente el acceso a datos.
Probar contra PostgreSQL real, no contra H2
Por defecto, @DataJpaTest intenta buscar una base de datos embebida en memoria como H2. Ese es otro error clásico del sector.
H2 no soporta las mismas funciones, ni los mismos dialectos, ni el mismo comportamiento de secuencias que PostgreSQL. Un test que pasa en verde sobre H2 puede estrellarse estrepitosamente en producción contra PostgreSQL.
Para indicarle a Spring que use nuestra conexión real de PostgreSQL, añadimos la anotación:
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE).
Comprobar lo guardado y volver a cargarlo
Observa este test inicial: comprueba el número de resultados, pero aún no verifica los valores recargados de la base de datos:
@Test
void buscarPorPrioridad_trampa() {
// 1. Guardar
Tarea t = new Tarea("Revisar índices", "alta");
repositorio.save(t);
// 2. Buscar
List<Tarea> resultado = repositorio.findByPrioridad("alta");
// 3. Afirmar
assertThat(resultado).hasSize(1);
}
Al guardar una entidad, Hibernate la mantiene en su contexto de persistencia, también llamado caché de primer nivel.
findByPrioridad sí ejecuta una consulta SQL. Sin embargo, Hibernate puede reutilizar las instancias ya gestionadas al materializar sus resultados. Para comprobar también que los campos se han guardado y se reconstruyen correctamente, forzaremos la escritura y vaciaremos el contexto antes de consultar.
Usaremos TestEntityManager para controlar ese recorrido:
- persist(entidad)
- flush() [fuerza SQL]
- clear() [vacía memoria]
- repositorio.find() [obliga a SELECT]
flush(): obliga a Hibernate a vaciar su cola de escrituras pendientes y ejecutar inmediatamente elINSERTen PostgreSQL.clear(): vacía por completo el contexto de persistencia en memoria. La caché de primer nivel queda a cero.- Al llamar al repositorio después del
clear(), Hibernate está obligado a enviar una sentenciaSELECTpor el cable TCP a PostgreSQL y reconstruir el objeto a partir de los datos del disco.
Se trabaja
140 minutos · implementación guiada sobre el proyecto propio
Paso 1 · Retomar el proyecto y preparar la comprobación
- Abre los repositorios y enumera las consultas propias que has añadido en la sesión 21. Conserva un ejemplo que deba coincidir y otro que no.
- Localiza
src/test/javay revisa la configuración de la base de datos de pruebas indicada en esta sesión. Utiliza un entorno de prueba separado de producción. - Prepara los registros desde el propio test. No dependas de ids ni filas creadas manualmente durante el taller anterior.
Paso 2 · Escribir TareaRepositoryTest
Antes de crear la clase, prepara el entorno de pruebas:
- En la conexión de PostgreSQL de desarrollo crea una base diferente,
gestor_test. En pgAdmin abre Query Tool sobrepostgresy ejecuta solo la sentencia siguiente; si la base ya existe, omite la creación.
CREATE DATABASE gestor_test;
- Crea
src/test/resources/application-test.properties. Sustituyetu_usuario_localpor el usuario de tu PostgreSQL y configura TEST_DB_PASSWORD en la terminal o en la ejecución de tests del IDE.
spring.datasource.url=jdbc:postgresql://localhost:5432/gestor_test
spring.datasource.username=tu_usuario_local
spring.datasource.password=${TEST_DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=create-drop
spring.sql.init.mode=never
spring.jpa.show-sql=true
create-drop crea las tablas al iniciar el contexto de pruebas y las elimina al cerrarlo: esta URL debe apuntar exclusivamente a gestor_test. El perfil no debe activarse en el backend de desarrollo ni en producción. Si tu contenedor publica otro puerto, cambia solo ese puerto.
- Añade
import org.springframework.test.context.ActiveProfiles;y@ActiveProfiles("test")encima de cada clase de pruebas JPA. Conserva@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)para usar PostgreSQL. - En los ejemplos siguientes crea primero un Proyecto válido con los campos obligatorios de tu modelo, persístelo mediante
em.persist(proyecto)y asignatarea.setProyectoId(proyecto.getId())antes de cadaem.persist(tarea). Desde la sesión 23, cuando la relación sea un objeto, utilizatarea.setProyecto(proyecto)en su lugar. Los tests preparan sus propios datos; no dependen de ids de peticiones anteriores.
Prepara primero una base de datos exclusiva para pruebas y el perfil test del bloque siguiente; no ejecutes esta clase contra la base de desarrollo llena de registros, porque una consulta contaría también esas filas. Crea después TareaRepositoryTest.java bajo src/test/java y aplica @ActiveProfiles("test"). Cada test prepara sus propias entidades, fuerza las escrituras, vacía el contexto de persistencia y consulta. Si tu modelo requiere proyecto, crea y persiste ese proyecto antes de la tarea y asígnale su id; no retires la relación para simplificar el test.
package com.ejemplo.gestor.repository;
import com.ejemplo.gestor.model.Tarea;
import com.ejemplo.gestor.model.Proyecto;
import org.springframework.test.context.ActiveProfiles;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@DataJpaTest
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class TareaRepositoryTest {
@Autowired
private TareaRepository repositorio;
@Autowired
private TestEntityManager em;
private void persistirTarea(Tarea tarea) {
Proyecto proyecto = new Proyecto("Proyecto " + tarea.getTitulo(), "Datos del test");
proyecto.setActivo(true);
em.persist(proyecto);
tarea.setProyectoId(proyecto.getId());
em.persist(tarea);
}
@Test
@DisplayName("findByPrioridad devuelve solo las tareas que coinciden exactamente")
void findByPrioridad_cuandoHayCoincidencias_devuelveSoloCoincidentes() {
// Preparar (Arrange): persistir dos tareas en PostgreSQL
Tarea urgente = new Tarea("Corregir fuga de memoria", "alta");
Tarea normal = new Tarea("Actualizar README", "media");
persistirTarea(urgente);
persistirTarea(normal);
em.flush();
em.clear(); // Vaciar memoria para obligar a consultar a PostgreSQL
// Actuar (Act): ejecutar la consulta derivada
List<Tarea> resultado = repositorio.findByPrioridad("alta");
// Comprobar (Assert)
assertThat(resultado).hasSize(1);
assertThat(resultado.get(0).getTitulo()).isEqualTo("Corregir fuga de memoria");
assertThat(resultado.get(0).getPrioridad()).isEqualTo("alta");
}
@Test
@DisplayName("findByPrioridad devuelve lista vacía si ninguna coincide")
void findByPrioridad_cuandoNoHayCoincidencias_devuelveListaVacia() {
Tarea normal = new Tarea("Actualizar README", "media");
persistirTarea(normal);
em.flush();
em.clear();
List<Tarea> resultado = repositorio.findByPrioridad("baja");
assertThat(resultado).isEmpty();
}
@Test
@DisplayName("findByTituloContainingIgnoreCase ignora mayúsculas y encuentra fragmentos")
void findByTitulo_ignoraMayusculasYMinusculas() {
Tarea t1 = new Tarea("Aprender PostgreSQL y Spring", "alta");
Tarea t2 = new Tarea("Escribir documentación", "baja");
persistirTarea(t1);
persistirTarea(t2);
em.flush();
em.clear();
List<Tarea> resultado = repositorio.findByTituloContainingIgnoreCase("POSTGRE");
assertThat(resultado).hasSize(1);
assertThat(resultado.get(0).getTitulo()).isEqualTo("Aprender PostgreSQL y Spring");
}
@Test
@DisplayName("countByCompletadaFalse cuenta únicamente las pendientes")
void countByCompletadaFalse_cuentaSoloPendientes() {
Tarea pendiente1 = new Tarea("Tarea 1", "alta");
Tarea pendiente2 = new Tarea("Tarea 2", "media");
Tarea terminada = new Tarea(null, "Tarea 3", "baja", true);
persistirTarea(pendiente1);
persistirTarea(pendiente2);
persistirTarea(terminada);
em.flush();
em.clear();
long pendientes = repositorio.countByCompletadaFalse();
assertThat(pendientes).isEqualTo(2);
}
}
- Por qué cada test hace rollback automático
- Por defecto, cada método anotado con
@Testdentro de una clase con@DataJpaTestestá envuelto en una transacción. Al terminar la ejecución de cada test, Spring ejecuta un ROLLBACK automático. Esto garantiza que el primer test no deje datos que contaminen al segundo, logrando un aislamiento total y repetible.
Paso 3 · Ejecutar y comprobar en la terminal
Abre la terminal y ejecuta exclusivamente los tests de persistencia:
./mvnw test -Dtest=TareaRepositoryTest
Observa la salida en la consola:
[INFO] Running com.ejemplo.gestor.repository.TareaRepositoryTest
2026-09-02T11:20:01.120+02:00 INFO 54321 --- [main] o.s.b.t.a.j.DataJpaTestContextBootstrapper : Found @SpringBootConfiguration com.ejemplo.gestor.GestorApplication
2026-09-02T11:20:01.890+02:00 INFO 54321 --- [main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Added connection
2026-09-02T11:20:02.100+02:00 INFO 54321 --- [main] org.hibernate.dialect.Dialect : HHH000400: Using dialect: org.hibernate.dialect.PostgreSQLDialect
Hibernate: insert into tareas (completada, prioridad, titulo) values (?, ?, ?)
Hibernate: select t1_0.id, t1_0.completada, t1_0.prioridad, t1_0.titulo from tareas t1_0 where t1_0.prioridad=?
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.452 s
[INFO] BUILD SUCCESS
Fíjate en las consultas SQL:
- Verás el
insertforzado por elem.flush(). - Verás el
selectforzado por elem.clear()y la llamada al repositorio. - El test pasa en menos de 2 segundos contra tu PostgreSQL real.
Paso 4 · Tests para ProyectoRepository
Copia la estructura de anotaciones y configuración de TareaRepositoryTest a ProyectoRepositoryTest, cambiando repositorio y entidades. Escribe cada escenario con sus propios datos. Para probar una restricción, incluye persistir y hacer flush dentro de la operación cuya excepción compruebas: con ids IDENTITY, el INSERT y el error pueden producirse al persistir. Distingue null de texto vacío: nullable=false solo rechaza el primero.
- Crea
src/test/java/com/ejemplo/gestor/repository/ProyectoRepositoryTest.javacon@DataJpaTesty@AutoConfigureTestDatabase(replace = NONE). - Escribe un test para
findByActivoTrue():- Inserta un proyecto activo y otro inactivo (
activo = false). - Ejecuta
flush()yclear(). - Verifica que la lista devuelta tiene tamaño 1 y que su campo
activoestrue.
- Inserta un proyecto activo y otro inactivo (
- Escribe un test para
existsByNombre():- Inserta un proyecto con nombre
"Plataforma Educativa". - Comprueba que
existsByNombre("Plataforma Educativa")devuelvetrue. - Comprueba que
existsByNombre("Nombre Inventado")devuelvefalse.
- Inserta un proyecto con nombre
- Escribe un test que intente persistir una entidad con un campo no nulo vacío (o nombre duplicado si configuraste
@Column(unique = true)):- Comprueba que al ejecutar
em.flush()se lanza una excepción de integridad de datos (DataIntegrityViolationExceptionoConstraintViolationException).
- Comprueba que al ejecutar
Paso 5 · Comprobar y registrar el resultado del proyecto
- Ejecuta los tests dos veces y comprueba que producen el mismo resultado sin depender del orden de ejecución.
- Incluye una consulta con coincidencias, otra sin ellas y una operación de escritura. Explica qué demuestra cada aserción y qué base de datos se ha utilizado.
Ampliación si has completado el trabajo
Primero termina y verifica los pasos anteriores. Estos retos profundizan en el mismo contenido; no sustituyen la entrega ni obligan a iniciar otro proyecto.
Reto · La trampa de las excepciones diferidas
Investiga este fenómeno clave del funcionamiento de los motores ORM:
Imagina que quieres comprobar que la base de datos rechaza una tarea sin título (titulo = null), violando la restricción @Column(nullable = false):
@Test
void tareaSinTitulo_debeFallar_malEscrito() {
Tarea sinTitulo = new Tarea(null, "alta");
em.persist(sinTitulo);
// Omitimos em.flush();
}
- Ejecuta ese test sin llamar a
em.flush(). El test pasa en verde sin lanzar ninguna excepción. - La causa es que Hibernate retrasa (defers) las sentencias de inserción hasta el último momento previo a la confirmación de la transacción. Como el método termina sin forzar el envío, la transacción se cancela (rollback) y la sentencia
INSERTjamás llega a viajar a PostgreSQL. - Ahora añade
em.flush()y envuélvelo enassertThrows(ConstraintViolationException.class, () -> em.flush());. - Explica con tus palabras por qué
flush()es la única instrucción que convierte un deseo en memoria en una sentencia SQL física verificable.
TareaRepositoryTest escrito con @DataJpaTest, usando flush() y clear() con los 4 tests en verde.ProyectoRepositoryTest completo cubriendo findByActivoTrue y existsByNombre contra PostgreSQL real.assertThrows y la justificación técnica de las escrituras diferidas en Hibernate.Ver respuestas
1 · Carga entidades (@Entity), repositorios (JpaRepository) y el DataSource de JPA; ignora controladores (@RestController), servicios (@Service), filtros de seguridad y el servidor web embebido.
2 · Porque Hibernate resuelve la búsqueda contra la caché de primer nivel en la memoria RAM de la JVM, sin enviar la sentencia SELECT a PostgreSQL ni verificar si el SQL generado funciona realmente en el motor relacional.
3 · Porque cada método de test está envuelto en una transacción gestionada que ejecuta un ROLLBACK automático al finalizar.
4 · La instrucción em.flush() (o testEntityManager.flush()).
Cierre
15 minutos · resultado comprobable y explicación individual
Al terminar la sesión:
La misma consulta devuelve el resultado esperado en ejecuciones repetidas y una restricción incumplida se detecta.
Cada integrante explica una decisión del código apoyándose en una de las comprobaciones realizadas.