INTERMEDIATEJAVATESTING

Reusable Testcontainers Postgres Base Class for Spring Boot

An abstract @Testcontainers base class that boots a Postgres container once per test suite and wires it into Spring Boot via @DynamicPropertySource.

Published May 6, 2026
javaspring-boottestcontainerspostgrestesting

PostgresTestContainerBase is the abstract class I extend in every Spring Boot integration test. It boots a single Postgres container once per JVM, wires its connection details into Spring's data source properties, and exposes a clean inheritance point. Drop it in, extend it, and your @SpringBootTest runs against a real Postgres in milliseconds per test.

Tested on Spring Boot 3.4, JDK 25, Testcontainers 1.20+.

When to Use This

  • Replacing in-memory H2 with a real Postgres in your integration tests
  • Catching SQL portability issues that H2 hides (JSONB, window functions, ON CONFLICT)
  • Sharing one container across many test classes for speed
  • Running CI integration tests that require database-specific behavior

Don't use this when your tests are pure unit tests with no SQL (mock the repository) or when you genuinely need a clean database per test (use container per class instead, with a different base).

Code

@Testcontainers
@SpringBootTest
public abstract class PostgresTestContainerBase {
 
    @Container
    static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test")
            .withReuse(true); // singleton container across runs
 
    static {
        POSTGRES.start();
    }
 
    @DynamicPropertySource
    static void registerProperties(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", () -> "create-drop");
    }
}

The static block starts the container before Spring's @DynamicPropertySource runs, which guarantees the JDBC URL is available the moment Spring starts wiring beans. withReuse(true) is the magic that keeps the container alive across mvn test runs locally — set testcontainers.reuse.enable=true in ~/.testcontainers.properties.

Usage

A repository test that extends the base:

class UserRepositoryTest extends PostgresTestContainerBase {
 
    @Autowired
    UserRepository repo;
 
    @Test
    void shouldUpsertOnConflict() {
        var user = new User("rabi@example.com", "Rabi");
        repo.upsert(user);
        repo.upsert(user); // second call should not throw
 
        assertThat(repo.findByEmail("rabi@example.com")).isPresent();
    }
}
class OutboxPublisherTest extends PostgresTestContainerBase {
 
    @Autowired OutboxPublisher publisher;
    @Autowired JdbcTemplate jdbc;
 
    @Test
    void shouldPublishPendingEvents() {
        jdbc.update("INSERT INTO outbox (aggregate_type, aggregate_id, event_type, payload) VALUES (?,?,?,?::jsonb)",
                "Order", "123", "OrderCreated", "{}");
 
        publisher.drain();
 
        Integer remaining = jdbc.queryForObject(
                "SELECT COUNT(*) FROM outbox WHERE published_at IS NULL",
                Integer.class);
        assertThat(remaining).isZero();
    }
}

Both test classes share the same Postgres container, started exactly once.

Caveats

  • Container reuse needs to be enabled per developer machine. Add testcontainers.reuse.enable=true to ~/.testcontainers.properties. Without it, withReuse(true) is ignored and a new container starts every run.
  • ddl-auto=create-drop resets the schema each time Spring starts. That's what you want for test isolation, but it slows down tests if your schema is huge. For very large schemas, use Flyway/Liquibase migrations and validate instead.
  • The @Testcontainers annotation is from org.testcontainers.junit.jupiter, not org.testcontainers. Easy import mistake.
  • CI must have Docker available. GitHub Actions ubuntu-latest has it pre-installed. Self-hosted runners may not.
  • Don't share state between tests via the database. Even with one container, each test should set up its own data and clean up after, or use @Transactional on the test class so changes roll back automatically.

Frequently Asked Questions

Why use a base class instead of @Testcontainers per test?

A base class with a static container reuses the same Postgres instance across every test class that extends it, which is dramatically faster than spinning a new container per test class. Combined with the singleton container pattern, you get one container per JVM run.

How does Spring Boot pick up the dynamic Postgres connection details?

@DynamicPropertySource lets you register property values at runtime, before the Spring context starts. The container exposes its random JDBC URL and credentials, and the base class wires those into spring.datasource.* before any bean is created.

X (Twitter)LinkedIn