1. Valor de un pequeño real DB path
Cuando ya está claro que la base de datos embebida no siempre demuestra el comportamiento de PostgreSQL, surge la siguiente pregunta de ingeniería: ¿qué parte de todo el suite de datos realmente merece ser reforzada con una base de datos real? La respuesta casi nunca es «todo en serie».
Cuando escuchas la frase «ejecutemos las pruebas en PostgreSQL real», el novato a menudo activa el maximalismo: «genial, entonces pasemos todo a la base de datos real: será más fiable». Esta idea suena lógica, más o menos como «vayamos al supermercado solo en tanque, es más seguro». El problema es que cualquier refuerzo tiene un coste: inicio, configuración del entorno, velocidad del suite, estabilidad en diferentes máquinas. Por lo tanto, nuestro objetivo no es «hacer todas las pruebas lo más fuertes posible», sino construir un conjunto razonable de evidencias, donde cada verificación costosa tenga una razón comprensible.
La idea principal: real DB path es un amplificador de confianza dentro del mismo @DataJpaTest, no un nuevo valor predeterminado para todo el suite ni una transición a un modo de infraestructura separado. No cambiamos la capa, no convertimos las pruebas de repositorio en una «mini-integración» y no llevamos todo a la base de datos real. Tiene sentido reforzar solo aquellos escenarios donde el riesgo reside precisamente en el comportamiento de la base de datos específica: en restricciones, ordenación, paginación, tiempo o consultas personalizadas. Todo lo demás permanece tranquilamente en el path embebido y sigue proporcionando una retroalimentación rápida.
2. Algoritmo para seleccionar pruebas para real DB
Cuando te sientas frente a un repositorio, normalmente tienes dos tentaciones. La primera es «no haré pruebas real DB, está bien así». La segunda es «lo llevo todo a real DB, que arda». Entre ellas hay un término medio ingenieril tranquilo: hacer un par de preguntas correctas y obtener una pequeña lista de candidatos.
Formulemos este algoritmo de modo que se parezca más a una ruta clara que a una filosofía. Imagina que estás aconsejando a tu yo futuro (que está cansado, en la release y no tiene fuerzas para romanticismos).
flowchart TD
A["¿Hay una prueba de datos o se planea una?"] --> B{"¿El riesgo está relacionado con el dialecto/comportamiento de la BD?"}
B -- no --> C["Dejamos la prueba en el path embebido"]
B -- sí --> D{"¿La prueba verifica: constraint / ordering / paging / time / @Query?"}
D -- no --> C
D -- sí --> E{"¿El escenario es crítico para la API/negocio?"}
E -- no --> F["Se puede dejar embebido, o hacer 1 prueba en real DB"]
E -- sí --> G["Incluimos en el pequeño subconjunto real-DB"]
Aquí es importante que no buscamos una «fórmula ideal». Recogemos un conjunto pequeño de pruebas con alto ROI: son aquellas verificaciones que capturan una clase real de errores de producción o protegen lecturas/restricciones importantes. Y sí, a veces en el subconjunto entran solo 3–6 pruebas por repositorio, y eso está bien. A veces 1–2, también está bien.
3. ContentHub: candidatos para el real DB subset
Ahora viene el momento en que es útil pensar no «qué método del repositorio es complejo», sino «dónde el negocio realmente sentirá el dolor». En ContentHub hay varios lugares donde el comportamiento de la base de datos es crítico: unicidad del slug, la lista pública de artículos publicados, la ordenación determinista y la paginación correcta. Estas son las zonas donde si la base de datos se comporta «de manera diferente», el cliente de la API comienza a ver resultados extraños, y los desarrolladores, informes de errores extraños.
Unicidad del slug: constraint como parte de los invariantes de dominio
Con el slug todo es simple y a la vez engañoso. En el código puedes hacer todo perfectamente: generar el slug, verificar algo a nivel de servicio, incluso escribir pruebas unitarias. Pero la última línea de defensa es el índice/constraint único en la base de datos. Y si de repente no funciona como se espera (o la migración no lo creó), el problema aparecerá solo en la base de datos real.
Mini-ejemplo de una prueba de datos que encaja perfectamente en el real DB subset:
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import jakarta.persistence.PersistenceException;
import org.junit.jupiter.api.Test;
@Test
void slug_mustBeUnique() {
// Guardamos dos entidades con el mismo slug: la aplicación puede "dejarlo pasar",
// pero la base de datos debe prohibirlo mediante la restricción única.
entityManager.persist(articleWithSlug("same-slug"));
entityManager.persist(articleWithSlug("same-slug"));
// Es importante forzar a que se toque la BD: persist por sí solo puede no
// enviar SQL inmediatamente, pero flush lo envía y provoca que el constraint actúe.
assertThatThrownBy(() -> entityManager.flush())
// Para TestEntityManager esto ya es suficiente señal de que la inserción falló
// justo en el límite de persistencia, no en algún otro lugar aleatorio de la prueba.
.isInstanceOf(PersistenceException.class);
}
Para el ejemplo de estudio esto es suficiente: fijamos que el duplicado fue rechazado por la base de datos y exactamente en flush(). En un suite real es útil aferrarse a la señal más estable de tu stack, por ejemplo, la DataIntegrityViolationException de Spring en el path del repositorio o ConstraintViolationException en la cadena cause de Hibernate.
Lista pública: ordenación + paginación + orden secundario
La lista pública es algo que se toca más a menudo. Si tenemos GET /api/public/articles (ahora no probamos la capa web, solo recordamos el significado comercial de la lectura), entonces a nivel de repositorio normalmente hay un método del tipo «dame los publicados, ordénalos por fecha de publicación, paginados».
El error más común en estas lecturas es asumir que la ordenación «ya es estable». La base de datos no está obligada a devolver filas en un «orden conveniente» si no se especifica la ordenación. Y si la ordenación se define por un campo cuyos valores pueden coincidir (por ejemplo, varios artículos publicados en el mismo segundo), entonces sin un orden secundario diferentes BD y diferentes planes de ejecución pueden devolver filas de manera distinta.
Candidato para el real DB subset: una consulta que tiene tanto order by como paginación y un contrato de resultado importante. Por ejemplo:
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
@Query("""
select a from Article a
where a.status = :status
-- Punto crítico: dos niveles de ordenación.
-- publishedAt define la "novedad", e id fija el orden cuando publishedAt son iguales.
order by a.publishedAt desc, a.id desc
""")
Page<Article> findPublicFeed(ArticleStatus status, Pageable pageable);
No probamos la cadena JPQL, probamos el efecto: qué artículos llegan y en qué orden. Y ese «en qué orden» es justamente el lugar donde la BD real puede mostrar matices de ordenación que el entorno embebido no mostrará.
publishedAt iguales: determinismo
Hay una clase desagradable de errores que suenan graciosos: «a veces el orden en la lista salta». ¿Sabes por qué son desagradables? Porque casi nunca se pueden «capturar a simple vista» en una verificación manual: hoy está bien, mañana diferente. Y precisamente por eso la ordenación determinista es un tema excelente para una prueba sólida.
Mini-idea de prueba (con un ejemplo de código muy breve): preparar artículos con el mismo publishedAt pero diferentes id, y verificar que la ordenación secundaria por id realmente fija el orden:
import static org.assertj.core.api.Assertions.assertThat;
import java.time.Instant;
@Test
void publicFeed_orderIsDeterministic() {
// Creamos dos materiales publicados con el mismo publishedAt:
// este es un "caso límite" típico donde sin ordenación secundaria el orden se vuelve aleatorio.
persistPublished("a", Instant.parse("2026-03-21T10:00:00Z"));
persistPublished("b", Instant.parse("2026-03-21T10:00:00Z"));
// Importante: tomamos la primera página y especificamos explícitamente la ordenación
// tal como se espera en la lista pública (publishedAt desc, id desc).
var page = repository.findPublicFeed(PUBLISHED, firstPageSorted());
// Verificamos el contrato: el orden debe ser determinista.
assertThat(page.getContent()).extracting(Article::getSlug)
.containsExactly("b", "a");
}
Sí, el ejemplo es condicional: el orden real depende de qué id asigne realmente la base de datos. Pero la lógica es clara: si los publishedAt son iguales, el orden debe resolverse mediante la ordenación secundaria, no por azar.
4. Qué dejar en el embedded path
Para hacer un «pequeño real DB subset», hay que decirse honestamente: parte de las pruebas no tienen obligación de vivir en la base de datos real. Y no es «ahorro por ahorrar», sino control de complejidad. En el path embebido permanecen perfectamente las consultas donde el riesgo está principalmente en que nos equivocamos en el nombre de un método derivado o en un filtro de igualdad, y donde el comportamiento de la BD apenas influye en el significado.
Un ejemplo típico: «encuentra artículo por id y autor» o «encuentra por slug y estado» (si no estamos probando aspectos sutiles como collation/sensibilidad a mayúsculas, etc.):
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Optional;
import org.junit.jupiter.api.Test;
@Test
void findByIdAndAuthorUsername_returnsOnlyOwnersArticle() {
// Preparación de datos: creamos un borrador que pertenece a alice.
var saved = persistDraft("spring-jpa", "alice");
// Probamos el contrato de lectura: el propietario debe encontrar su registro
// mediante el par id + authorUsername.
Optional<Article> found = repository.findByIdAndAuthorUsername(saved.getId(), "alice");
assertThat(found).isPresent();
}
Esta prueba captura perfectamente defectos del tipo «pusimos el campo equivocado» o «el método del repositorio no coincide con lo esperado», pero apenas depende del dialecto. Por lo tanto, se puede mantener tranquilamente en modo barato, sin obligar a cada ejecución del suite a ir a la base de datos real.
Es importante no confundir esto con «las pruebas simples no son necesarias». Sí lo son. Solo que tienen otra tarea: proteger las lecturas de forma rápida y local, sin convertir el suite de datos en una odisea de infraestructura.
5. Organización: suite + subset sin copia y pega
La organización de las pruebas es el lugar donde la gente suele «deslizarse hacia el caos». Primero todo bonito: un solo ArticleRepositoryDataJpaTest. Luego añadimos replace = NONE, luego otra clase, luego alguien copia pruebas «por si acaso», y de repente tenemos dos conjuntos idénticos que a veces divergen en expectativas. Esto es una forma casi segura de generar un mantenimiento sin sentido.
Un modelo más saludable es este: tenemos un paquete principal de pruebas de datos (path embebido), y una clase pequeña separada (o conjunto de clases) que en su nombre indique claramente que es un subset. Por ejemplo:
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest
// Interruptor clave: Spring no reemplaza el DataSource por uno embebido,
// sino que utiliza la BD "real" de la configuración de pruebas.
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ArticleRepositoryRealDbSubsetTest {
// Aquí mantenemos un conjunto pequeño (por ejemplo, 3–6) de pruebas
// que realmente son sensibles a PostgreSQL.
}
Observa la idea sutil pero importante: no estamos construyendo una «segunda realidad». No es un segundo mundo donde «todo es diferente». Son los mismos repositorios, el mismo @DataJpaTest, los mismos TestEntityManager, flush/clear. La diferencia está solo en el entorno subyacente: la fuente de verdad ahora es la base de datos real del proyecto.
Y para evitar copia y pega, es conveniente extraer las fábricas de objetos de prueba y pequeños métodos helper a un lugar donde sean comunes para ambos caminos. Puede ser, por ejemplo, ArticleTestFactory o TestArticles, que crea entidades con valores predeterminados comprensibles, y en cada prueba solo cambias lo que es importante para el escenario.
Mini-ejemplo de «fábrica» (deliberadamente corto y sin magia):
import java.time.Instant;
Article published(String slug, Instant publishedAt) {
// Conjunto mínimo de campos que son importantes para los escenarios de la lista pública:
// slug (para verificación fácil en pruebas), estado (entra en el filtro), publishedAt (para ordenación).
Article a = new Article();
a.setSlug(slug);
a.setStatus(ArticleStatus.PUBLISHED);
a.setPublishedAt(publishedAt);
return a;
}
Sí, es código «manual». Y es bueno precisamente porque no oculta al estudiante qué campos son realmente importantes. En las pruebas valoramos la transparencia un poco más que la elegancia.
A continuación, un ejemplo de cómo podría verse un pequeño real DB subset para ArticleRepository si seleccionamos escenarios por riesgo, no por cantidad de métodos.
| Prueba (idea/significado) | Qué demuestra | Por qué es candidata a real DB |
|---|---|---|
| slug_mustBeUnique | La restricción única realmente existe y funciona | La BD embebida puede comportarse de manera diferente; la migración puede ser incorrecta |
| publicFeed_orderIsDeterministic | Orden estable cuando los publishedAt son iguales | Los matices de ordenación y los planes de ejecución se manifiestan en la BD real |
| publicFeed_paginationWorks | La paginación realmente divide el conjunto, los metadatos son correctos | Importante la combinación de order by + limit/offset, pueden aparecer diferencias SQL |
| findLatestPublished_orderByPublishedAtThenId | La ordenación secundaria realmente se tiene en cuenta | Causa muy frecuente de resultados «saltarines» |
| notNullFields_enforcedByDb | La base de datos no acepta registros incorrectos | La BD embebida a veces es más «tolerante» que PostgreSQL |
| customQuery_withJoin_ifAny | El join + filtro funciona correctamente | El comportamiento de los joins y el SQL generado a veces se manifiesta de manera diferente |
Observa que aquí no está la idea de «cubrir cada método del repositorio». Y es correcto: el objetivo del subset es cubrir los errores más costosos, aquellos que solo se manifiestan en la base de datos real y afectan más al comportamiento de la aplicación.
Si quieres una regla corta que realmente ayude en la vida, es esta: en el real DB subset entran pruebas de las que puedes decir honestamente una frase: «subo esto a la base de datos real porque precisamente la base de datos es la fuente del riesgo aquí».
6. Errores típicos del real DB subset
Error nº1: llevar todo al real DB «porque es más fiable».
En la práctica, casi siempre termina en que el suite se vuelve lento y caprichoso, y el equipo empieza a ejecutar las pruebas con menos frecuencia. La ironía es que «las pruebas más fiables» se convierten en «las pruebas más ignoradas». La ganancia real no la da una migración masiva, sino un subconjunto selectivo de alto valor.
Error nº2: duplicar la misma prueba tanto en embebido como en real DB sin nueva fuerza probatoria.
A veces se copia una prueba «por si acaso», pero verifica exactamente el mismo hecho que no depende del dialecto ni de restricciones. Al final tenemos dos pruebas idénticas que a veces fallan por razones diferentes (por ejemplo, distintos mensajes de error) y no añaden confianza. La duplicación se justifica solo si el entorno realmente cambia la probabilidad de capturar un defecto.
Error nº3: cambiar las aserciones «para que encajen con la base», ajustando las expectativas.
Si la prueba en embebido dice «el orden es este», y en la base de datos real hay que reescribir las expectativas, no es motivo para «ajustar las aserciones» en silencio. Es motivo para detenerse y entender: o en embebido no fijamos el determinismo (y entonces la prueba era débil), o en la consulta no hay orden explícito (y entonces la consulta en sí misma está subdeterminada).
Error nº4: incluir en el subset pruebas sin una razón clara.
Esto se ve como «bueno, siento que esta prueba es importante». Los sentimientos en las pruebas a veces son útiles (especialmente el dolor cuando el suite falla), pero para el real DB subset es mejor mantener una disciplina simple: cada prueba tiene una razón breve de una línea. Si no puedes explicarla, probablemente la prueba no es candidata.
Error nº5: convertir el subset en un «segundo suite completo» con el tiempo.
La degradación más imperceptible: hoy añadimos 4 pruebas, mañana otras 4, luego otras 4, y de repente el subset deja de ser un subconjunto. El remedio es muy simple y aburrido (por lo tanto, funciona): releer periódicamente la lista y eliminar honestamente lo que no aporta valor único.
GO TO FULL VERSION