Skip to main content
Back to Blog
Guide
2026-05-05

Testcontainers Postgres Java Spring Boot — Complete Guide 2026

Master Testcontainers for PostgreSQL in Java and Spring Boot. Real JDBC integration tests with Flyway, Liquibase, JPA, and CI/CD patterns.

Testcontainers Postgres Java Spring Boot Complete Guide

PostgreSQL is the most-deployed open-source database in modern Java applications, powering everything from Spring Boot microservices to massive multi-tenant SaaS platforms. Testing JPA, Spring Data, and JDBC code that depends on PostgreSQL-specific features (JSONB, full-text search, generated columns, partitioning) has historically been a tradeoff between H2 (incomplete SQL dialect), shared Postgres instances (flaky, slow, hard to parallelize), or docker-compose (separate startup step). Testcontainers solves all of this by spinning up a real PostgreSQL instance per test class, programmatically managed by JUnit 5, with one annotation.

This guide is a hands-on walkthrough of Testcontainers with PostgreSQL for Java and Spring Boot in 2026. We cover the PostgreSQLContainer module, Spring Boot 3 integration via @DynamicPropertySource, JDBC URL property substitution, Flyway and Liquibase migration patterns, JPA repository tests, transactional rollback for fast tests, container reuse for local dev, and CI/CD configuration. Every code sample is working Java with JUnit 5, Spring Boot 3, and Testcontainers 1.20+.


Key Takeaways

  • PostgreSQLContainer provides one-line setup for real PostgreSQL in tests
  • @DynamicPropertySource is the Spring Boot integration mechanism for injecting JDBC URL, username, password
  • JDBC URL substitution is an alternative pattern using jdbc:tc:postgresql:16:///
  • Flyway and Liquibase both work seamlessly — migrations run as part of Spring context startup
  • Transactional rollback via @Transactional gives instant test isolation
  • Container reuse drops local startup from 5 seconds to under 1 second
  • CI/CD setup is trivial because Docker is available on GitHub Actions ubuntu runners

Why Testcontainers for PostgreSQL in Java

The alternatives are all flawed. H2 in PostgreSQL compatibility mode handles 60-70% of SQL but breaks on JSONB, generated columns, window functions, stored procedures, full-text search, and dozens of other features. HSQLDB has the same problems. Embedded Postgres (otj-pg-embedded, zonky/embedded-postgres) is closer to real Postgres but lags behind versions and behaves differently on Mac M-series chips.

Testcontainers gives you real Postgres, in any version you care about, with one annotation and no platform-specific gotchas.


Installation

Maven:

<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>junit-jupiter</artifactId>
  <version>1.20.4</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>postgresql</artifactId>
  <version>1.20.4</version>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
  <groupId>org.postgresql</groupId>
  <artifactId>postgresql</artifactId>
  <scope>runtime</scope>
</dependency>

Spring Boot 3.1+ also offers spring-boot-testcontainers which simplifies setup:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-testcontainers</artifactId>
  <scope>test</scope>
</dependency>

Your First Test

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.ResultSet;
import java.sql.Statement;

import static org.junit.jupiter.api.Assertions.assertEquals;

@Testcontainers
class PostgresIntegrationTest {

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

    @Test
    void runsQuery() throws Exception {
        try (Connection conn = DriverManager.getConnection(
                POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
             Statement st = conn.createStatement()) {
            ResultSet rs = st.executeQuery("SELECT 1 + 1");
            rs.next();
            assertEquals(2, rs.getInt(1));
        }
    }
}

Spring Boot Integration with @DynamicPropertySource

This is the standard pattern for Spring Boot 3:

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;

@SpringBootTest
@Testcontainers
class UserRepositoryTest {

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

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

    @Autowired
    private UserRepository userRepository;

    @Test
    void savesAndLoadsUser() {
        User saved = userRepository.save(new User("alice@example.com"));
        User loaded = userRepository.findById(saved.getId()).orElseThrow();
        assertEquals("alice@example.com", loaded.getEmail());
    }
}

The @DynamicPropertySource method runs before Spring boot starts, so the container is ready when JPA initializes.


JDBC URL Substitution Pattern

Testcontainers also supports a special JDBC URL syntax that doesn't require any Java code — just configure your application properties:

# application-test.properties
spring.datasource.url=jdbc:tc:postgresql:16-alpine:///test
spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver

When the application starts a connection on jdbc:tc: URL, Testcontainers transparently starts a container and substitutes the real URL. Useful for quick prototypes but less explicit than @DynamicPropertySource.


PostgreSQLContainer API Reference

MethodPurposeDefault
ConstructorImage like postgres:16-alpinenone
.withDatabaseName(name)Override database nametest
.withUsername(name)Override usernametest
.withPassword(pwd)Override passwordtest
.withInitScript(path)Run SQL script on startupnone
.withReuse(true)Reuse container across runsdisabled
.withCopyFileToContainer(...)Copy files innone

After start:

MethodReturns
getJdbcUrl()JDBC URL
getUsername()Username
getPassword()Password
getDatabaseName()Database name
getHost()Hostname
getFirstMappedPort()Postgres port

Flyway Migration Integration

Flyway migrations run automatically when Spring Boot starts. Just put your migrations in src/main/resources/db/migration/:

-- V1__create_users.sql
CREATE TABLE users (
  id BIGSERIAL PRIMARY KEY,
  email TEXT UNIQUE NOT NULL,
  created_at TIMESTAMPTZ DEFAULT now()
);
spring.flyway.enabled=true
spring.jpa.hibernate.ddl-auto=validate

Your tests will start with the schema migrated to current head. To run test-only migrations, place them in src/test/resources/db/migration/ and include both folders in spring.flyway.locations.


Liquibase Integration

For Liquibase users:

spring.liquibase.enabled=true
spring.liquibase.change-log=classpath:db/changelog/db.changelog-master.xml

Liquibase runs as part of the Spring startup and migrations apply to the Testcontainers Postgres instance.


JPA Repository Tests with @DataJpaTest

For repository-layer tests, @DataJpaTest is faster than full @SpringBootTest:

import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
import static org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace.NONE;

@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)
@Testcontainers
class UserRepositoryDataJpaTest {

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

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

    @Autowired
    private UserRepository userRepository;

    @Test
    void findsByEmail() {
        userRepository.save(new User("a@b.com"));
        assertTrue(userRepository.findByEmail("a@b.com").isPresent());
    }
}

@AutoConfigureTestDatabase(replace = NONE) disables Spring's automatic H2 substitution.


Testing JSONB

JSONB is where H2 utterly fails. With Testcontainers, JSONB tests work:

@Test
void queriesJsonb() {
    User user = new User();
    user.setEmail("alice@example.com");
    user.setMetadata(Map.of("plan", "pro", "seats", 10));
    userRepository.save(user);

    List<User> proUsers = userRepository.findByMetadataPlan("pro");
    assertEquals(1, proUsers.size());
}
@Query("SELECT u FROM User u WHERE function('jsonb_extract_path_text', u.metadata, 'plan') = :plan")
List<User> findByMetadataPlan(@Param("plan") String plan);

Per-Test Isolation with @Transactional

Spring's @Transactional rolls back after each test by default in @SpringBootTest:

@SpringBootTest
@Testcontainers
@Transactional
class UserServiceTransactionalTest {

    @Container
    static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
    // dynamic property source omitted for brevity

    @Test
    void test1() {
        userRepository.save(new User("a@b.com"));
        // rolled back automatically after test
    }

    @Test
    void test2() {
        assertEquals(0, userRepository.count()); // clean state
    }
}

Caveat: @Transactional rollback doesn't work for code that calls its own TransactionTemplate.execute because that creates a nested transaction.


Container Reuse

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

Enable in ~/.testcontainers.properties:

testcontainers.reuse.enable=true

First run: 5 seconds. Subsequent runs: under 1 second.


Spring Boot 3.1+ Service Connection

Spring Boot 3.1 added @ServiceConnection which generates @DynamicPropertySource automatically:

import org.springframework.boot.testcontainers.service.connection.ServiceConnection;

@SpringBootTest
@Testcontainers
class UserRepoTest {

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

    @Autowired UserRepository userRepository;

    @Test
    void works() {
        // Spring auto-configures spring.datasource.url etc.
    }
}

This is the cleanest pattern in Spring Boot 3.1+ projects.


CI/CD Configuration

name: test
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: 21
      - uses: actions/cache@v4
        with:
          path: ~/.m2/repository
          key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
      - run: ./mvnw test

Common Pitfalls

ddl-auto: create. Don't use create or create-drop with real Postgres in tests. Use validate plus Flyway/Liquibase, or update if you must.

Slow startup with many @SpringBootTest classes. Each class restarts the Spring context. Use @ContextConfiguration to share contexts across test classes when possible.

Transactional + multiple connections. If your code uses non-default DataSources, @Transactional rollback won't cover them.

Image version drift. Always pin to a specific tag like postgres:16-alpine, never postgres:latest.


Conclusion

Testcontainers transforms Postgres integration testing in Java and Spring Boot. Real Postgres, real JPA mappings, real JSONB, real Flyway migrations — all isolated per test class with one annotation. Spring Boot 3.1+ @ServiceConnection makes setup even simpler. Container reuse keeps local iteration fast, and CI requires zero configuration.

Browse the QA skills directory for related Java testing patterns, or read our Kafka guide for event-driven systems.

Testcontainers Postgres Java Spring Boot — Complete Guide 2026 | QASkills.sh