Integration test con Testcontainers en Spring Boot 3

1. Descripción general

Los integration test ejecutándose en H2 suelen dar resultados “verdes” en tu máquina pero aún fallan en producción que ejecuta PostgreSQL. La razón es que la sintaxis SQL, los tipos de datos y el comportamiento de las transacciones de los dos sistemas de gestión son diferentes. Testcontainers resuelve este problema iniciando una base de datos real en un contenedor Docker justo cuando la prueba se ejecuta.

En este artículo, configurarás Testcontainers para una aplicación Spring Boot 3.x y usarás @ServiceConnection para conectar el contenedor a Spring. Luego compartirás un contenedor entre múltiples clases de prueba y comprenderás los errores comunes.

2. Preparación

2.1. Dependencias

Necesitas que Docker esté ejecutándose en tu máquina, o un daemon de Docker compatible. Luego agrega las siguientes dependencias a pom.xml:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <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>
</dependencies>

El padre de Spring Boot ya gestiona la versión de Testcontainers, así que no necesitas declarar <version>. El artifact spring-boot-testcontainers está disponible desde Spring Boot 3.1 y proporciona la anotación @ServiceConnection.

2.2. Código a probar

Usamos una entidad Order simple:

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String customerEmail;

    private BigDecimal total;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    protected Order() {
    }

    public Order(String customerEmail, BigDecimal total, OrderStatus status) {
        this.customerEmail = customerEmail;
        this.total = total;
        this.status = status;
    }

    public Long getId() { return id; }
    public String getCustomerEmail() { return customerEmail; }
    public BigDecimal getTotal() { return total; }
    public OrderStatus getStatus() { return status; }
}
public enum OrderStatus { NEW, PAID, CANCELLED }

Repository tiene una consulta nativa que usa ILIKE de PostgreSQL:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query(value = "select * from orders where customer_email ilike :pattern",
           nativeQuery = true)
    List<Order> searchByEmail(@Param("pattern") String pattern);

    List<Order> findByStatus(OrderStatus status);
}

La consulta nativa es el tipo de código que fácilmente “funciona en H2 pero se rompe en Postgres”, o viceversa. Este es exactamente el lugar donde deberías hacer pruebas en una base de datos real.

3. Prueba de repositorio con @ServiceConnection

3.1. Declaración del contenedor

@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void givenOrders_whenSearchByEmailIgnoringCase_thenReturnMatches() {
        orderRepository.save(new Order("An.Nguyen@example.com", new BigDecimal("150000"), OrderStatus.NEW));
        orderRepository.save(new Order("binh@example.com", new BigDecimal("90000"), OrderStatus.PAID));

        List<Order> result = orderRepository.searchByEmail("%an.nguyen%");

        assertThat(result)
            .extracting(Order::getCustomerEmail)
            .containsExactly("An.Nguyen@example.com");
    }
}

Cada componente funciona de la siguiente manera:

  • @Testcontainers activa la extensión JUnit 5. Esta extensión busca campos con @Container y gestiona su ciclo de vida.
  • @Container en un campo static: el contenedor se inicia una sola vez antes de todas las pruebas de la clase y se detiene después de la última prueba. Si el campo no es static, el contenedor se recreará para cada método de prueba.
  • @ServiceConnection: Spring Boot lee el host, el puerto aleatorio, el nombre de usuario y la contraseña del contenedor, luego crea un bean JdbcConnectionDetails. Gracias a esto, no tienes que configurar manualmente spring.datasource.url.
  • @AutoConfigureTestDatabase(replace = NONE): por defecto, @DataJpaTest puede reemplazar el DataSource con una base de datos embebida. A partir de Spring Boot 3.4, el framework reconoce automáticamente el DataSource que viene de @ServiceConnection. Sin embargo, la declaración explícita ayuda a que el código funcione correctamente en todas las versiones 3.x.
  • ddl-auto=create-drop: con una base de datos no embebida, Spring Boot no crea automáticamente el esquema por defecto. En un proyecto real, deberías usar Flyway o Liquibase para que las pruebas ejecuten las migraciones correctas de producción.

Al ejecutar la prueba, el registro tendrá este aspecto:

Creating container for image: postgres:16-alpine
Container postgres:16-alpine is starting: 3f9c1a...
Container postgres:16-alpine started in PT2.1S
HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@6a1b...

La primera ejecución será más lenta porque Docker debe descargar la imagen. Las siguientes ejecuciones generalmente solo tardan unos segundos.

3.2. La forma antigua: @DynamicPropertySource

Antes de Spring Boot 3.1, o con contenedores que aún no soportan @ServiceConnection, debes configurar la propiedad manualmente:

@DynamicPropertySource
static void datasourceProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl);
    registry.add("spring.datasource.username", postgres::getUsername);
    registry.add("spring.datasource.password", postgres::getPassword);
}

Este enfoque sigue funcionando. Sin embargo, con PostgreSQL, MySQL, Redis, Kafka, MongoDB y muchos otros servicios populares, @ServiceConnection es más conciso y menos propenso a errores de tipografía de nombres de propiedades.

4. Prueba de toda la aplicación con @SpringBootTest

La prueba de repositorio solo carga la capa JPA. Para verificar el flujo HTTP → servicio → base de datos, necesitas cargar el contexto completo. Supongamos que tenemos el siguiente controlador:

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderRepository orderRepository;

    public OrderController(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public record CreateOrderRequest(String customerEmail, BigDecimal total) {}

    @PostMapping
    public ResponseEntity<Order> create(@RequestBody CreateOrderRequest request) {
        Order saved = orderRepository.save(
            new Order(request.customerEmail(), request.total(), OrderStatus.NEW));
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Order> get(@PathVariable Long id) {
        return ResponseEntity.of(orderRepository.findById(id));
    }
}

4.1. Compartir contenedor con @TestConfiguration

En lugar de declarar el contenedor en cada clase de prueba, lo conviertes en un bean:

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>("postgres:16-alpine");
    }
}

Aquí el contenedor es gestionado por Spring, no por JUnit. Spring inicia el contenedor antes de crear el DataSource y lo detiene cuando el contexto se cierra. Spring TestContext cachea el contexto entre clases de prueba que tienen la misma configuración, por lo que esas clases comparten un contenedor.

4.2. Escribir la prueba

@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
    properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@Import(TestcontainersConfig.class)
class OrderControllerIT {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private OrderRepository orderRepository;

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

    @Test
    void whenCreateOrder_thenItIsPersistedAndReadable() {
        var request = new OrderController.CreateOrderRequest(
            "chi@example.com", new BigDecimal("250000"));

        ResponseEntity<Order> created =
            restTemplate.postForEntity("/orders", request, Order.class);

        assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        Long id = created.getBody().getId();

        Order fetched = restTemplate.getForObject("/orders/" + id, Order.class);

        assertThat(fetched.getCustomerEmail()).isEqualTo("chi@example.com");
        assertThat(fetched.getTotal()).isEqualByComparingTo("250000");
        assertThat(fetched.getStatus()).isEqualTo(OrderStatus.NEW);
        assertThat(orderRepository.findByStatus(OrderStatus.NEW)).hasSize(1);
    }
}

Algunos puntos a tener en cuenta:

  • RANDOM_PORT inicia un servidor real. TestRestTemplate apunta automáticamente a ese puerto.
  • Esta prueba no tiene @Transactional, porque la solicitud HTTP se ejecuta en un hilo diferente al de la prueba. Los datos se confirman realmente en Postgres, así que debes limpiar en @BeforeEach.
  • isEqualByComparingTo compara BigDecimal por valor. Este enfoque evita el error de que 250000 sea diferente de 250000.00 debido a diferentes escalas.
  • La clase Order necesita ser deserializada por Jackson. Si encuentras un error de deserialización, verifica el constructor por defecto y los getters, o usa un DTO de registro separado para la respuesta.

4.3. Reutilizar la configuración al ejecutar la aplicación localmente

TestcontainersConfig tiene otro uso poco conocido: ejecutar la aplicación localmente sin necesidad de instalar Postgres. Crea la siguiente clase en src/test/java:

public class TestOrderApplication {

    public static void main(String[] args) {
        SpringApplication.from(OrderApplication::main)
            .with(TestcontainersConfig.class)
            .run(args);
    }
}

Luego ejecuta el comando:

./mvnw spring-boot:test-run

La aplicación se inicia con un contenedor Postgres. Este contenedor se detiene cuando cierras la aplicación.

5. Errores comunes

5.1. @Container estático en múltiples clases compartiendo contexto

Este es el error más incómodo. Supongamos que tienes una clase base abstracta que contiene @Container static junto con @ServiceConnection, y múltiples clases de prueba la heredan:

OrderControllerIT  -> contenedor se inicia en puerto 55001, contexto en caché
CustomerControllerIT -> JUnit detiene el contenedor anterior, inicia uno nuevo en puerto 55017
                    -> Spring reutiliza el contexto en caché, aún apuntando a 55001
                    -> Connection refused / HikariPool timeout

JUnit detiene el contenedor después de cada clase, pero Spring sigue manteniendo el contexto anterior en caché. Tienes dos formas de arreglarlo:

  1. Usa un bean contenedor como en la sección 4.1. En este caso, el ciclo de vida del contenedor está vinculado al contexto. Este es el enfoque recomendado.
  2. Usa un contenedor singleton: elimina @Container y @Testcontainers, luego inicia manualmente en un bloque estático. Ryuk limpiará el contenedor cuando JVM finalice.
public abstract class AbstractIntegrationTest {

    @ServiceConnection
    static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");

    static {
        POSTGRES.start();
    }
}

5.2. Usar la etiqueta latest

Nota: Siempre fija la versión de la imagen (postgres:16-alpine), y es mejor que coincida con la versión en producción. La etiqueta latest puede hacer que las pruebas cambien de comportamiento inesperadamente cuando se lanza una nueva imagen.

5.3. Fuga de datos entre pruebas

@DataJpaTest hace rollback por defecto después de cada prueba, pero @SpringBootTest con RANDOM_PORT no. Al compartir el contenedor, los datos de la clase anterior aún permanecen en la base de datos. Limpia los datos en @BeforeEach, o usa @Sql para ejecutar un script TRUNCATE. No escribas pruebas que dependan del orden de ejecución.

5.4. Entorno CI sin Docker

Si Docker no está ejecutándose, la prueba informará de un error:

java.lang.IllegalStateException: Could not find a valid Docker environment.

Necesitas asegurar que el ejecutor de CI tenga Docker. Si quieres omitir las pruebas cuando falta Docker en lugar de reportar un fallo, usa @Testcontainers(disabledWithoutDocker = true). Considera cuidadosamente antes de habilitar esta opción, porque las pruebas omitidas en silencio son fáciles de olvidar.

5.5. Acelerar localmente con reuse

Para mantener vivo el contenedor entre ejecuciones de prueba locales, habilita reuse en el archivo ~/.testcontainers.properties:

testcontainers.reuse.enable=true

Luego llama a .withReuse(true) al crear el contenedor. No debes habilitar esta opción en CI, porque el contenedor no se limpiaría automáticamente.

6. Cuándo no usar Testcontainers

Testcontainers hace que las pruebas sean más confiables, pero cada prueba será más lenta y requiere Docker. Algunos casos donde no deberías usarlo:

  • Lógica pura como cálculo de precios, validación o mapeo: escribir pruebas unitarias con mock u objetos reales es suficiente. Las pruebas unitarias se ejecutan cientos de veces más rápido.
  • Cada clase de prueba con una configuración de contexto diferente: cada configuración crea un nuevo contexto. Si el contenedor está vinculado al contexto, tendrás múltiples contenedores ejecutándose en paralelo. Agrupa las pruebas en pocas configuraciones comunes.
  • Servicios de terceros a través de HTTP: generalmente deberías usar WireMock en lugar de intentar ejecutar el servicio real en un contenedor.

Una proporción razonable es: la mayoría son pruebas unitarias, mientras que las pruebas de integración con Testcontainers se enfoca en lugares donde tocas la infraestructura, como repository, consultas nativas, migraciones y message brokers.

7. Conclusión

En este artículo, usaste Testcontainers para ejecutar pruebas de integración en PostgreSQL real. @ServiceConnection elimina completamente la configuración manual del datasource. Convertir el contenedor en un bean en @TestConfiguration tanto ayuda a compartir un contenedor entre múltiples clases de prueba, como permite ejecutar la aplicación localmente usando spring-boot:test-run.

Cosas a recordar:

  • Fija la versión de la imagen para que coincida con producción.
  • No mezcles @Container estático con el caché de contexto de Spring.
  • Limpia los datos tú mismo cuando la prueba no se ejecuta en una transacción.
  • Usa Testcontainers solo donde realmente necesites una base de datos real.
spring-boottestcontainersintegration-testjunit5postgresql