Ch. 8 · Spring Boot

Spring Boot Testcontainers

Run integration tests against real Postgres in Docker with @ServiceConnection, share one container across test classes, and keep tests isolated.

~6 min readadvancedupdated Oct 19, 2026

For years the default way to test a Spring Boot repository was an in-memory H2 database. It’s fast and needs nothing installed, and it’s not the database you run in production. Postgres-specific SQL like INSERT ... ON CONFLICT, jsonb columns, partial indexes, and Flyway migrations written for Postgres either fail on H2 or, worse, pass on H2 and fail in production. Testcontainers fixes this by starting the real database (or Kafka, Redis, a mock S3) in a Docker container for the duration of your tests.

Interviewers ask about it to see whether you understand integration tests beyond “it works on my machine”: how containers are wired into Spring, how long the suite takes, and what happens to data shared between tests.

The Java here targets Spring Boot 3.1 or later with Testcontainers 1.x and JUnit 5. It’s illustrative and wasn’t run for this article; there’s no Docker daemon in the environment it was written in. Testcontainers 2.x, used with Boot 4, moved module classes to new packages and dropped the generic type parameter on some containers, so check imports if you’re on the newer line.

The smallest working test

Add spring-boot-testcontainers, org.testcontainers:junit-jupiter and the module for your database (org.testcontainers:postgresql) as test dependencies. Then:

@SpringBootTest
@Testcontainers
class OrderRepositoryIT {

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

    @Autowired OrderRepository orders;

    @Test
    void upsertKeepsOneRowPerExternalId() {
        orders.upsert(new Order("ext-1", 4999));
        orders.upsert(new Order("ext-1", 5999));   // uses INSERT ... ON CONFLICT (external_id) DO UPDATE

        assertThat(orders.findByExternalId("ext-1")).get()
                .extracting(Order::totalCents).isEqualTo(5999);
    }
}
java

Three things happen. @Testcontainers is the JUnit extension that starts every @Container field before the tests: static fields once per class, instance fields once per test method. The container listens on a random free host port, so tests never clash with a local Postgres on 5432. And @ServiceConnection tells Boot to create a JdbcConnectionDetails bean from the running container (its JDBC URL, username and password), which the DataSource auto-configuration uses in preference to anything in application.properties. Flyway or Liquibase then runs your real migrations against the real engine, which is often the most valuable part of the test.

Before Boot 3.1 you wired this by hand, and you’ll still see it in older code:

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

It still works, and it’s what you use for anything without a built-in service connection (a custom property your code reads, for example). Boot ships service connections for Postgres, MySQL, MariaDB, Oracle, SQL Server, MongoDB, Redis, Kafka, RabbitMQ, Elasticsearch, Cassandra, Neo4j and several more.

Pin the image tag to the major version you run in production. postgres:latest means your tests silently move to a new major version on some random Tuesday.

Why the suite got slow: containers versus context caching

Starting a Postgres container takes a few seconds; how many depends on the machine and whether the image is cached. That’s fine once. It’s not fine forty times.

With the pattern above, every test class has its own static field, so every test class starts its own container. Spring’s TestContext framework, meanwhile, caches application contexts between test classes with the same configuration. Those two lifecycles don’t match. The Spring Boot documentation warns about the consequence: the JUnit extension stops a static container when its class finishes, but a cached context may still hold a connection pool pointing at it, and the next class that reuses that context fails with connection errors.

The fix is to let Spring own the container. Declare it as a bean in a shared test configuration:

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

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

    @Bean
    @ServiceConnection(name = "redis")      // a GenericContainer needs the name to pick a connection type
    GenericContainer<?> redis() {
        return new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
    }
}

@SpringBootTest
@Import(TestcontainersConfig.class)
class CheckoutFlowIT { /* no container fields */ }
java

Container beans are started before other beans and stopped after them, so the lifecycle always matches the context. And because every test class imports the same configuration, they share one cached context and therefore one set of containers. A suite of 40 integration test classes goes from 40 Postgres starts to one.

That only holds if the contexts really are identical. Anything that changes the context configuration creates a new context, and with it new containers: a different set of @MockitoBean (or the older @MockBean) fields, a different @ActiveProfiles, test-specific properties on @SpringBootTest, a different webEnvironment. If your suite starts containers far more often than you expect, look for that variation and consolidate it, for example by moving common mocks into the shared configuration. Spring logs context cache statistics at debug level for org.springframework.test.context.cache, which makes the culprits easy to spot.

Shared containers mean shared data

Once tests share a database, one test’s rows are visible to the next. A test asserting orders.count() == 1 passes alone and fails in the suite. There are three common strategies:

  • Roll back each test. Annotate the test class with @Transactional, and Spring rolls back after each method. It’s the fastest option, but it only works when the code under test runs on the test’s thread. With webEnvironment = RANDOM_PORT, requests are handled by the embedded server on other threads, in their own transactions, so nothing is rolled back. It also hides bugs that only appear on commit, like deferred constraint violations or @TransactionalEventListener(phase = AFTER_COMMIT) handlers that never fire.
  • Clean up explicitly. Truncate the tables you touch in @BeforeEach (TRUNCATE orders, order_lines RESTART IDENTITY CASCADE in Postgres), or use @Sql scripts. Slower than rollback, but it tests real commits.
  • Make data unique per test. Generate ids and customer emails per test ("user-" + UUID.randomUUID() + "@test.local") and assert only on your own rows. This scales best and is the only one that works well if you run test classes in parallel.

Most teams combine them: unique data by default, truncation for tests that count rows or check global state.

Running it locally and in CI

Testcontainers needs a Docker-compatible runtime: Docker Desktop, Colima, Podman with its Docker socket, or a remote daemon. In CI that means a runner with Docker available (GitHub Actions’ Ubuntu runners have it). Testcontainers also starts a small helper container called Ryuk that removes your containers if the JVM dies, so a crashed build doesn’t leave databases running.

For local work two features help. Container reuse (testcontainers.reuse.enable=true in ~/.testcontainers.properties plus .withReuse(true) on the container) keeps a container alive between test runs so the next run starts in milliseconds. Keep it out of CI, since reused containers carry state between builds. And Boot can use the same configuration to run the application locally against containers: a TestApplication class in src/test that calls SpringApplication.from(Application::main).with(TestcontainersConfig.class).run(args), started with ./mvnw spring-boot:test-run or ./gradlew bootTestRun. New team members get a working app without installing Postgres.

Interview question: green locally, red in the pipeline

A team’s integration tests pass on every developer laptop but fail intermittently in CI with Connection to localhost:49153 refused and occasionally duplicate key value violates unique constraint "orders_external_id_key". Each test class declares its own static PostgreSQLContainer with @Container, and some classes use @MockBean for different services. What’s going on?

Working through the answer

The connection error is the lifecycle mismatch. Each class’s container is stopped when that class finishes, but Spring has cached the application context built for it. When a later class with the same configuration reuses that cached context, its connection pool still points at the stopped container’s port, and connections are refused. Whether it happens depends on test ordering, which differs between machines and runs, hence “intermittent”.

The duplicate-key error is shared state. Classes that do share a live container and context are inserting the same hard-coded external_id values, and there’s no cleanup between them. Locally, a different order or fewer shared contexts hid it.

Move the container into a single @TestConfiguration as a @Bean with @ServiceConnection and import it everywhere, so container lifecycle follows the context. Consolidate the varying @MockBean sets where possible, so fewer distinct contexts (and containers) are created, which also speeds CI up. Then fix isolation: generate unique external ids per test, and truncate tables in @BeforeEach for tests that assert on counts. Finally, pin the Postgres image tag so CI and laptops run the same version.

Further reading

More in Spring Boot

esc