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.
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.
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.
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.
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);
}
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.
@Sql for fixturesLoad 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).
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.
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') }}
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.