Available Hire Me
← All Writing Testing

Integration Testing with PostgreSQL Using Testcontainers in Spring Boot

How to set up Testcontainers with PostgreSQL in Spring Boot — real database, zero mocks, fast teardown, and reliable CI.

H2 in-memory databases lie to you. They accept SQL that PostgreSQL rejects, silently ignore constraint violations that your production database enforces, and handle JSON, arrays, and window functions differently enough to make integration tests meaningless. Testcontainers fixes this by spinning up a real PostgreSQL container for your tests, tearing it down afterwards, and requiring zero infrastructure outside a Docker daemon.

This is not a configuration hack or a test convenience — it’s the minimum bar for integration tests on a Spring Boot service with a relational database.

Dependencies

Add Testcontainers to your Maven build. The BOM manages versions across the Testcontainers modules:

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

<dependencies>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

spring-boot-testcontainers (added in Spring Boot 3.1) provides @ServiceConnection, which removes the need to wire container properties into Spring’s datasource configuration manually.

The simplest possible setup

With @ServiceConnection, declaring a PostgreSQLContainer bean in your test configuration is all Spring Boot needs to configure the datasource:

@SpringBootTest
@Testcontainers
class OrderRepositoryIT {

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

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void savesAndRetrievesOrder() {
        var order = new Order(null, "BET-001", new BigDecimal("50.00"), OrderStatus.PENDING);
        var saved = orderRepository.save(order);

        var found = orderRepository.findById(saved.id()).orElseThrow();
        assertThat(found.reference()).isEqualTo("BET-001");
        assertThat(found.status()).isEqualTo(OrderStatus.PENDING);
    }
}

@Container on a static field means one container instance is shared across all test methods in the class. @Testcontainers hooks the JUnit 5 extension that manages container lifecycle. @ServiceConnection detects the PostgreSQLContainer type and populates spring.datasource.* from the running container’s JDBC URL, username, and password — no @DynamicPropertySource needed.

Sharing a container across test classes

Starting a fresh container per test class is clean but slow. For a suite with many integration tests, use a shared static container in a base class or a Testcontainers configuration class:

public abstract class PostgresIntegrationTest {

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

    static {
        POSTGRES.start();
    }

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

Test classes extend PostgresIntegrationTest and inherit the running container. withReuse(true) instructs Testcontainers to keep the container alive between JVM runs (requires a ~/.testcontainers.properties file with testcontainers.reuse.enable=true), which dramatically speeds up local development iteration.

Spring Boot 3.1+ users can replace the static initialiser pattern with a @TestConfiguration class:

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

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

Import it with @Import(TestcontainersConfig.class) on any test class that needs it, or use @SpringBootTest on a test application class to share it across the whole suite.

Testing with Flyway migrations

If your application uses Flyway, the same migration scripts run against the Testcontainers PostgreSQL instance — which is the point. You get the same schema your production database has, including any complex DDL (partial indexes, generated columns, check constraints) that H2 would silently mishandle.

No extra configuration is needed. Spring Boot auto-configures Flyway when it detects the Flyway dependency on the classpath, and it runs migrations against the datasource that Testcontainers provides.

To assert migration state in tests:

@Autowired
private Flyway flyway;

@Test
void migrationsAppliedSuccessfully() {
    MigrationInfo[] applied = flyway.info().applied();
    assertThat(applied).hasSizeGreaterThanOrEqualTo(5);
    assertThat(Arrays.stream(applied))
            .allMatch(m -> m.getState() == MigrationState.SUCCESS);
}

Isolating tests with transactions

For tests that write data, use @Transactional on the test class to roll back after each test method. The database returns to a clean state without truncating tables:

@SpringBootTest
@Testcontainers
@Transactional
class TradeRepositoryIT {

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

    @Autowired
    private TradeRepository tradeRepository;

    @Test
    void persistsTrade() {
        tradeRepository.save(new Trade(null, "RUNNER-42", new BigDecimal("1.95"), Side.BACK));
        assertThat(tradeRepository.count()).isEqualTo(1);
    }

    @Test
    void emptyByDefault() {
        assertThat(tradeRepository.count()).isZero(); // previous test rolled back
    }
}

Be aware of the limitation: @Transactional does not cover code paths that run in a separate transaction (e.g., @Async methods or code that calls Propagation.REQUIRES_NEW). For those scenarios, reset state explicitly using @BeforeEach with a repository deleteAll() or a @Sql script.

Using @Sql for fixtures

Load test fixtures from SQL files rather than Java code when the data is complex or shared across tests:

@Test
@Sql("/fixtures/three-orders.sql")
@Sql(value = "/fixtures/cleanup.sql", executionPhase = Sql.ExecutionPhase.AFTER_TEST_METHOD)
void returnsThreeOrders() {
    assertThat(orderRepository.findAll()).hasSize(3);
}

Keep fixture files in src/test/resources/fixtures/. The AFTER_TEST_METHOD phase runs the cleanup script after the test, which is useful when you can’t use @Transactional (e.g., because the code under test commits its own transaction).

Testing native queries and PostgreSQL-specific features

This is where Testcontainers pays off most clearly. You can test queries that use PostgreSQL-specific syntax:

@Test
void findsOrdersUsingJsonbContains() {
    // PostgreSQL JSONB operator — H2 cannot run this
    var orders = orderRepository.findByMetadataContaining("{\"source\": \"streaming\"}");
    assertThat(orders).hasSize(2);
}

@Test
void windowFunctionRanksByVolume() {
    // ROW_NUMBER() OVER (PARTITION BY ...) — H2 support is partial
    var ranked = tradeRepository.findRankedByVolume("MARKET-001");
    assertThat(ranked.get(0).rank()).isEqualTo(1);
}

These tests cannot run against H2. They run against Testcontainers without any additional setup.

CI configuration

Testcontainers needs a Docker daemon. Most CI environments (GitHub Actions, GitLab CI, CircleCI) provide one by default. For GitHub Actions, a standard Java workflow is sufficient:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
      - run: mvn test

No special Docker setup is required — the ubuntu-latest runner has Docker available. Testcontainers detects and uses it automatically.

For faster CI, cache the Maven local repository between runs:

      - uses: actions/cache@v4
        with:
          path: ~/.m2/repository
          key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}

ProTips

Pin your image tag: postgres:16-alpine is more reliable than postgres:latest in CI — the latter can change between runs and introduce unexpected schema or behaviour differences.

Use alpine variants: The alpine PostgreSQL images are roughly 100MB vs 400MB+ for the default Debian images. Container pull time on cold CI runners matters.

Don’t use @DataJpaTest with Testcontainers unless you exclude auto-configuration: @DataJpaTest replaces the datasource with an embedded database by default. To use it with Testcontainers, add @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE).

Check for flaky test isolation: If two test classes modify the same rows and run in parallel, tests can interfere. Use unique identifiers in your test data (e.g., random UUIDs as references) so reads in one test don’t accidentally pick up writes from another.

If you’re moving a Spring Boot service from H2 to Testcontainers and want help identifying which tests will break under real PostgreSQL behaviour, get in touch.

Samuel Jackson

Samuel Jackson

Senior Java Back End Developer & Contractor

Senior Java Back End Developer — Betfair Exchange API specialist, Spring Boot, AWS, and event-driven architecture. 20+ years delivering high-performance systems across betting, finance, energy, retail, and government. Available for Java contracting.