Testcontainers for Real Database Integration Tests

Run integration tests with real PostgreSQL, MySQL, Redis, and Kafka services using Testcontainers. Learn setup, networking, CI configuration, and cleanup.

published: reading time: 22 min read author: GeekWorkBench
Quick Summary

Testcontainers lets Java integration tests launch real databases and services in disposable Docker containers, so they can exercise production-specific SQL and network behavior. The guide covers JUnit and Spring Boot setup, mapped ports, startup waits, security, CI configuration, and common failure modes. Use its decision points to choose where container-backed tests add confidence and where mocks or embedded databases keep feedback faster.

Testcontainers for Real Database Integration Tests

Introduction

Integration tests sit between fast unit tests and realistic end-to-end tests. An in-memory database can accept a query that production PostgreSQL rejects, while asking every developer to install and configure PostgreSQL by hand makes the suite harder to run.

Testcontainers launches disposable Docker containers for dependencies such as PostgreSQL, MySQL, Redis, and Kafka. A test can use the container’s JDBC URL to connect to a real database:

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

@Test
void connectsToRealPostgres() throws SQLException {
    try (Connection connection = DriverManager.getConnection(
            postgres.getJdbcUrl(), postgres.getUsername(), postgres.getPassword())) {
        assertThat(connection.isValid(1)).isTrue();
    }
}

This guide explains how that test environment works, when its realism is worth the setup cost, and how to handle common CI and container failures.

TestContainers Architecture

TestContainers sits between your JUnit tests and Docker. When a test class annotated with @Testcontainers starts, the JUnit extension boots Docker containers defined by @Container fields. Each container initializes with its image, environment variables, exposed ports, and initialization scripts before any test method runs. After the test suite completes, TestContainers sends a stop signal and destroys the containers, freeing system resources.

graph TD
    A["JUnit Test Class<br/>@Testcontainers"] --> B["Testcontainers JUnit5 Extension"]
    B --> C["Docker Daemon"]
    C --> D["PostgreSQL Container<br/>port: 5432"]
    C --> E["Redis Container<br/>port: 6379"]
    C --> F["MySQL Container<br/>port: 3306"]
    D --> G["Ephemeral Volume<br/>data directory"]
    E --> H["Ephemeral Volume<br/>data directory"]
    F --> I["Ephemeral Volume<br/>data directory"]
    J["Test Code"] --> K["JDBC Connection<br/>jdbc:postgresql://localhost:mapped"]
    J --> L["Jedis/Redisson<br/>localhost:mapped"]
    K --> D
    L --> E

Your application connects through dynamically mapped ports. Docker assigns a random host port for each exposed container port, and TestContainers exposes these via getMappedPort(). The connection string points to localhost with the mapped port, exactly as it would in production.

Failure Scenarios

TestContainers depends on Docker being available and responsive. Several failure modes deserve attention.

Docker Not Available

If Docker is not running when your tests start, you get an IllegalStateException saying Docker is not reachable. Your CI pipeline must verify Docker is running as a precondition. Most CI environments need explicit startup commands or service initialization before TestContainers can use them.

// This throws IllegalStateException if Docker is not available
@Container
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:15-alpine");

Check Docker availability before running tests in CI by adding a precondition step.

Port Conflicts

Docker assigns random host ports when you expose container ports without explicit mapping. This prevents most conflicts, but if your machine runs services on the same ephemeral port range Docker chooses, startup can fail. Testcontainers assigns a random host port for an exposed container port, which avoids most collisions. Avoid binding a fixed host port unless a test specifically requires it; retrieve the assigned value with getMappedPort(5432).

Container Startup Timeout

Containers pulling large images on first run can exceed default startup timeouts. TestContainers defaults to sixty seconds, sufficient for most databases with pre-pulled images but may fail on cold CI runners or slow network connections.

Configure per-container startup timeout:

new PostgreSQLContainer("postgres:15-alpine")
    .withStartupTimeout(Duration.ofSeconds(120));

Trade-off Analysis

Dimension TestContainers Embedded Databases In-Memory Mocks
Realism Real engine and protocol behavior Database engine (H2/MySQL mode) but not production-grade Behavior only, no actual storage
Startup Time 15-60 seconds (first run slower) Sub-second Near-instant
CI Compatibility Requires Docker daemon Works everywhere Works everywhere
Maintenance Image updates needed periodically No maintenance No maintenance
Database-Specific Features Full support Limited to compatibility modes None
Network Behavior Real TCP/IP, actual latency In-process None
Parallel Test Execution Port conflicts possible Safe Safe
Resource Usage CPU, memory, disk for containers Minimal Minimal

TestContainers wins on realism and feature coverage. It loses on startup time and CI complexity. Embedded databases win on speed and portability. Choose based on what your tests actually need to verify.

Implementation

Basic PostgreSQL Setup

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

@Testcontainers
class PostgresRepositoryTest {

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

    private Connection connection;

    @BeforeEach
    void setUp() throws SQLException {
        connection = DriverManager.getConnection(
            postgres.getJdbcUrl(),
            postgres.getUsername(),
            postgres.getPassword()
        );
    }

    @Test
    void shouldInsertAndRetrieveUser() throws SQLException {
        connection.createStatement().execute(
            "INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com')"
        );

        var resultSet = connection.createStatement().executeQuery(
            "SELECT * FROM users WHERE name = 'Alice'"
        );

        assertThat(resultSet.next()).isTrue();
        assertThat(resultSet.getString("email")).isEqualTo("alice@example.com");
    }
}

Basic MySQL Setup

import org.testcontainers.containers.MySQLContainer;

@Testcontainers
class MySqlRepositoryTest {

    @Container
    static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.0")
        .withDatabaseName("testdb")
        .withUsername("testuser")
        .withPassword("testpassword");

    @Test
    void shouldConnectToMySQL() {
        assertThat(mysql.getJdbcUrl()).contains("jdbc:mysql://");
        assertThat(mysql.getDatabaseName()).isEqualTo("testdb");
    }
}

GenericContainer for Custom Services

When a specialized container module does not exist, use GenericContainer:

import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

@Testcontainers
class ElasticsearchIntegrationTest {

    @Container
    static GenericContainer<?> elasticsearch = new GenericContainer<>(
        DockerImageName.parse("elasticsearch:8.11.0")
    )
    .withExposedPorts(9200, 9300)
    .withEnv("discovery.type", "single-node")
    .withEnv("xpack.security.enabled", "false")
    .waitingFor(Wait.forHttp("/").forPort(9200));

    @Test
    void shouldConnectToElasticsearch() {
        String host = elasticsearch.getHost();
        Integer mappedPort = elasticsearch.getMappedPort(9200);
        // connect to http://host:mappedPort
    }
}

Custom Initialization Scripts

Initialize databases with schema and seed data before tests run by mounting initialization scripts:

@Container
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:15-alpine")
    .withInitScript("db/init-schema.sql")
    .withUsername("testuser")
    .withPassword("testpassword");

Create the script file at src/test/resources/db/init-schema.sql:

CREATE TABLE IF NOT EXISTS users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE IF NOT EXISTS orders (
    id SERIAL PRIMARY KEY,
    user_id INTEGER REFERENCES users(id),
    product VARCHAR(255) NOT NULL,
    quantity INTEGER NOT NULL,
    order_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO users (name, email) VALUES
    ('Alice', 'alice@example.com'),
    ('Bob', 'bob@example.com');

Spring Boot Integration

TestContainers integrates smoothly with Spring Boot’s test slices:

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;

@SpringBootTest
class OrderServiceIntegrationTest {

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

    @DynamicPropertySource
    static void properties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
        registry.add("spring.jpa.hibernate.ddl-auto", () -> "none");
    }

    @Test
    void shouldCreateOrderForExistingUser(@Autowired OrderService orderService) {
        Order order = orderService.createOrder(1L, "Widget", 3);
        assertThat(order.getId()).isNotNull();
    }
}

The @DynamicPropertySource annotation injects container connection properties into the Spring context before the application starts, replacing whatever you have in application.properties.

Observability Checklist

When running integration tests against real containers, you need visibility into what is happening.

  • Container logs: Access via container.getLogs() for debugging failures. Embedding logs in test reports saves time when diagnosing CI failures you cannot reproduce locally.

  • Startup duration: Monitor how long containers take to initialize. Excessive startup times indicate image pull overhead or slow initialization scripts. Cache images in CI to keep feedback loops short.

  • Connection verification: Add explicit connection checks before running assertions. A container reporting “running” does not mean your application can query it yet.

@Test
void shouldWaitForDatabaseReadiness() {
    await()
        .atMost(30, TimeUnit.SECONDS)
        .until(() -> {
            try (Connection conn = DriverManager.getConnection(
                postgres.getJdbcUrl(),
                postgres.getUsername(),
                postgres.getPassword())) {
                return conn.createStatement()
                    .executeQuery("SELECT 1")
                    .next();
            } catch (SQLException e) {
                return false;
            }
        });
}
  • Query logging: In a Spring Boot test profile, set spring.jpa.show-sql=true and logging.level.org.hibernate.SQL=DEBUG to capture executed queries. This helps identify N+1 problems or unexpected query patterns during integration tests.

Security Notes

Containers provide process isolation, but that isolation has limits worth understanding.

Container Isolation

Containers on the same Docker network can often reach each other by service name. If your test suite includes multiple containers (PostgreSQL, Redis, Kafka), they may be able to communicate through Docker’s internal DNS. This is generally safe for integration tests but differs from production network segmentation.

Use unique database names and credentials per test class when running tests in parallel:

@Container
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:15-alpine")
    .withDatabaseName("test_" + UUID.randomUUID().toString().replace("-", ""))
    .withUsername("test_" + UUID.randomUUID().toString().substring(0, 8))
    .withPassword(UUID.randomUUID().toString());

Credential Management

Never hardcode credentials, even in test code. Use environment variables or externalized configuration:

.withUsername(Optional.ofNullable(System.getenv("POSTGRES_USER")).orElse("test"))
.withPassword(Optional.ofNullable(System.getenv("POSTGRES_PASSWORD")).orElse("test"));

For CI environments, inject secrets through the pipeline’s secret management system instead of passing them as plain text in configuration files.

Image Provenance

Pull images only from trusted registries. Specify exact image digests in production-critical CI to prevent supply chain attacks through image tags that get updated unexpectedly:

// Prefer digest over tag for reproducible builds
new PostgreSQLContainer("postgres@sha256:abc123...");

Common Pitfalls / Anti-Patterns

Resource Cleanup

TestContainers containers are automatically stopped when the JVM exits, but abrupt shutdowns can leave dangling containers or Docker processes. Prefer try-with-resources or explicit cleanup in @AfterAll:

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

If your tests spawn many containers in sequence, Docker’s local image cache helps but disk space can accumulate. Run docker system prune periodically in CI to clean up unused images and volumes.

CI/CD Compatibility

TestContainers works on all major CI platforms (GitHub Actions, GitLab CI, Jenkins, CircleCI) but requires Docker-in-Docker or Docker socket mounting. GitHub Actions runners already have Docker installed; you may need to enable it explicitly:

services:
  docker:
    image: docker:20.10.16-dind

Jenkins agents need the Docker pipeline plugin or Docker-in-Docker configuration. Add a startup check step to verify Docker is available before running tests.

Parallel Test Execution

JUnit’s parallel execution combined with TestContainers requires careful port management. If two test classes both launch PostgreSQL containers without explicit port mapping, Docker assigns random ports and no conflict occurs. If multiple tests share one container or database state, they need their own synchronization and isolation strategy; Testcontainers does not provide a built-in @Shared annotation.

Use the @TestMethodOrder annotation with deterministic ordering if tests depend on shared state, or better yet, design tests to be independent and let TestContainers manage per-class container isolation.

Slow Image Pulls on Cold CI

First-run image pulls on CI runners without Docker layer caching can add minutes to your build. Pre-pull images in a CI setup step:

docker pull postgres:15-alpine
docker pull mysql:8.0
docker pull redis:7.0-alpine

Container reuse can reduce repeated startup work in local development, but Docker’s image cache is what avoids pulling an image again.

When NOT to Use

TestContainers is useful, but it is not always the right fit. Consider alternatives in these situations:

When Embedded or In-Memory Databases Suffice

If your tests only verify that a repository calls the correct SQL string, returns mapped entities, or handles basic CRUD operations, an in-memory database like H2 handles this without Docker overhead. When your tests need behavior that differs between H2 and your production database, that is when TestContainers becomes worthwhile. Until then, the added complexity is hard to justify.

When Tests Do Not Need Real Database Behavior

Some tests exercise business logic that happens to use a repository, without depending on any database-specific feature. In these cases, mocking the repository removes the need for any database at all. TestContainers does not help here. A well-designed unit test with proper mocking is faster, more deterministic, and easier to run in isolation.

When Docker Is Not Available in CI

Some CI environments, especially older self-hosted runners or certain containerized sandbox setups, either lack Docker or restrict its use. If your pipeline cannot run Docker containers reliably, TestContainers adds friction without benefit. Check whether your CI infrastructure can support Docker before deciding on TestContainers as a testing strategy.

When Startup Time Is Critical

TestContainers containers take seconds to start, even with cached images. For large test suites that must run in seconds, this cost multiplies across hundreds of test classes. If your team has strict fast-feedback requirements and your tests do not need real database behavior, an embedded alternative or pure mocking will outperform TestContainers.

When You Only Need to Verify Mock Interactions

If you only want to confirm that a service layer calls a repository method with specific arguments and handles the returned data correctly, mocking the repository and writing focused unit tests is simpler. TestContainers introduces real infrastructure for a problem that does not require it.

In short, reach for TestContainers when your tests must exercise real database behavior, real service interactions, or production-like conditions. When those requirements do not apply, the faster and lighter alternatives are usually preferable.

Quick Recap Checklist

Before shipping your TestContainers integration to production:

  • @Testcontainers annotates the test class
  • @Container marks each container field
  • Containers use specific image tags or digests, not latest
  • Initialization scripts live in src/test/resources/
  • Connection properties injected via @DynamicPropertySource for Spring Boot
  • Credentials come from environment variables, not hardcoded strings
  • Container logs captured for CI failure diagnosis
  • Startup timeout adjusted for cold CI runners
  • Docker availability verified as a CI precondition
  • Parallel test isolation understood and configured if needed

Testing Kafka with Testcontainers

Apache Kafka is a distributed streaming platform with complex startup sequences that make it an ideal candidate for TestContainers. The KafkaContainer module handles Zookeeper initialization and Kafka broker configuration automatically:

@Testcontainers
class KafkaConsumerIntegrationTest {

    @Container
    static KafkaContainer kafka = new KafkaContainer("apache/kafka:3.7.0")
        .withEmbeddedZookeeper();

    @Test
    void shouldConsumeMessagesFromKafka() {
        String bootstrapServers = kafka.getBootstrapServers();

        KafkaProducer<String, String> producer = new KafkaProducer<>(
            Map.of(
                "bootstrap.servers", bootstrapServers,
                "key.serializer", StringSerializer.class.getName(),
                "value.serializer", StringSerializer.class.getName()
            )
        );

        producer.send(new ProducerRecord<>("test-topic", "key", "value"));

        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(
            Map.of(
                "bootstrap.servers", bootstrapServers,
                "group.id", "test-group",
                "key.deserializer", StringDeserializer.class.getName(),
                "value.deserializer", StringDeserializer.class.getName(),
                "auto.offset.reset", "earliest"
            )
        );

        consumer.subscribe(List.of("test-topic"));

        ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(5));

        assertThat(records.count()).isEqualTo(1);
        assertThat(records.iterator().next().value()).isEqualTo("value");
    }
}

Note that .withEmbeddedZookeeper() is required for Kafka versions prior to 3.6. Starting with Kafka 3.6 and later, KRaft mode eliminates the Zookeeper dependency, and you can use KafkaContainer without the embedded Zookeeper:

static KafkaContainer kafka = new KafkaContainer("apache/kafka:3.7.0");

For Spring Boot applications, register Kafka properties through @DynamicPropertySource:

@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
    registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers);
}

Interview Questions

1. What problem does TestContainers solve that H2 or embedded databases cannot?
TestContainers runs real database engines like PostgreSQL and MySQL inside Docker containers, whereas H2 simulates a database in the JVM process. The distinction matters when tests depend on database-specific behaviors such as stored procedures, native SQL features, replication semantics, or exact constraint enforcement. H2 compatibility modes approximate real database behavior but diverge in ways that cause subtle bugs to surface only in production. TestContainers also handles services that have no embedded alternative, like Kafka, Redis clusters, or MongoDB replicas, giving you the same real-software testing approach across your entire stack.
2. How does TestContainers integrate with JUnit 5 and what lifecycle methods does it provide?
The @Testcontainers annotation activates the TestcontainersExtension in JUnit 5. The extension launches containers marked with @Container before any test method runs and stops them after the test class finishes. You can control container lifecycle by annotating fields as static (shared across all test methods in the class) or instance fields (restarted between methods). For class-level containers, declare them as static. For method-level isolation, remove the static modifier. The extension respects test method ordering through @TestMethodOrder and handles exceptions gracefully, stopping containers even when tests fail.
3. How do you pass database connection properties from TestContainers to a Spring Boot application?
Use the @DynamicPropertySource annotation on a static method that registers container properties into Spring's DynamicPropertyRegistry. The method receives the registry as a parameter and adds entries like spring.datasource.url, spring.datasource.username, and spring.datasource.password using the container's getter methods as value suppliers. Spring Boot resolves these properties before the ApplicationContext initializes, so your DataSource autoconfiguration picks up the TestContainers connection details instead of whatever you have in application.yml. This approach works for any property Spring Boot consumes at startup, including JPA settings, Flyway migration configuration, and connection pool tuning.
4. What are the main failure scenarios when running TestContainers in CI/CD pipelines?
The most frequent failure is Docker not being available or not having permission to access the Docker socket. CI runners need either Docker-in-Docker configured or the Docker socket mounted from the host. Port conflicts rarely occur because TestContainers assigns random mapped ports, but extremely constrained environments with many services competing for the ephemeral port range can still cause startup failures. Container image pulls on cold CI runners introduce significant latency on first run, which teams mitigate by pre-pulling images or using layer caching in their CI configuration. Startup timeouts can also trigger if databases need longer initialization than the default sixty seconds allows; adjusting withStartupTimeout() solves this.
5. How would you run multiple TestContainers containers that need to communicate with each other, such as PostgreSQL and Redis for a cache-backed service?
Declaring multiple @Container fields starts each container, but does not automatically place them on a shared user-defined network. To let containers address one another by network alias, create a Network and attach each container with withNetwork(network) and withNetworkAliases(...). Code running on the host should use each container's getHost() and getMappedPort(...) values instead. For Spring Boot, register each service's connection properties separately with @DynamicPropertySource.
6. What happens to TestContainers containers when a test crashes or the JVM terminates unexpectedly?
TestContainers registers a JVM shutdown hook that stops containers when the JVM exits normally. However, abrupt terminations such as SIGKILL or sudden power loss bypass this hook entirely, potentially leaving dangling containers on the Docker host. Docker does not automatically remove every orphaned container, so CI environments should use a controlled cleanup step if an abrupt termination leaves resources behind. The @AfterAll method or try-with-resources pattern provides explicit cleanup for the normal case, while docker system prune in CI pipelines handles the exceptional case. For critical CI workflows, add a post-build step that forcefully removes any remaining containers by pattern matching on naming conventions used in test classes.
7. How does TestContainers handle container reuse across test classes, and when should you enable it?
Testcontainers supports opt-in container reuse through withReuse(true) and local configuration. Reused containers can remain running after the test JVM exits and may be reused by later runs; they are not automatically removed at JVM shutdown. This can reduce startup time, but it also means tests must reset shared state deliberately. However, reuse introduces state leakage between tests if containers are not designed for idempotent initialization. Use reuse for long-running integration suites where container startup dominates execution time, but avoid it when tests modify database state in ways that conflict with other test classes. In CI, prefer @Container per-class isolation with image layer caching rather than container reuse, since parallel jobs cannot share reused containers anyway.
8. What wait strategies does TestContainers support, and how do you choose the right one for a service?
TestContainers provides several wait strategies: Wait.forHttp() waits for an HTTP endpoint to return a successful response; Wait.forPort() waits for a TCP port to be open; Wait.forLogMessage() waits for a specific log message pattern; Wait.forExposedPorts() waits for all declared ports to be accessible; and Wait.forSuccessfulCommand() runs a shell command and checks its exit code. Choose based on what the container exposes and how it signals readiness. Databases typically signal readiness through log messages or port availability, making Wait.forLogMessage() or Wait.forPort() appropriate. Application services with health endpoints work well with Wait.forHttp(). For custom services, you can combine multiple wait strategies or implement WaitStrategy directly. Always prefer the least brittle approach that still guarantees the service is ready to accept connections.
9. How do you test database migration scripts using TestContainers?
Run database migration scripts inside TestContainers by combining initialization scripts with explicit migration tool configuration. Use withInitScript() to load schema creation scripts before tests run, or configure Flyway or Liquibase through @DynamicPropertySource to point at the TestContainers database. Spring Boot applications register migration tools as usual, and @DynamicPropertySource injects the container's JDBC URL, username, and password before the ApplicationContext starts. This means Flyway or Liquibase runs against the real database engine automatically. For non-Spring applications, invoke the migration tool explicitly in @BeforeAll after the container starts, using the container's JDBC URL to establish a connection. This approach verifies that migration scripts work against the actual database engine with all its specific SQL dialect support.
10. What are the security implications of running containers in tests, and how should you handle sensitive data?
Test containers run with Docker's default isolation, which means they share the host kernel but have separate filesystem and network namespaces. Sensitive data in container environment variables or mounted volumes is visible to any process on the host with Docker access. Never pass production credentials through TestContainers, even in tests. Use environment variables for container configuration and inject secrets through CI secret management systems rather than hardcoding them in test code. The Docker socket itself represents a significant privilege—if compromised, attackers gain root access to the host. Restrict Docker socket access to trusted users and CI service accounts. For teams with strict security requirements, consider rootless Docker or Podman as alternatives that provide stronger process isolation while remaining compatible with TestContainers.
11. How does TestContainers behave differently between Apple Silicon (ARM) and Intel/AMD (x86_64) architectures?
Many official Docker images publish multi-architecture manifests, but some images lack ARM64 variants and will run through emulation via Rosetta 2 on Apple Silicon. Emulated execution is significantly slower, especially for database containers that perform heavy computation. When using Apple Silicon in development, prefer images explicitly tagged with -arm64 or :latest-arm64v8 where available. PostgreSQL, MySQL, and Redis all provide ARM-compatible images. If an image lacks ARM support, you may encounter slower container startup and higher CPU usage during initialization. In CI, x86_64 runners are the norm, so this rarely affects pipeline performance. Test your containers on the target architecture before assuming behavior is identical across platforms.
12. How do you configure TestContainers to use a private Docker registry for images?
TestContainers respects Docker's image pull configuration, so if Docker is configured to authenticate against a private registry, TestContainers automatically uses those credentials. Configure Docker credentials in ~/.docker/config.json or through docker login on CI runners. For corporate registries requiring custom CA certificates, ensure the Docker daemon has access to the registry CA and that the DOCKER_CERT_PATH environment variable points to the correct certificate directory. TestContainers also supports registryConfig and dockerConfig in the DockerClientConfig if you need programmatic configuration. In Kubernetes-based CI, image pull secrets are handled separately through Kubernetes ServiceAccount configurations, which Docker does not automatically consume—ensure your CI pod specification includes the appropriate imagePullSecrets.
13. What approaches exist for debugging TestContainers failures, and how do you inspect container state?
Debugging TestContainers failures requires inspecting container logs, process state, and network connectivity. Call container.getLogs() to retrieve stdout and stderr from the container, which often reveals initialization errors or service crashes. Use container.execInContainer() to run diagnostic commands inside the running container, such as checking database connection state or querying internal service metrics. For network issues, DockerLogsFinder and Wireshark on the Docker bridge interface help identify connection problems. In IntelliJ and Eclipse, the TestContainers extension provides a Docker view showing container status, port mappings, and log streams. For persistent debugging, start a container with a long startup timeout and a breakpoint in your test setup, then manually verify the service is responsive before continuing.
14. How does TestContainers compare to using docker-compose for integration testing?
Docker Compose can provide services for integration tests, but the test harness must start and clean them up and manage state between runs. Testcontainers integrates container lifecycle with JUnit, which makes per-class setup and teardown easier to express in Java. Compose remains useful when a test environment needs a large, stable service topology defined in YAML. Some teams use Compose for local development and Testcontainers for tests that benefit from fresh containers, random mapped ports, or per-test configuration.
15. How do you handle database seeding and test data management across multiple test classes with TestContainers?
Effective test data management with TestContainers relies on initialization scripts, per-class database isolation, or transactional rollback strategies. Use withInitScript() for schema and seed data that every test in the class requires, accepting that all tests share this baseline. For independent test data, give each test method a unique identifier prefix or suffix in table rows, then query with appropriate filters. Spring Boot applications can combine @Transactional on test classes with @Rollback(false) and explicit EntityManager operations for more granular control. Another pattern uses TestContainers' GenericContainer to launch a dedicated seed data container that produces a database backup or SQL dump, which subsequent containers restore via initialization scripts. Choose based on whether tests need shared baseline data or completely isolated datasets.

Further Reading

Conclusion

Testcontainers lets integration tests exercise real databases and services through disposable Docker containers. It is useful when database-specific behavior, wire protocols, or service configuration matter to the test; it adds startup time and requires a working Docker environment. Use it for the cases where a substitute could hide a production issue, and keep unit tests fast by mocking dependencies when infrastructure behavior is outside their scope.

Category

Related Posts

Spring Boot Build Tools: Maven & Gradle

Configure Maven and Gradle for Spring Boot projects—plugins, dependency management, packaging JARs and WARs, and build automation essentials.

#spring-boot #spring-boot-roadmap #learning-path

Embedded Web Servers in Spring Boot: Tomcat, Jetty, Undertow

Configure embedded servers in Spring Boot: compare Tomcat, Jetty, and Undertow, tune thread pools, enable access logs, and switch implementations.

#spring-boot #spring-boot-roadmap #learning-path

JUnit 5 & Jupiter: Lifecycle, Nested & Parameterized Tests

Explore JUnit 5 Jupiter features: master test lifecycle annotations, organize tests with @Nested, and parameterize tests with @CsvSource and @MethodSource.

#spring-boot #spring-boot-roadmap #learning-path