๐Ÿ”ท Core

testcontainers-expert

Use when writing integration tests that require real infrastructure dependencies such as databases, message brokers, caches, cloud service emulators, or any Dockerized service. Invoke for container lifecycle management, reusable containers, network configuration, wait strategies, custom images, and Testcontainers modules for PostgreSQL, MySQL, Kafka, Redis, LocalStack, and more.

$ npx skills add ortus-solutions/skills/testcontainers-expert
$ coldbox ai skills install ortus-solutions/skills/testcontainers-expert
๐Ÿ”— https://skills.boxlang.io/skills/raw/ortus-solutions/skills/testcontainers-expert

Testcontainers Expert

Testcontainers specialist for reliable, reproducible Java integration tests against real infrastructure.

Role Definition

Designs integration test suites that use real infrastructure in Docker containers to eliminate the gap between mocked behavior and production reality. Applies Testcontainers patterns for lifecycle management, performance optimization, and CI compatibility without sacrificing test isolation.

When to Use This Skill

  • Testing repository or DAO layers against a real database
  • Validating message-driven flows with a real Kafka or RabbitMQ broker
  • Testing against cloud service APIs with LocalStack (AWS) or Azurite (Azure)
  • Running full-stack Spring Boot slice tests with live dependencies
  • Replacing brittle embedded databases (H2) with production-equivalent engines
  • Configuring Testcontainers in CI/CD pipelines

Dependency Setup

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.testcontainers</groupId>
      <artifactId>testcontainers-bom</artifactId>
      <version>1.20.1</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
  <!-- Core -->
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <scope>test</scope>
  </dependency>
  <!-- JUnit 5 integration -->
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
  </dependency>
  <!-- Module examples โ€” add only what you need -->
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>kafka</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>localstack</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

Gradle

val testcontainersVersion = "1.20.1"

dependencies {
    testImplementation(platform("org.testcontainers:testcontainers-bom:$testcontainersVersion"))
    testImplementation("org.testcontainers:testcontainers")
    testImplementation("org.testcontainers:junit-jupiter")
    testImplementation("org.testcontainers:postgresql")
    testImplementation("org.testcontainers:kafka")
    testImplementation("org.testcontainers:localstack")
}

JUnit 5 Integration

@Testcontainers + @Container

@Testcontainers
class OrderRepositoryIT {

    // static = shared across all tests in the class (faster)
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("orders_test")
        .withUsername("test")
        .withPassword("test");

    // instance = new container per test (more isolated, slower)
    @Container
    RedisContainer redis = new RedisContainer("redis:7-alpine");

    @Test
    void shouldPersistOrder() {
        var dataSource = buildDataSource(postgres);
        var repo = new OrderRepository(dataSource);
        // ...
    }
}

Rule: Use static @Container unless you need a fresh container state for every test.

Container Lifecycle

LifecycleAnnotation comboContainer scope
Per test class (shared)static @Containerstarted once, stopped after class
Per test methodinstance @Containerstarted and stopped per test
Global (singleton)Singleton pattern (see below)started once, reused across classes
Manualcontainer.start() / container.stop()fully controlled

Manual lifecycle (programmatic)

class ProductServiceIT {

    static PostgreSQLContainer<?> postgres;

    @BeforeAll
    static void startInfrastructure() {
        postgres = new PostgreSQLContainer<>("postgres:16-alpine")
            .withReuse(true);
        postgres.start();
    }

    @AfterAll
    static void stopInfrastructure() {
        if (postgres != null) postgres.stop();
    }
}

Database Containers

PostgreSQL

static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
    .withDatabaseName("testdb")
    .withUsername("user")
    .withPassword("pass")
    .withInitScript("db/schema.sql");  // run SQL on startup

// Access connection info
String jdbcUrl   = postgres.getJdbcUrl();      // jdbc:postgresql://localhost:PORT/testdb
String username  = postgres.getUsername();
String password  = postgres.getPassword();

MySQL

static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
    .withDatabaseName("testdb")
    .withUsername("user")
    .withPassword("pass");

MongoDB

static MongoDBContainer mongo = new MongoDBContainer("mongo:7.0");

// Access URI
String mongoUri = mongo.getReplicaSetUrl();  // mongodb://localhost:PORT/test

Microsoft SQL Server

static MSSQLServerContainer<?> mssql = new MSSQLServerContainer<>("mcr.microsoft.com/mssql/server:2022-latest")
    .acceptLicense()
    .withPassword("Strong!Password1");

Database initialization strategies

Option 1: withInitScript โ€” run a classpath SQL file

.withInitScript("db/init.sql")

Option 2: Flyway / Liquibase โ€” apply migrations automatically

@BeforeAll
static void runMigrations() {
    Flyway.configure()
          .dataSource(postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())
          .load()
          .migrate();
}

Option 3: @Sql (Spring) โ€” run SQL per test method

@Test
@Sql("/test-data/orders.sql")
void shouldListOrders() { ... }

Spring Boot Integration

Approach 1: @DynamicPropertySource

@SpringBootTest
@Testcontainers
class OrderServiceIT {

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

    @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);
    }

    @Autowired
    private OrderService orderService;

    @Test
    void shouldPersistAndRetrieveOrder() {
        var placed = orderService.place(Order.of("item-1", 3));
        var found  = orderService.findById(placed.id());
        assertThat(found).isPresent().get().extracting(Order::status)
            .isEqualTo(OrderStatus.ACCEPTED);
    }
}

Approach 2: Spring Boot 3.1+ ServiceConnection

@SpringBootTest
@Testcontainers
class OrderServiceIT {

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

    // No @DynamicPropertySource needed โ€” Spring auto-configures the datasource
}

Approach 3: Reusable @TestConfiguration base class

@TestConfiguration(proxyBeanMethods = false)
public class TestInfrastructureConfiguration {

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

    @Bean
    @ServiceConnection
    KafkaContainer kafkaContainer() {
        return new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
    }
}

// Use in tests
@SpringBootTest
@Import(TestInfrastructureConfiguration.class)
class ApplicationIT { ... }

Singleton Container Pattern (Cross-class Reuse)

Avoid starting a new container per test class. Start once, share across all tests in a JVM run.

public abstract class PostgresIntegrationBase {

    static final PostgreSQLContainer<?> POSTGRES;

    static {
        POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("testdb")
            .withReuse(true);
        POSTGRES.start();
    }

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

// All tests extend the base
@SpringBootTest
class OrderRepositoryIT extends PostgresIntegrationBase { ... }

@SpringBootTest
class ProductRepositoryIT extends PostgresIntegrationBase { ... }

Enable container reuse in ~/.testcontainers.properties:

testcontainers.reuse.enable=true

Message Brokers

Kafka

static KafkaContainer kafka = new KafkaContainer(
    DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
);

// Access bootstrap servers
String bootstrapServers = kafka.getBootstrapServers();

// Spring Boot 3.1+ ServiceConnection
@Container
@ServiceConnection
static KafkaContainer kafka = new KafkaContainer(
    DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
);

RabbitMQ

static RabbitMQContainer rabbitmq = new RabbitMQContainer("rabbitmq:3.13-management")
    .withVhost("test-vhost")
    .withUser("admin", "admin");

// Access AMQP URL
String amqpUrl = rabbitmq.getAmqpUrl();

Redis

// Requires testcontainers-redis or use GenericContainer
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
    .withExposedPorts(6379)
    .waitingFor(Wait.forListeningPort());

int redisPort = redis.getMappedPort(6379);
String redisHost = redis.getHost();

Cloud Service Emulators

LocalStack (AWS)

static LocalStackContainer localstack = new LocalStackContainer(
    DockerImageName.parse("localstack/localstack:3.0")
).withServices(S3, SQS, DYNAMODB);

// Build AWS clients pointing to LocalStack
S3Client s3 = S3Client.builder()
    .endpointOverride(localstack.getEndpoint())
    .credentialsProvider(StaticCredentialsProvider.create(
        AwsBasicCredentials.create(localstack.getAccessKey(), localstack.getSecretKey())
    ))
    .region(Region.of(localstack.getRegion()))
    .build();

Azurite (Azure Storage)

static GenericContainer<?> azurite = new GenericContainer<>("mcr.microsoft.com/azure-storage/azurite:3.30.0")
    .withExposedPorts(10000, 10001, 10002)
    .withCommand("azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0")
    .waitingFor(Wait.forListeningPort());

Wait Strategies

Wait strategies prevent test failures caused by containers not being ready.

// Wait for a port to be listening (default for most containers)
.waitingFor(Wait.forListeningPort())

// Wait for an HTTP endpoint to return 200
.waitingFor(Wait.forHttp("/health").forStatusCode(200))

// Wait for HTTPS
.waitingFor(Wait.forHttps("/health").allowInsecure())

// Wait for a log message
.waitingFor(Wait.forLogMessage(".*database system is ready.*", 1))

// Wait for all of the above
.waitingFor(new WaitAllStrategy()
    .withStrategy(Wait.forListeningPort())
    .withStrategy(Wait.forLogMessage(".*ready.*", 1))
    .withStartupTimeout(Duration.ofSeconds(60))
)

// Custom wait with startup timeout
.waitingFor(Wait.forListeningPort()
    .withStartupTimeout(Duration.ofMinutes(2)))

GenericContainer โ€” Custom Images

Use when no dedicated Testcontainers module exists.

static GenericContainer<?> wiremock = new GenericContainer<>("wiremock/wiremock:3.5.4")
    .withExposedPorts(8080)
    .withClasspathResourceMapping("wiremock", "/home/wiremock", BindMode.READ_ONLY)
    .waitingFor(Wait.forHttp("/__admin/health").forStatusCode(200));

int wiremockPort = wiremock.getMappedPort(8080);
String wiremockUrl = "http://" + wiremock.getHost() + ":" + wiremockPort;

Executing commands inside container

var result = container.execInContainer("pg_dump", "-U", "postgres", "testdb");
assertThat(result.getExitCode()).isZero();
String dump = result.getStdout();

Copying files to/from container

container.copyFileToContainer(MountableFile.forClasspathResource("data.json"), "/app/data.json");
container.copyFileFromContainer("/app/output.csv", "/tmp/local-output.csv");

Docker Compose

static DockerComposeContainer<?> environment = new DockerComposeContainer<>(
    new File("src/test/resources/docker-compose.yml")
)
    .withExposedService("postgres", 5432, Wait.forListeningPort())
    .withExposedService("redis", 6379, Wait.forListeningPort())
    .withLocalCompose(true);  // use local `docker compose` binary

// Access service info
String postgresHost = environment.getServiceHost("postgres", 5432);
int postgresPort    = environment.getServicePort("postgres", 5432);

Container Networking

// Put multiple containers on the same network
static Network network = Network.newNetwork();

static GenericContainer<?> appContainer = new GenericContainer<>("myapp:latest")
    .withNetwork(network)
    .withNetworkAliases("app")
    .dependsOn(postgresContainer);

static PostgreSQLContainer<?> postgresContainer = new PostgreSQLContainer<>("postgres:16-alpine")
    .withNetwork(network)
    .withNetworkAliases("db");

// App container can reach DB via jdbc:postgresql://db:5432/testdb

Configuration and Environment Variables

new GenericContainer<>("myapp:latest")
    .withEnv("APP_ENV", "test")
    .withEnv("DB_URL", "jdbc:postgresql://db:5432/testdb")
    .withEnv(Map.of(
        "FEATURE_FLAG_X", "true",
        "LOG_LEVEL", "DEBUG"
    ))
    .withLabel("test-group", "integration");

Elasticsearch / OpenSearch

static ElasticsearchContainer elasticsearch = new ElasticsearchContainer(
    DockerImageName.parse("docker.elastic.co/elasticsearch/elasticsearch:8.13.0")
)
    .withEnv("xpack.security.enabled", "false")
    .withEnv("discovery.type", "single-node");

String httpHostAddress = elasticsearch.getHttpHostAddress();
// e.g. localhost:9200

Performance Optimization

TechniqueImpactWhen to Use
Static @ContainerHighAlways, unless fresh state needed per test
Singleton patternVery HighWhen multiple test classes share infrastructure
withReuse(true)Very HighLocal dev; combine with .testcontainers.properties
withImagePullPolicy(CACHED)MediumCI with warm Docker cache
Parallel test executionHighIndependent test classes
Shared NetworkLowWhen containers must communicate

Reuse configuration (~/.testcontainers.properties)

testcontainers.reuse.enable=true
docker.client.strategy=org.testcontainers.dockerclient.UnixSocketClientProviderStrategy

CI/CD Considerations

GitHub Actions

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
      - name: Run integration tests
        run: ./mvnw verify -Pintegration-tests
        # Docker is pre-installed on ubuntu-latest runners
        # Testcontainers works out of the box

Ryuk (resource cleanup daemon)

Testcontainers starts a Ryuk container to clean up resources after tests. In restricted environments:

// Disable Ryuk (containers survive JVM exit โ€” use with caution)
System.setProperty("testcontainers.ryuk.disabled", "true");
// OR via environment variable: TESTCONTAINERS_RYUK_DISABLED=true

Docker socket alternatives

# Use Podman socket
DOCKER_HOST=unix:///run/user/1000/podman/podman.sock
TESTCONTAINERS_RYUK_DISABLED=true

Full Spring Boot Integration Test Example

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

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withInitScript("db/schema.sql");

    @Container
    @ServiceConnection
    static KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
    );

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldCreateOrderAndPublishEvent() throws Exception {
        var request = new PlaceOrderRequest("item-1", 5);
        var response = restTemplate.postForEntity("/api/orders", request, OrderResponse.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody()).isNotNull();
        assertThat(response.getBody().status()).isEqualTo("ACCEPTED");

        // Verify database state
        var saved = orderRepository.findById(response.getBody().id());
        assertThat(saved).isPresent();

        // Verify Kafka event (consume with test consumer)
        var consumer = buildTestConsumer(kafka.getBootstrapServers());
        consumer.subscribe(List.of("orders.placed"));
        var records = consumer.poll(Duration.ofSeconds(5));
        assertThat(records.count()).isEqualTo(1);
        assertThat(records.iterator().next().value()).contains("item-1");
    }
}

Reference Guide

InfrastructureModuleImage recommendation
PostgreSQLtestcontainers:postgresqlpostgres:16-alpine
MySQLtestcontainers:mysqlmysql:8.0
MongoDBtestcontainers:mongodbmongo:7.0
Kafkatestcontainers:kafkaconfluentinc/cp-kafka:7.6.0
RabbitMQtestcontainers:rabbitmqrabbitmq:3.13-management
RedisGenericContainerredis:7-alpine
Elasticsearchtestcontainers:elasticsearchdocker.elastic.co/elasticsearch/elasticsearch:8.13.0
LocalStacktestcontainers:localstacklocalstack/localstack:3.0
WireMockGenericContainerwiremock/wiremock:3.5.4

Constraints

MUST DO

  • Use static @Container by default to share containers across test methods
  • Apply the Singleton pattern for containers shared across test classes
  • Use dedicated Testcontainers modules over GenericContainer when available
  • Always configure an appropriate waitingFor strategy
  • Use Alpine-based images in CI to reduce pull time

MUST NOT DO

  • Do not use H2 in-memory database as a substitute for PostgreSQL or MySQL in integration tests
  • Do not start containers in @BeforeEach โ€” use @BeforeAll with a static container
  • Do not hardcode host or port โ€” always use container.getHost() and container.getMappedPort()
  • Do not disable Ryuk in production CI without explicit resource cleanup strategy
  • Do not use withReuse(true) in CI pipelines where container state may pollute runs

Output Templates

## Integration Test Infrastructure Plan
- Test class: [ClassName]
- Required containers: [list with images and modules]
- Lifecycle: [static/singleton/per-test with justification]
- Spring integration: [@DynamicPropertySource / @ServiceConnection]
- Wait strategy: [per container]
- Data setup: [initScript / Flyway / @Sql]
- CI considerations: [runner, Ryuk, pull policy]

Knowledge Reference

testcontainers 1.20, junit-jupiter integration, @testcontainers, @container, postgresqlcontainer, mysqlcontainer, mongodbcontainer, kafkacontainer, rabbitmqcontainer, localstackcontainer, genericcontainer, dockercomposecontainer, wait strategies, waitforlisteningport, waitforlogmessage, waitforhttp, singleton pattern, withreuse, servicecondition, @dynamicpropertysource, @serviceconnection, spring boot 3.1 integration, network, withnetworkaliases, dependson, execincontainer, copytofromcontainer, ryuk, ci github actions, podman socket, testcontainers.properties, flyway liquibase migration, alpine images, performance optimization

  • junit-expert
  • mockito-expert
  • java-expert
  • code-reviewer