Spring Boot PostgreSQL Tests with Testcontainers

A Spring Boot test can pass against an in-memory database and still fail against PostgreSQL. Differences in SQL syntax, constraints, types, and database configuration can hide until the application reaches its real database. Spring Boot PostgreSQL Testcontainers tests address this by starting PostgreSQL in a Docker container and connecting the test application to it.

The quickest useful path is to add the Testcontainers PostgreSQL and JUnit modules, the PostgreSQL JDBC driver, and Spring Boot’s Testcontainers support; define a database migration; then start a PostgreSQL container for the test and let Spring Boot use its connection details. The example below tests a unique constraint against PostgreSQL and removes its data before every test.

Scope note: Examples use Java 17, Spring Boot 3.5.0, Testcontainers 1.21.3, and a PostgreSQL 16 image. This is an explicit teaching baseline, a teaching baseline rather than a latest-release recommendation. Check compatibility with your project’s dependency management, Docker environment, and selected image before adopting the setup.

The bug an in-memory test missed

Suppose an order table stores an external reference that must be unique. A test using a mock repository can check whether application code calls save, but it cannot prove PostgreSQL enforces the real database constraint. An in-memory database may also differ in its SQL dialect, column types, constraint behavior, or support for the query being tested.

A PostgreSQL-backed integration test includes the database engine in the path under test. It can check that a migration runs, a row can be written and read, and a duplicate reference is rejected by the database. That makes the test more representative for database behavior, though it still does not cover every production detail, such as the exact production server configuration or network setup.

Testcontainers manages a container for the test. The PostgreSQL module provides PostgreSQLContainer, but it does not automatically provide the PostgreSQL JDBC driver. Include that driver separately. The module and driver have distinct jobs: the container provides a database server; the driver lets Java connect to it. The Testcontainers PostgreSQL module guide documents the module’s PostgreSQL container support.

If you are new to Spring tests, first see Spring Boot testing. This guide focuses on the database-specific setup, lifecycle, migration, and cleanup.

Reproducible project setup

Use a compatible and explicit set of versions rather than copying dependency names from examples for another Testcontainers major release.

ComponentExample baselinePurpose
Java17Application and test runtime
Spring Boot3.5.0Application framework and dependency management
Testcontainers1.21.3JUnit integration and PostgreSQL container module
PostgreSQL imagepostgres:16Database engine for this example

The PostgreSQL 16 major tag is convenient for a walkthrough, but it is not an immutable image reference: the image behind a tag may change. For repeatable CI, select and verify a PostgreSQL patch tag or image digest that your team intends to use. Do not infer a patch version from this guide.

Testcontainers 1.x and 2.x use different dependency names in their documentation. This example is for 1.21.3: its artifacts are org.testcontainers:postgresql and org.testcontainers:junit-jupiter, and its PostgreSQL container import is org.testcontainers.containers.PostgreSQLContainer. Testcontainers 2.x documentation uses names such as testcontainers-postgresql. Do not mix 1.x dependencies or imports with 2.x examples. Verify artifacts and imports against the major version you choose.

Add the following dependencies to a Maven project. The example uses Spring Boot’s managed versions for Spring dependencies, but declares the Testcontainers 1.21.3 BOM so both Testcontainers modules use the same release. Flyway’s PostgreSQL support is included explicitly for this Spring Boot 3.5 baseline.

<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.0</version><relativePath/></parent>
<groupId>com.example</groupId>
<artifactId>postgres-test-demo</artifactId>
<version>1.0.0</version>
<properties>
    <java.version>17</java.version>
    <testcontainers.version>1.21.3</testcontainers.version>
</properties>

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

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>

    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.flywaydb</groupId>
        <artifactId>flyway-core</artifactId>
    </dependency>

    <dependency>
        <groupId>org.flywaydb</groupId>
        <artifactId>flyway-database-postgresql</artifactId>
    </dependency>

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

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

    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>

    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
</project>

This is a complete pom.xml: it includes the Spring Boot 3.5.0 parent. Keep Spring Boot’s dependency management and the explicit Testcontainers BOM together; avoid adding a second, conflicting version declaration for either Testcontainers module.

You also need a compatible Docker environment that the test process can reach. Testcontainers does not replace Docker, nor does it make container startup possible when the Docker daemon is unavailable. See the official guidance on supported Docker environments.

Starting PostgreSQL and wiring Spring

Spring Boot’s @ServiceConnection lets Boot derive connection details from a supported Testcontainers container. Add the spring-boot-testcontainers dependency, declare a PostgreSQL container, and annotate it with @ServiceConnection. Spring Boot’s connection details can take precedence over connection-related properties when it configures the application. Read the Spring Boot Testcontainers reference for the framework behavior.

PostgreSQL container starts, Spring uses its connection and migrations, tests run, then the container stops.
The database container, Spring application and test data each have their own lifecycle. A shared container does not clear rows between tests.

The container exposes a mapped port. That port can vary, so do not hard-code localhost:5432 for the test connection. The container integration supplies the address and port that apply at runtime.

Here is the application entry point. The filename and package path should match the rest of the example: src/main/java/com/example/orders/OrdersApplication.java.

package com.example.orders;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class OrdersApplication {

    public static void main(String[] args) {
        SpringApplication.run(OrdersApplication.class, args);
    }
}

The test class later uses the JUnit 5 Testcontainers extension. Its @Testcontainers annotation works with the junit-jupiter module. With a static container field, the JUnit extension starts the container once for the test class and stops it when the class is finished. Instance fields instead have per-method lifecycle. The extension does not support parallel test execution; keep its tests sequential unless you use a separately designed and verified lifecycle strategy. See the JUnit 5 integration guide.

A different connection option is Spring’s @DynamicPropertySource. Use this when you need to register container-derived values as properties yourself, or when a service-connection setup does not fit your test. It is an alternative wiring method, not an annotation to layer onto the same test class just because both exist.

The following is a separate test setup excerpt, not a second annotation to add to the complete test shown later. It illustrates the alternative property-registration approach:

static PostgreSQLContainer<?> postgres =
        new PostgreSQLContainer<>("postgres:16");

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

In a real test using that approach, the container must also be started and kept alive for the Spring application context. Do not combine this excerpt with @ServiceConnection in the complete example below. For more on dynamic property registration, see Dynamic property setup in Spring.

Repository behavior and database migrations

The example uses JdbcTemplate rather than JPA to keep the database interaction visible. A migration defines the schema used by the application and the test. This is different from relying on ORM-generated test tables: migration-managed schema changes are explicit SQL files that can be reviewed and applied by the same migration tool in different environments.

Create src/main/resources/db/migration/V1__create_orders.sql:

CREATE TABLE orders (
    id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    external_reference VARCHAR(100) NOT NULL UNIQUE,
    description VARCHAR(250) NOT NULL
);

The UNIQUE constraint is the behavior we want PostgreSQL itself to enforce. Flyway discovers versioned migration files under its default migration location and applies them at application startup when configured by Spring Boot. In this setup, Flyway’s PostgreSQL support dependency is included alongside flyway-core.

Add a small repository at src/main/java/com/example/orders/OrderRepository.java:

package com.example.orders;

import java.util.Optional;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class OrderRepository {

    private final JdbcTemplate jdbcTemplate;

    public OrderRepository(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public long insert(String externalReference, String description) {
        return jdbcTemplate.queryForObject(
                """
                INSERT INTO orders (external_reference, description)
                VALUES (?, ?)
                RETURNING id
                """,
                Long.class,
                externalReference,
                description
        );
    }

    public Optional<OrderRecord> findByExternalReference(String externalReference) {
        return jdbcTemplate.query(
                """
                SELECT id, external_reference, description
                FROM orders
                WHERE external_reference = ?
                """,
                (resultSet, rowNumber) -> new OrderRecord(
                        resultSet.getLong("id"),
                        resultSet.getString("external_reference"),
                        resultSet.getString("description")
                ),
                externalReference
        ).stream().findFirst();
    }

    public int deleteAll() {
        return jdbcTemplate.update("DELETE FROM orders");
    }

    public record OrderRecord(long id, String externalReference, String description) {
    }
}

The repository returns the generated ID from PostgreSQL’s RETURNING clause. The lookup returns an Optional, which represents a result that may not exist. The deleteAll method is included to make each test’s cleanup explicit; it is not an invitation to expose unrestricted cleanup operations through a production API.

Now create src/test/java/com/example/orders/OrderRepositoryPostgresTest.java. This is the complete test class for the example:

package com.example.orders;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.dao.DuplicateKeyException;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

@SpringBootTest
@Testcontainers
class OrderRepositoryPostgresTest {

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

    @Autowired
    private OrderRepository orderRepository;

    @BeforeEach
    void removeExistingRows() {
        orderRepository.deleteAll();
    }

    @Test
    void insertsAndFindsAnOrder() {
        long id = orderRepository.insert("INV-1001", "Stationery");

        assertThat(id).isPositive();
        assertThat(orderRepository.findByExternalReference("INV-1001"))
                .contains(new OrderRepository.OrderRecord(
                        id, "INV-1001", "Stationery"
                ));
    }

    @Test
    void rejectsDuplicateExternalReferences() {
        orderRepository.insert("INV-2001", "Notebook");

        assertThatThrownBy(() ->
                orderRepository.insert("INV-2001", "Another notebook")
        ).isInstanceOf(DuplicateKeyException.class);
    }

    @Test
    void startsWithNoRowsAfterPerTestCleanup() {
        assertThat(orderRepository.findByExternalReference("INV-1001"))
                .isEmpty();
        assertThat(orderRepository.findByExternalReference("INV-2001"))
                .isEmpty();

        orderRepository.insert("INV-3001", "Pen");

        assertThat(orderRepository.findByExternalReference("INV-3001"))
                .isPresent();
    }
}

The first test checks an insert and a lookup. The second checks that the unique constraint rejects a duplicate. Spring’s JDBC exception translation commonly presents a database constraint violation as a DuplicateKeyException; confirm the exception type used by your own Spring configuration if your application customizes translation. The third checks that known references from earlier tests are absent, then writes its own row.

These assertions are examples of expected behavior, not observed test output. A successful run should report all three tests passing. If the tests cannot start the application, check the migration, driver, container startup, and Spring connection wiring before changing the assertions.

Test data isolation

A static container shares one database server across the test class. It does not automatically clear tables between methods. If one test inserts a row and another reuses the same unique reference, results may depend on execution order. The @BeforeEach cleanup in the example removes table rows before each test so each method begins with a known state.

Test isolation cycle: remove old rows before each test, write and assert on local test data, then clean before the next test.
Class-level container sharing saves repeated startup. Explicit per-test cleanup prevents one test’s rows from changing another test’s result.

Isolation means a test’s result should not depend on data left by another test. For this small example, explicit cleanup is easy to see and reason about. As a schema grows, use carefully scoped cleanup for the tables each test owns. If foreign keys are introduced, delete rows in dependency order or design a safe database-reset helper; blindly truncating every table can break the very relationships you intend to test.

Another option is a transaction around each test. A transaction can be useful for tests that call the repository directly and perform all database work on the same transaction-bound connection. But it is not a universal cleanup mechanism. If a test sends an HTTP request to a separately running server thread, that request may use a different transaction. The test thread’s rollback does not necessarily roll back work committed by the server thread. Verify transaction boundaries instead of assuming that an annotation resets all activity.

A fresh database for each test or test class can provide stronger separation, but it has a lifecycle and startup cost that must be considered. The static class-level container here reduces repeated container startup within that class, while the per-method delete controls row state. Neither choice makes tests safe for parallel execution under the JUnit Testcontainers extension.

Repository tests and request-based application tests may need different isolation approaches. For a request test, consider a unique reference per test, explicit cleanup through a controlled test hook, or a disposable database lifecycle designed for the test. Keep cleanup away from production endpoints, and never point a test at a production database.

For tables that contain date or time values, also verify the application’s intended type and timezone behavior against PostgreSQL rather than assuming an in-memory database matches it. The related guide on PostgreSQL date and time storage can help with that separate concern.

Local failures and CI

Container-backed tests depend on the local or CI Docker environment, image availability, database readiness, and correct Spring connection details. A failure message is a clue, not a complete diagnosis. The examples below are illustrative message patterns; exact wording depends on the Docker client, Testcontainers version, host, and failure.

Alternative Spring test wiring with ServiceConnection or DynamicPropertySource, using the real container host and mapped port.
Service connections derive supported connection details. Dynamic properties register values explicitly. The article shows them as alternatives.
Illustrative symptomLikely areaChecks to make
“Could not find a valid Docker environment”Docker access or detectionCheck that Docker is installed, the daemon is running, and the current process can use the configured Docker endpoint. Review the supported-environment guidance instead of granting broad system permissions.
Image pull fails or times outRegistry access or networkCheck the image name, registry availability, and CI network policy. Confirm the selected PostgreSQL image can be pulled from that runner.
Connection refused during startupReadiness or wrong addressWait for Testcontainers’ readiness check to complete. Avoid connecting to a guessed host port; use the container-provided connection details.
Application connects to a local or stale databaseProperty precedence or hard-coded configurationCheck for test properties, environment variables, and custom datasource configuration that override the intended connection. Keep production credentials out of the test setup.
“relation does not exist”Migration not applied or wrong schemaCheck the migration filename and location, Flyway dependencies, migration startup logs, and the schema queried by the application.
Duplicate-key failure appears in an unrelated testLeaked data or test orderingCheck per-test cleanup and ensure the test is not relying on a row inserted by another method.

With @ServiceConnection, Spring Boot can use the connection details derived from the PostgreSQL container. If your application also defines datasource properties manually, investigate whether custom configuration has taken precedence or bypassed the expected connection details. In particular, inspect active profiles and environment variables in CI. Do not “fix” a connection problem by adding production database credentials to the test job.

When Docker access fails, follow your organization’s approved runner setup. A CI service may need a configured Docker daemon or a supported remote Docker environment; what is available depends on the platform. Do not grant blanket administrator permissions as a general response to authentication or socket errors. Check the runner’s Docker access model and the Testcontainers Docker environment notes.

If an image pull or connection times out, investigate network and readiness separately. A successful image pull does not prove the database is ready to accept connections. A readiness failure does not prove the port should be hard-coded. Testcontainers’ container networking guide explains how container addresses and mapped ports work.

Run the example with Maven using:

mvn test

On a successful run, Maven should report that the test phase completed and that the three test methods passed. This is illustrative expected behavior, not a reported execution result. If Maven fails while resolving dependencies, inspect the dependency tree and verify that the Testcontainers artifacts resolve to the same 1.21.3 release and the project’s Spring Boot dependency management is in place.

Speed and realistic scope

A PostgreSQL container test does more setup than a unit test: it needs a Docker environment, an image, a database process, and application wiring. The guide makes no timing claim. Startup time varies with the machine, image availability, Docker configuration, and CI runner.

Use a unit test for logic that does not depend on SQL behavior, and use PostgreSQL-backed tests for important database interactions such as migrations, constraints, or PostgreSQL-specific queries. This keeps the database tests focused without asking mocks or another database engine to prove PostgreSQL behavior.

A static container is useful for sharing one container across test methods in a class, but it does not share a clean database state automatically. The JUnit extension’s container lifecycle and the test’s data-cleanup strategy are separate decisions. Start with sequential tests and explicit cleanup; consider more complex reuse or parallel execution only when its lifecycle and isolation behavior are designed and verified.

Verification checklist

Before relying on this setup in a project, work through these checks:

  1. Confirm the build targets Java 17 and Spring Boot 3.5.0, and that the Testcontainers BOM pins both 1.x modules to 1.21.3.
  2. Check that the project includes the PostgreSQL JDBC driver, spring-boot-testcontainers, Flyway’s PostgreSQL support, and both Testcontainers test modules.
  3. Verify that the imports match Testcontainers 1.x and that the test image is the PostgreSQL major version you intend to use. Choose a verified patch tag or digest for repeatable CI.
  4. Run mvn test with an accessible Docker environment and check that the container connection uses its runtime host and mapped port.
  5. Confirm the migration creates the unique constraint, and that the test actually observes duplicate-reference rejection.
  6. Run the test class repeatedly and run it with the rest of the test suite. Check that cleanup prevents leaked rows from affecting results.
  7. Inspect active test configuration to ensure the test is not connecting to a local or production database, and that no production credentials are used.

When these checks pass in your project, you have a focused PostgreSQL integration test: migrations create the schema, Spring connects to the container, and the database—not a mock—enforces the uniqueness rule.

Post a Comment

0 Comments