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:
@Testcontainersactiva la extensión JUnit 5. Esta extensión busca campos con@Containery gestiona su ciclo de vida.@Containeren un campostatic: 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 esstatic, 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 beanJdbcConnectionDetails. Gracias a esto, no tienes que configurar manualmentespring.datasource.url.@AutoConfigureTestDatabase(replace = NONE): por defecto,@DataJpaTestpuede 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_PORTinicia un servidor real.TestRestTemplateapunta 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. isEqualByComparingTocomparaBigDecimalpor valor. Este enfoque evita el error de que250000sea diferente de250000.00debido a diferentes escalas.- La clase
Ordernecesita 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:
- 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.
- Usa un contenedor singleton: elimina
@Containery@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 etiquetalatestpuede 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
@Containerestá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.