BACKEND 14 min

Testing en Microservicios Java: De H2 y Mocks a Testcontainers en Producción

Elimina los falsos positivos en tus tests de integración. Aprende a utilizar Testcontainers con Spring Boot 3, @ServiceConnection, PostgreSQL y Apache Kafka para validar dependencias reales en JUnit 5.

Escrito por Samir El Azem Crovetto • Senior Backend Engineer & DevSecOps

La Crisis de Confianza en las Pruebas de Integración

En el desarrollo de microservicios empresariales con Java y Spring Boot, existe una discrepancia persistente que frustra a los equipos de ingeniería: suites de pruebas automatizadas que reportan un 100% de éxito en el entorno local del desarrollador o en el pipeline de CI/CD, pero que fallan estrepitosamente en el momento en que el artefacto se despliega en producción o en un entorno de integración real.

El origen de este fenómeno radica en la dependencia histórica de dos atajos arquitectónicos:

  1. La proliferación de Mocks (Mockito): Simular (mockear) repositorios de datos, clientes HTTP y colas de mensajería verifica únicamente que el código interactúa según lo que el desarrollador supuso en el momento de escribir la prueba. Un mock nunca devolverá un error de violación de clave foránea, un fallo de bloqueo pesimista (pessimistic lock) o una incompatibilidad de serialización de red.
  2. Las bases de datos en memoria (H2 / Fongo): Durante más de una década, configurar h2database para los tests de integración fue el estándar de la industria. Sin embargo, H2 no es PostgreSQL ni MySQL: carece de soporte nativo para tipos avanzados (como columnas JSONB, índices GIN, operadores geoespaciales PostGIS o generación nativa de UUIDs), implementa un motor de concurrencia simplista y utiliza un analizador léxico que tolera sentencias SQL inválidas en los motores de producción.

En sistemas corporativos y plataformas de misión crítica —como sistemas de gestión de usuarios de telecomunicaciones o servicios financieros—, estos falsos positivos no son meras molestias: son incidentes de producción latentes.

La respuesta definitiva de la ingeniería de software a este desafío es Testcontainers: una librería open source que orquesta programáticamente contenedores Docker reales y efímeros durante el ciclo de vida de ejecución de JUnit. Con Testcontainers, la máxima \"testea exactamente como despliegas\" se convierte en un estándar ejecutable y determinista.


1. Arquitectura y Ciclo de Vida de Testcontainers

Para utilizar Testcontainers con eficacia, es indispensable comprender cómo interactúa con el demonio de Docker y con el gestor de pruebas de JUnit 5:

sequenceDiagram
    autonumber
    participant JUnit as Motor JUnit 5 (@Testcontainers)
    participant Ryuk as Contenedor Ryuk (Resource Reaper)
    participant Docker as Demonio Docker (Host / CI)
    participant Spring as ApplicationContext (Spring Boot 3)
    participant Test as Clase de Prueba (@Test)

    JUnit->>Docker: 1. Conecta vía socket /var/run/docker.sock
    JUnit->>Docker: 2. Arranca contenedor auxiliar Ryuk (Mounter de limpieza)
    JUnit->>Docker: 3. Arranca Contenedores de prueba (PostgreSQL, Kafka, etc.)
    Docker-->>JUnit: 4. Mapea puertos efímeros aleatorios (ej. 5432 -> 32789)
    JUnit->>Spring: 5. @ServiceConnection inyecta JDBC URL y credenciales
    Spring->>Docker: 6. Conecta a la base de datos REAL y aplica migraciones (Flyway)
    Test->>Spring: 7. Ejecuta pruebas de integración contra infraestructura real
    Test-->>JUnit: 8. Pruebas finalizadas
    JUnit->>Docker: 9. Detiene contenedores de prueba
    Note over Ryuk,Docker: Si la JVM muere abruptamente, Ryuk destruye<br/>todos los contenedores huérfanos automáticamente

Componentes Clave de la Arquitectura:

  1. Mapeo Dinámico de Puertos: Testcontainers nunca expone los servicios sobre los puertos estándar fijos del host (como 5432 para PostgreSQL o 9092 para Kafka). En su lugar, enlaza cada puerto del contenedor a un puerto libre aleatorio del host. Esto permite ejecutar múltiples suites de pruebas en paralelo en la misma máquina o en runners de CI compartidos sin colisiones de puertos.
  2. *El Guardián Ryuk (Resource Reaper):* Uno de los temores habituales al ejecutar Docker en tests es la acumulación de contenedores huérfanos si la suite falla o se cancela abruptamente (por ejemplo, con un SIGKILL en Jenkins o GitHub Actions). Testcontainers arranca un contenedor ligero llamado testcontainers/ryuk antes que ningún otro. Ryuk mantiene un socket abierto con la JVM de pruebas; si la conexión se cierra por cualquier motivo, Ryuk purga de inmediato todos los contenedores, redes y volúmenes generados durante la sesión.
  3. Inyección en Spring Boot: Una vez inicializado el contenedor, sus coordenadas de red (IP, puerto mapeado, usuario y contraseña generados) deben propagarse al contexto de configuración de Spring Boot antes de que se inicialice el pool de conexiones HikariCP.

2. Dependencias y Configuración en Spring Boot 3

En Spring Boot 3.1+, el equipo de Spring incorporó soporte nativo de primer nivel para Testcontainers a través del módulo spring-boot-testcontainers.

Declaramos las dependencias necesarias en pom.xml:

<dependencies>
    <!-- Dependencias de prueba -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Módulo oficial de integración Spring Boot + Testcontainers -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- Módulos específicos de Testcontainers -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>kafka</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

3. La Revolución de @ServiceConnection frente a @DynamicPropertySource

Históricamente, integrar Testcontainers en Spring Boot requería escribir métodos estáticos verbosos anotados con @DynamicPropertySource para inyectar cada propiedad individualmente en el entorno de Spring:

// ENFOQUE ANTIGUO Y VERBOSO (Pre Spring Boot 3.1)
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl);
    registry.add("spring.datasource.username", postgres::getUsername);
    registry.add("spring.datasource.password", postgres::getPassword);
}

A partir de Spring Boot 3.1, la anotación @ServiceConnection elimina por completo este código repetitivo (boilerplate). Spring detecta automáticamente el tipo de contenedor de Testcontainers y configura los beans correspondientes (DataSource, ConnectionFactory, KafkaConnectionDetails) de forma totalmente transparente:

package es.secdevoops.integration;

import es.secdevoops.domain.UserAccount;
import es.secdevoops.repository.UserAccountRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class UserPersistenceIntegrationTest {

    // 1. Declarar la imagen exacta que se utiliza en producción (ej. PostgreSQL 16)
    @Container
    @ServiceConnection // Inyección automática de DataSource en Spring Boot 3.1+
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("secdevoops_test_db")
            .withUsername("test_user")
            .withPassword("test_password");

    @Autowired
    private UserAccountRepository userAccountRepository;

    @BeforeEach
    void cleanDatabase() {
        userAccountRepository.deleteAll();
    }

    @Test
    @DisplayName("Debe persistir y recuperar entidades utilizando tipos nativos de PostgreSQL")
    void shouldPersistAndRetrieveUserAccount() {
        // Given
        UserAccount newUser = new UserAccount("ingenieria@secdevoops.es", "ROLE_SECURITY_ENGINEER");

        // When
        UserAccount savedUser = userAccountRepository.save(newUser);

        // Then
        Optional<UserAccount> retrieved = userAccountRepository.findById(savedUser.getId());
        assertThat(retrieved).isPresent();
        assertThat(retrieved.get().getEmail()).isEqualTo("ingenieria@secdevoops.es");
    }
}

4. El Patrón Singleton Container: Acelerando Suites de Integración

Uno de los principales motivos por los que los desarrolladores dudan en adoptar Testcontainers es el impacto en el tiempo de ejecución: levantar un contenedor de base de datos o Kafka suele tardar entre 2 y 5 segundos. Si un proyecto contiene 30 clases de tests de integración y cada una arranca y destruye sus propios contenedores, el tiempo total de la compilación se dispara a varios minutos.

Para mitigar esto, el patrón recomendado por la arquitectura de pruebas es el Singleton Container Pattern: todos los tests de integración comparten una única instancia de contenedor durante toda la ejecución de la suite de Maven/Gradle.

Implementación de una Clase Base Abstracta:

package es.secdevoops.integration.base;

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public abstract class AbstractIntegrationTestBase {

    // Instancia estática compartida entre todas las clases de prueba
    @ServiceConnection
    protected static final PostgreSQLContainer<?> SHARED_POSTGRES;

    static {
        SHARED_POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
                .withDatabaseName("secdevoops_shared_db")
                .withReuse(true); // Habilita reutilización local si está configurada

        // Arranque manual fuera del ciclo de vida de JUnit para mantenerlo vivo entre clases
        SHARED_POSTGRES.start();
    }
}

Cualquier clase de prueba simplemente extiende esta clase base:

class OrderServiceIntegrationTest extends AbstractIntegrationTestBase {
    // El contenedor ya está arrancado y disponible instantáneamente
}

class InvoiceServiceIntegrationTest extends AbstractIntegrationTestBase {
    // Reutiliza exactamente la misma instancia del contenedor PostgreSQL sin tiempo de espera
}

Aislamiento de Estado entre Tests

Al compartir una única base de datos entre múltiples clases, es indispensable aislar el estado para evitar que los datos de un test afecten a los siguientes:

  • Estrategia Recomendada: Utilizar un @BeforeEach o un listener de Spring Test que ejecute un truncado rápido de tablas (TRUNCATE TABLE ... RESTART IDENTITY CASCADE). Esto tarda menos de 10 milisegundos y garantiza una base de datos limpia para cada prueba.

5. Pruebas de Flujos Asíncronos con Apache Kafka y Awaitility

En arquitecturas de microservicios modernas, muchas operaciones combinan persistencia relacional con emisión de eventos asíncronos. Probar estos flujos con bases de datos simuladas o mocks de Kafka no permite validar retrasos de entrega, deserialización de mensajes ni transacciones distribuidas.

A continuación se muestra un test de integración completo que combina PostgreSQL, Apache Kafka (en modo Kraft, sin Zookeeper) y la librería Awaitility para verificar aserciones asíncronas:

package es.secdevoops.integration;

import es.secdevoops.integration.base.AbstractIntegrationTestBase;
import es.secdevoops.order.dto.CreateOrderRequest;
import es.secdevoops.order.service.OrderService;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.kafka.KafkaContainer;

import java.time.Duration;
import java.util.Collections;
import java.util.Properties;
import java.util.concurrent.TimeUnit;

import static org.awaitility.Awaitility.await;
import static org.assertj.core.api.Assertions.assertThat;

class OrderEventIntegrationTest extends AbstractIntegrationTestBase {

    @ServiceConnection
    static KafkaContainer kafka = new KafkaContainer("apache/kafka-native:3.8.0");

    static {
        kafka.start();
    }

    @Autowired
    private OrderService orderService;

    private KafkaConsumer<String, String> testConsumer;

    @BeforeEach
    void setUpKafkaConsumer() {
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers());
        props.put(ConsumerConfig.GROUP_ID_CONFIG, "test-verification-group");
        props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());

        testConsumer = new KafkaConsumer<>(props);
        testConsumer.subscribe(Collections.singletonList("orders.events"));
    }

    @AfterEach
    void tearDown() {
        if (testConsumer != null) {
            testConsumer.close();
        }
    }

    @Test
    void alCrearOrden_DebePersistirEnBaseDeDatosYPublicarEventoEnKafka() {
        // 1. Ejecutar la operación de negocio en el servicio
        CreateOrderRequest request = new CreateOrderRequest("PROD-8821", 2, 199.99);
        var createdOrder = orderService.createOrder(request);

        // 2. Verificar que se persistió en PostgreSQL real
        assertThat(createdOrder.id()).isNotNull();

        // 3. Verificar de forma no bloqueante con Awaitility que el evento llegó a Kafka
        await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> {
            var records = testConsumer.poll(Duration.ofMillis(100));
            assertThat(records).isNotEmpty();
            
            ConsumerRecord<String, String> record = records.iterator().next();
            assertThat(record.value()).contains("PROD-8821");
            assertThat(record.value()).contains("ORDER_CREATED");
        });
    }
}

6. Ejecución Fiable en Pipelines de CI/CD (GitHub Actions)

Para que Testcontainers funcione en entornos de Integración Continua, el ejecutor (runner) debe tener acceso al socket del demonio de Docker.

En GitHub Actions, los ejecutores basados en Linux (ubuntu-latest) incluyen Docker preinstalado por defecto. A continuación se presenta un workflow de CI optimizado que habilita caché de capas de Docker y maximiza el rendimiento:

name: CI/CD Pipeline - Backend Integration Tests

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  integration-tests:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    steps:
      - name: Checkout del repositorio
        uses: actions/checkout@v4

      - name: Configurar JDK 21 (Temurin)
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: maven

      - name: Verificar disponibilidad del demonio Docker
        run: docker info

      - name: Ejecutar Tests de Integración con Testcontainers
        env:
          TESTCONTAINERS_RYUK_DISABLED: "false"
          # Permite optimizar el pull de imágenes si se utiliza un registro corporativo
        run: mvn clean verify -Dtest=*IntegrationTest

Buenas Prácticas Operativas en CI/CD:

  1. Gestión de Memoria en Runners: Cada contenedor Docker consume memoria RAM del runner. En runners con 7 GB de memoria (como los de GitHub Actions), limita la asignación de memoria heap de la JVM de Maven (-Xmx2g) para dejar suficiente memoria libre a los contenedores Docker (postgres, kafka).
  2. Imágenes Ligeras o Alpine: Utiliza siempre imágenes base minimalistas (postgres:16-alpine, apache/kafka-native) para minimizar el tiempo de descarga (pull time) en los runners efímeros.

7. Comparativa Arquitectónica de Enfoques de Testing

Dimensión Mocks Puros (Mockito) Base de Datos en Memoria (H2) Testcontainers (Docker Real)
Fidelidad con Producción Nula (solo prueba hipótesis) Baja (divergencia de dialectos SQL) Idéntica (100% de paridad)
Detección de Bugs de Concurrencia Imposible Pobre (bloqueos irreales) Alta (motor y MVCC real)
Validación de Migraciones (Flyway) No aplicable Parcial (scripts adaptados) Total (ejecuta scripts reales)
Velocidad de Ejecución Milisegundos Muy rápida (< 1 segundo) Rápida con Singleton Pattern
Mantenibilidad a Largo Plazo Frágil ante refactorizaciones Complejo mantener dos dialectos Robusto y desacoplado

Conclusión

La adopción de Testcontainers en el ecosistema Java y Spring Boot 3 no es una simple preferencia de herramientas: es una transformación arquitectónica que devuelve la credibilidad a las suites de pruebas de integración. Reemplazar bases de datos simuladas y mocks frágiles por contenedores reales efímeros garantiza que cada restricción de base de datos, cada transacción distribuida y cada evento de mensajería se valide con absoluta certeza antes de llegar a producción.

Combinar esta estrategia de pruebas con Spring Data JPA de Alto Rendimiento, autenticación asimétrica en OAuth 2.1 y Spring Security 6 y empaquetado optimizado en Contenedores Docker establece los cimientos para desplegar microservicios resilientes, seguros y mantenibles.

Si necesitas auditar la estrategia de pruebas de tus microservicios Java, erradicar falsos positivos en tus pipelines de CI/CD o acelerar tus suites de integración con Testcontainers, puedes consultar mis Servicios de Ciberseguridad y Consultoría o contactar directamente conmigo en Sobre mí.