Pavan Rangani

HomeBlogSpring Boot Testcontainers: Integration Testing Guide for 2026

Spring Boot Testcontainers: Integration Testing Guide for 2026

By Pavan Rangani · February 26, 2026 · Java & Spring

Spring Boot Testcontainers: Integration Testing Guide for 2026

Spring Boot Testcontainers: Integration Testing Guide

Spring Boot Testcontainers has transformed how developers write integration tests in modern Java applications. Therefore, instead of relying on mocked dependencies or shared test databases, you can now spin up real services in Docker containers during your test lifecycle. This approach catches bugs that unit tests simply cannot detect, because the database, broker, or cache your code talks to in the test is the same engine you run in production.

Why Integration Testing Needs Real Dependencies

Unit tests verify individual methods in isolation, but they miss issues at system boundaries. Moreover, mocking a database driver hides SQL dialect differences and constraint violations. As a result, many teams discover integration bugs only in staging or production environments, where they are far more expensive to diagnose and fix.

Furthermore, in-memory databases like H2 behave differently from PostgreSQL or MySQL in production. Consequently, tests that pass locally may fail when deployed against the actual database engine. For instance, H2 silently accepts certain syntax that PostgreSQL rejects, and it lacks faithful support for JSONB columns, array types, partial indexes, and advisory locks. Therefore, a green H2 test suite can give false confidence right up to the moment a migration hits the real database.

Spring Boot Testcontainers development environment with code editor
Modern Java development environment configured for integration testing

Setting Up the Library in Your Project

Getting started requires adding the Testcontainers dependencies to your Maven or Gradle build. Additionally, Spring Boot 3.1+ provides first-class support through the spring-boot-testcontainers module, which is what makes the wiring below so concise:

@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {

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

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldPersistOrderWithItems() {
        Order order = new Order("customer-1", List.of(
            new OrderItem("SKU-100", 2, 29.99)
        ));
        Order saved = orderRepository.save(order);
        assertThat(saved.getId()).isNotNull();
        assertThat(saved.getItems()).hasSize(1);
    }
}

The @ServiceConnection annotation automatically configures the datasource URL, username, and password. Therefore, you no longer need manual property overrides in your test configuration. Under the hood it registers a ConnectionDetails bean that resolves the container’s mapped host and randomized port at runtime, which is why nothing in your application.yml needs to know about the container.

How the Container Lifecycle Actually Works

Understanding the lifecycle prevents the most common mistakes. The @Testcontainers extension scans for fields annotated with @Container; a static field starts once before all tests in the class and stops after the last one, while an instance field starts and stops around every single test method.

That distinction has real cost implications. A fresh PostgreSQL container typically takes a couple of seconds to become ready, so an instance-scoped container in a class with twenty tests pays that startup forty times. In contrast, a static container pays it once. Therefore, prefer static containers and write tests that clean up their own data, rather than relying on a fresh database per method.

Behind the scenes, the library also launches a tiny companion container called Ryuk. Specifically, Ryuk watches the test JVM and forcibly removes containers, networks, and volumes if the process dies unexpectedly. As a result, a crashed or killed test run does not leak Docker resources across your machine or a CI agent.

Testing with Multiple Containers

Real applications depend on more than just a database. Specifically, you might need Redis for caching, Kafka for messaging, or Elasticsearch for search. The library supports all of these simultaneously, and each has a dedicated module with sensible defaults so you describe intent rather than wiring ports by hand.

@SpringBootTest
@Testcontainers
class CheckoutFlowIntegrationTest {

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

    @Container @ServiceConnection
    static GenericContainer<?> redis =
        new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

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

    @Test
    void shouldPublishEventAfterCheckout() {
        // order persisted in Postgres, session cached in Redis,
        // OrderPlaced event produced to Kafka — all real services
    }
}

However, running multiple containers increases test startup time. In contrast, using @Container with static fields ensures containers are shared across all test methods in the class. As a result, the overhead is paid only once per test class, and a checkout flow exercising three real backends still starts in a handful of seconds.

Server-side testing infrastructure with Docker containers
Docker containers providing isolated test environments for each service

Performance Optimization Strategies

Slow integration tests discourage developers from running them frequently. Therefore, consider using reuse mode, which keeps containers alive between test runs on a developer’s machine. Additionally, sharing a single container across many test classes — the singleton pattern — avoids repeated startup across the whole suite.

For example, enabling reuse is as simple as adding .withReuse(true) to your container definition and opting in via testcontainers.reuse.enable=true in ~/.testcontainers.properties. Meanwhile, a singleton container declared in an abstract base class starts on first access and is never stopped, so every subclass test reuses the same instance:

abstract class AbstractIntegrationTest {

    static final PostgreSQLContainer<?> POSTGRES =
        new PostgreSQLContainer<>("postgres:16-alpine").withReuse(true);

    static {
        POSTGRES.start(); // started once for the whole JVM, never stopped
    }

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

One subtlety: because a reused or singleton container survives across tests and classes, it accumulates state. Consequently, you must reset data deliberately — truncate tables, flush Redis, or wrap each test in a rolled-back transaction — otherwise tests start passing or failing based on execution order, which is the worst kind of flakiness to debug.

CI/CD Pipeline Configuration

Running these tests in CI requires Docker-in-Docker or a mounted Docker socket. Furthermore, GitHub Actions and GitLab CI both support this out of the box with their standard runners. Specifically, configure resource limits to prevent container sprawl in shared CI environments, and pin exact image tags so a moving latest tag never changes test behavior between runs.

Reuse mode is intentionally a no-op in most CI setups, since each pipeline run gets a clean agent and there is nothing to reuse. Therefore, the bigger CI wins come from caching Docker image layers and constraining parallelism so you do not exhaust the agent’s memory. A common pitfall is image pull rate limits; pulling shared images through an internal registry mirror avoids throttling on busy days.

CI/CD pipeline running automated integration tests
Automated pipeline executing containerized integration tests

When Not to Use This Approach

This is not a free lunch, and it is worth being honest about the trade-offs. Every developer and CI agent needs a working Docker daemon, which complicates locked-down corporate laptops and some hosted runners. Moreover, container startup adds real seconds to feedback loops, so testing pure business logic that has no external dependency is still better served by fast plain unit tests.

In other words, reserve this technique for the boundaries that genuinely matter: persistence, messaging, caching, and search. Pushing every test through a container inflates suite time without adding coverage where the risk does not live. A balanced suite keeps a broad base of unit tests and a focused layer of containerized integration tests around the edges where dialect, serialization, and protocol bugs actually hide.

Related Reading:

Further Resources:

In conclusion, Spring Boot Testcontainers enables reliable integration testing by running real dependencies in disposable Docker containers. Therefore, adopt this approach for the system boundaries that matter, lean on static and reused containers to keep it fast, and ship with greater confidence that what passed in your tests will hold in production.

← Back to all articles