Testcontainers en Spring Boot: por qué dejamos de mockear la base de datos en los tests de integración
Durante mucho tiempo, la capa de persistencia en nuestros tests de integración de Spring Boot vivió detrás de un mock. @MockBean sobre el repositorio, Mockito.when(...) devolviendo la entidad que el test necesitaba, y el test corría en milisegundos sin tocar ninguna base de datos real. Funcionaba mientras la consulta fuera simple: un findById que devuelve una entidad o un Optional.empty(). El problema aparecía en todo lo demás: un JOIN FETCH, una restricción UNIQUE que debía disparar una excepción concreta, un @Version para bloqueo optimista, una función de ventana escrita a mano en SQL nativo. Nada de eso lo ejecuta un mock, porque no tiene un motor de base de datos detrás: solo devuelve lo que le dijimos que devolviera, sin importar si esa consulta compilaría siquiera contra un esquema real.
El problema de fondo con los mocks de repositorio
Un mock verifica que el código que llama al repositorio hace lo que esperamos con el resultado que el repositorio devuelve. No verifica que el repositorio, tal como está escrito, produzca ese resultado contra una base de datos real. Esa distinción parece sutil hasta que alguien cambia una consulta @Query y rompe la sintaxis JPQL, o cambia el tipo de una columna y una conversión que funcionaba deja de funcionar, o agrega una restricción de integridad que ahora rechaza datos que el test seguía insertando sin problema. Con mocks, todos esos cambios pasan los tests en verde. El error aparece en producción, en el peor momento posible: cuando ya hay datos reales de por medio.
La alternativa más común antes de Testcontainers era H2 en memoria configurado en modo compatibilidad con Postgres. Resolvía parte del problema -al menos había un motor SQL real ejecutando las consultas- pero el modo compatibilidad nunca es completo. Tipos como jsonb, funciones específicas de Postgres o el comportamiento exacto de una restricción CHECK generaban tests que pasaban contra H2 y fallaban contra el Postgres real de producción. Cambiábamos de mockear la base de datos a mockear el motor, que es el mismo problema con otro nombre.
Qué hace Testcontainers distinto
Testcontainers no simula nada: levanta un contenedor Docker con la imagen real del motor que usamos en producción, ejecuta las migraciones contra ese contenedor, y el test corre consultas reales contra un Postgres real. La diferencia frente a apuntar los tests a una base de datos compartida de desarrollo es que el contenedor es efímero y aislado: se crea al arrancar la suite, cada test parte de un estado conocido, y se destruye al terminar, sin dejar residuos que contaminen la siguiente ejecución ni depender de que alguien no haya dejado datos sucios en un esquema compartido.
La integración con JUnit 5 se declara con una anotación sobre la clase de test:
@SpringBootTest
@Testcontainers
class PedidoRepositoryIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configurarPropiedades(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
@Autowired
private PedidoRepository pedidoRepository;
@Test
void guardaYRecuperaUnPedidoConLineasAsociadas() {
Pedido pedido = new Pedido(...);
pedidoRepository.save(pedido);
Pedido recuperado = pedidoRepository.findByIdConLineas(pedido.getId())
.orElseThrow();
assertThat(recuperado.getLineas()).hasSize(2);
}
}
@DynamicPropertySource resuelve un problema concreto: el puerto del contenedor no se conoce hasta que Testcontainers lo arranca, así que las propiedades de conexión no pueden fijarse en un application-test.yml estático. Registrarlas dinámicamente después de que el contenedor ya está corriendo asegura que Spring apunte al puerto real que Docker asignó, no a uno fijo que podría estar ocupado por otro proceso.
Con esta configuración, un JOIN FETCH mal escrito falla en el test con el mismo error de Hibernate que fallaría en producción, una restricción UNIQUE lanza la misma DataIntegrityViolationException, y una función de ventana en SQL nativo se ejecuta contra el motor real que la va a interpretar en producción. El test deja de preguntar "¿el código llama al repositorio correctamente?" y empieza a preguntar "¿esta consulta funciona de verdad?", que es la pregunta que de verdad nos importa en la capa de persistencia.
Lo que no funcionó: un contenedor nuevo por cada clase de test
La primera versión de esta migración levantaba un contenedor por clase de test, con el ciclo de vida por defecto de @Testcontainers: arranca al empezar la clase, se destruye al terminar. Con dos o tres clases de test de integración el costo era tolerable. El problema apareció cuando el número de clases creció: cada una pagaba el costo completo de arrancar un Postgres desde cero -crear el contenedor, esperar a que el motor esté listo para aceptar conexiones, ejecutar las migraciones de Flyway desde la primera- antes de correr una sola aserción. La suite de integración pasó a ser, con diferencia, la parte más lenta del pipeline de CI, y el tiempo no crecía de forma lineal con el número de tests dentro de cada clase, sino con el número de clases, porque cada una repetía el mismo arranque.
La corrección fue compartir un único contenedor entre todas las clases de test de integración, usando el patrón de contenedor singleton: una clase base o una interfaz que arranca el contenedor una sola vez, de forma estática, y lo mantiene vivo durante toda la ejecución de la suite en vez de reiniciarlo por clase.
public abstract class ContenedorPostgresBase {
static final PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
static {
postgres.start();
}
@DynamicPropertySource
static void configurarPropiedades(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
}
Cada clase de test extiende ContenedorPostgresBase en vez de declarar su propio @Container. El contenedor arranca una sola vez para toda la JVM de test y Testcontainers lo detiene automáticamente al finalizar el proceso mediante su mecanismo de Ryuk, sin que tengamos que gestionar el apagado a mano. El costo de arranque del motor y de las migraciones se paga una única vez por ejecución completa de la suite, no una vez por clase.
Esto trajo un problema distinto: si un test deja datos escritos y el siguiente asume una tabla vacía, el orden de ejecución empieza a importar, que es justo el acoplamiento entre tests que queremos evitar. Lo resolvimos con limpieza explícita antes de cada test en vez de confiar en el aislamiento por contenedor, usando @Sql con un script de truncado o un @BeforeEach que borra las tablas relevantes. Es más código que "cada test tiene su base de datos limpia por definición", pero preferimos pagar ese costo de forma explícita a que aparezca como un test que solo falla cuando corre después de otro.
El costo en tiempo de CI que hay que asumir
Incluso con el contenedor compartido, una suite contra Testcontainers es más lenta que la misma suite contra mocks, y no tiene sentido pretender lo contrario. Levantar Docker, descargar la imagen si no está en caché, arrancar el motor y correr las migraciones añade un costo fijo al principio de la ejecución que un mock no tiene. En un pipeline donde la imagen de Postgres ya está en la caché de capas de Docker, ese costo se reduce al arranque del motor más las migraciones: sigue siendo mayor que cero, pero deja de ser el cuello de botella dominante si el número de tests de integración por ejecución es razonable.
La decisión que tomamos fue de alcance, no de todo o nada: los tests unitarios que verifican lógica de negocio pura siguen usando mocks, porque ahí un mock es la herramienta correcta y no hay ninguna base de datos real que consultar. Testcontainers lo reservamos para los tests que existen específicamente para verificar que una consulta, una migración o una restricción de esquema funcionan contra el motor real. Esa separación mantiene la mayoría de la suite rápida y concentra el costo de los contenedores exactamente en los tests donde ese costo compra algo que un mock no puede darnos.
Cuándo sigue teniendo sentido mockear
Testcontainers no reemplaza cada mock del proyecto, y no debería. Si un servicio de aplicación orquesta tres repositorios y la lógica que queremos probar es el orden en que se llaman y qué hace con las excepciones que cada uno lanza, mockear esos repositorios sigue siendo lo correcto: el test no necesita un Postgres real para verificar orden de llamadas y manejo de excepciones, y forzarlo solo añadiría tiempo sin añadir confianza. La pregunta que usamos para decidir es simple: si esta consulta o esta restricción se ejecutara contra el motor real, ¿podría fallar de una forma que el mock no puede reproducir? Si la respuesta es sí, va con Testcontainers. Si la respuesta es no -porque el test no depende de ningún comportamiento específico del motor de base de datos-, un mock sigue siendo más rápido y sigue siendo correcto.