00:00

Testcontainers with Spring Boot

Last updated:

Testcontainers with Spring Boot lets your tests run against real services, such as PostgreSQL, MongoDB or Kafka, started in throwaway Docker containers. Instead of an in-memory stand-in like H2 that only behaves roughly like your production database, every test talks to the same database engine and version you deploy. Bugs in SQL, migrations and locking show up on your laptop instead of in production.

This tutorial explains how Testcontainers works with Spring Boot, then covers @Testcontainers and @Container, @ServiceConnection vs @DynamicPropertySource, sharing one container across test classes, and running your app locally against containers. It builds a complete order service on Spring Boot 4.1 with PostgreSQL and Flyway, tests it with @DataJpaTest, a concurrency test and full HTTP tests, and shows the real errors behind the most common pitfalls.

1. What Is Testcontainers?

Testcontainers is a Java library that starts Docker containers from your test code and removes them when the tests finish. It has ready-made modules for common services, such as PostgreSQLContainer or MongoDBContainer, which know how to start the service, wait until it is ready and tell you how to connect. For anything else there is GenericContainer, which runs any image.

Spring Boot adds the glue. With @ServiceConnection, Boot reads the container's host, port and credentials and configures the matching connection, such as the DataSource, automatically. You never copy a URL or port into a properties file.

2. Why Test Against a Real Database Instead of H2

H2 is fast and needs no Docker, but it is a different database. It understands much of PostgreSQL's SQL, not all of it. The example's Flyway migration uses PostgreSQL types and features such as TIMESTAMPTZ, a CHECK constraint and a partial index. Running the same @DataJpaTest against H2 fails before a single test runs:

Console (the same test with H2 instead of PostgreSQL)
o.f.core.FlywayExecutor : Database: jdbc:h2:mem:412882db-508b-4542-9575-c3000adf456d (H2 2.4)
...
Caused by: org.flywaydb.core.internal.exception.FlywayMigrateException: Failed to execute script V1__create_orders.sql
Caused by: org.h2.jdbc.JdbcSQLNonTransientException: Unknown data type: "TIMESTAMPTZ"; SQL statement:

Tests run: 3, Failures: 0, Errors: 3, Skipped: 0

Even when the SQL happens to run on both, behaviour can differ in locking, case sensitivity, constraint names and date handling. The example's job queue uses FOR UPDATE SKIP LOCKED, a PostgreSQL row-locking feature whose behaviour only a real PostgreSQL can show. Testcontainers removes the guesswork: if the tests pass, the SQL works on the engine you ship.

3. How Testcontainers Works with Spring Boot

How Testcontainers and Spring Boot connect a test to PostgreSQLThe JUnit test class starts first. Testcontainers asks the Docker engine to pull and run the postgres:17 image and waits until it is ready; the container port 5432 is published on a random host port such as 61867. Spring Boot's @ServiceConnection reads the host and mapped port from the container and creates JdbcConnectionDetails with the URL, user and password. Those details configure the DataSource used by Flyway, JPA and your code, which then sends SQL to the container over the mapped port. JUnit test class starts first Testcontainers starts container, waits until ready Docker engine pulls and runs image postgres:17 container port 5432 published on a random host port, e.g. 61867 @ServiceConnection reads the container JdbcConnectionDetails URL, user, password DataSource Flyway, JPA, your code host + mapped port SQL over the mapped port
Figure 1: Testcontainers starts the database; @ServiceConnection turns its address into the connection Spring Boot uses.
  1. Before the Spring context starts, Testcontainers asks Docker to start the container. Docker maps the container's port (5432) to a random free port on your machine, so parallel builds never clash.
  2. Testcontainers waits until the service is ready, for PostgreSQL until it accepts connections.
  3. Spring Boot finds the container marked @ServiceConnection and creates a ConnectionDetails bean (here JdbcConnectionDetails) from it. Auto-configuration uses those details in place of spring.datasource.*.
  4. Your tests run against the real database. Flyway migrates it like any other.
  5. When the tests finish, the container is stopped. A small helper container called Ryuk also removes leftovers if the JVM crashes.

4. Setup

You need Docker running locally (Docker Desktop, Rancher Desktop, Colima or a remote Docker host). If containers are new to you, the Docker with Spring Boot tutorial explains the basics. Add the Spring Boot Testcontainers support and the module for your database:

pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-flyway</artifactId>
    </dependency>
    <dependency>
        <groupId>org.flywaydb</groupId>
        <artifactId>flyway-database-postgresql</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers-postgresql</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
Version note: Testcontainers 2 (used by Spring Boot 4) renamed its modules: org.testcontainers:postgresql became testcontainers-postgresql and junit-jupiter became testcontainers-junit-jupiter. The container classes moved too, to org.testcontainers.postgresql.PostgreSQLContainer, which no longer takes a generic type. The old org.testcontainers.containers.PostgreSQLContainer is deprecated. Spring Boot's own @ServiceConnection has been available since Spring Boot 3.1.

5. Core Concepts

5.1 @Testcontainers and @Container

@Testcontainers on a test class activates a JUnit 5 extension. It starts every field annotated with @Container and stops it afterwards. Whether the field is static decides how long the container lives:

FieldContainer lifetimeUse with Spring
staticStarted once before the first test of the class, stopped after the lastYes, this is the normal choice
Instance (non-static)A new container for every test methodNo. @ServiceConnection rejects it (section 8.2)
OrderRepositoryTest.java (excerpt)
@DataJpaTest
@Testcontainers
class OrderRepositoryTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");
}

5.2 @ServiceConnection vs @DynamicPropertySource

There are two ways to point Spring at the container. @ServiceConnection is the modern one. @DynamicPropertySource sets properties yourself and still works for anything Boot doesn't recognise:

OrderRepositoryTest.java (with @DynamicPropertySource)
@Container
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");

@DynamicPropertySource
static void postgresProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", postgres::getJdbcUrl);
    registry.add("spring.datasource.username", postgres::getUsername);
    registry.add("spring.datasource.password", postgres::getPassword);
}
@ServiceConnection@DynamicPropertySource
Code neededOne annotationOne line per property
How it worksCreates ConnectionDetails beans that auto-configuration usesAdds properties with the highest precedence
Supported servicesThose Spring Boot knows: JDBC databases, MongoDB, Redis, Kafka, RabbitMQ, Elasticsearch and moreAnything that is configured through properties
Works with GenericContainerOnly with name set to a recognised image, e.g. @ServiceConnection(name = "redis")Yes
Use forSupported services (the default choice)Custom properties, such as your own client's base URL

5.3 Sharing a Container with @TestConfiguration

Instead of a field in every test class, declare the container once as a @Bean in a test configuration. Spring then starts the container with the application context and stops it when the context closes. Every test that imports this class and has the same context configuration reuses both the context and the container:

TestcontainersConfiguration.java
package com.javabytechie.shop;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.postgresql.PostgreSQLContainer;

// One PostgreSQL container for every test that imports this class and shares the same context
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {

    @Bean
    @ServiceConnection
    PostgreSQLContainer postgresContainer() {
        return new PostgreSQLContainer("postgres:17");
    }
}

This is also the layout that Spring Initializr generates when you choose Testcontainers.

5.4 Running the App Locally Against Containers

The same configuration can start your application with a database container, so you don't need a local PostgreSQL install. Put a second main class in src/test/java:

TestShopApplication.java
package com.javabytechie.shop;

import org.springframework.boot.SpringApplication;

// Starts the real application against a PostgreSQL container: ./mvnw spring-boot:test-run
public class TestShopApplication {

    public static void main(String[] args) {
        SpringApplication.from(ShopApplication::main)
                .with(TestcontainersConfiguration.class)
                .run(args);
    }
}

Run it with ./mvnw spring-boot:test-run (Gradle: ./gradlew bootTestRun). The log shows the container start, the migration and the random port:

Console
INFO tc.postgres:17                       : Creating container for image: postgres:17
INFO tc.postgres:17                       : Container postgres:17 started in PT0.59911S
INFO o.f.core.internal.command.DbMigrate  : Successfully applied 1 migration to schema "public", now at version v1 (execution time 00:00.004s)
	Database JDBC URL [jdbc:postgresql://localhost:61867/test?loggerLevel=OFF]
INFO o.s.boot.tomcat.TomcatWebServer      : Tomcat started on port 8080 (http) with context path '/'
INFO com.javabytechie.shop.ShopApplication : Started ShopApplication in 2.466 seconds

6. A Complete Example: Testing an Order Service with PostgreSQL

The order service stores orders in PostgreSQL. Flyway creates the schema. Workers claim pending orders in batches with FOR UPDATE SKIP LOCKED, which locks the rows it returns and skips rows another transaction has already locked, so two workers never get the same order. Errors follow one pattern: 404 for an unknown order number and 409 for a duplicate. The Spring Data JPA with Hibernate tutorial and the Flyway migration tutorial cover those parts in depth.

6.1 Schema and Entity

CREATE TABLE orders (
    id             BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    order_number   VARCHAR(20)    NOT NULL UNIQUE,
    customer_email VARCHAR(255)   NOT NULL,
    total          NUMERIC(10, 2) NOT NULL CHECK (total >= 0),
    status         VARCHAR(20)    NOT NULL,
    created_at     TIMESTAMPTZ    NOT NULL
);

-- Workers only look for pending orders, so index just those rows (a partial index)
CREATE INDEX idx_orders_pending ON orders (created_at) WHERE status = 'PENDING';

6.2 Repository, Service and Controller

package com.javabytechie.shop.order;

import java.util.List;
import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

public interface OrderRepository extends JpaRepository<CustomerOrder, Long> {

    Optional<CustomerOrder> findByOrderNumber(String orderNumber);

    long countByStatus(OrderStatus status);

    // PostgreSQL row locking: rows another transaction has locked are skipped, not waited for
    @Query(value = """
            SELECT * FROM orders
            WHERE status = 'PENDING'
            ORDER BY created_at
            LIMIT :limit
            FOR UPDATE SKIP LOCKED
            """, nativeQuery = true)
    List<CustomerOrder> lockNextPending(@Param("limit") int limit);
}

6.3 Repository Test with @DataJpaTest

This test uses the JUnit style from section 5.1. It checks that the migration ran, that the native locking query returns only pending orders, oldest first, and that PostgreSQL's CHECK constraint rejects a negative total. @DataJpaTest rolls back each test's transaction, so tests don't see each other's data:

OrderRepositoryTest.java
package com.javabytechie.shop.order;

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

import java.math.BigDecimal;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.dao.DataIntegrityViolationException;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;

@DataJpaTest
@Testcontainers
class OrderRepositoryTest {

    // static: one container for all tests in this class, started before the Spring context
    @Container
    @ServiceConnection
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");

    @Autowired
    OrderRepository repository;

    @Test
    void flywayMigrationRanAgainstPostgres() {
        assertThat(repository.count()).isZero();
        assertThat(postgres.isRunning()).isTrue();
    }

    @Test
    void lockNextPendingReturnsOldestPendingOrdersFirst() {
        repository.save(new CustomerOrder("ORD-1001", "asha@example.com", new BigDecimal("120.00")));
        CustomerOrder processing = new CustomerOrder("ORD-1002", "ravi@example.com", new BigDecimal("45.50"));
        processing.startProcessing();
        repository.save(processing);
        repository.save(new CustomerOrder("ORD-1003", "meera@example.com", new BigDecimal("89.99")));
        repository.flush();

        assertThat(repository.lockNextPending(5))
                .extracting(CustomerOrder::getOrderNumber)
                .containsExactly("ORD-1001", "ORD-1003");
    }

    @Test
    void checkConstraintRejectsNegativeTotal() {
        assertThat(repository.count()).isZero();

        assertThatThrownBy(() -> repository.saveAndFlush(
                new CustomerOrder("ORD-2001", "asha@example.com", new BigDecimal("-5.00"))))
                .isInstanceOf(DataIntegrityViolationException.class)
                .hasMessageContaining("orders_total_check");
    }
}
Note: no @AutoConfigureTestDatabase(replace = NONE) is needed. Its default, Replace.NON_TEST, only swaps in an embedded database when the DataSource did not come from the test itself, and a @ServiceConnection container counts as coming from the test.

6.4 Concurrency Test: Two Workers, No Duplicates

Five orders are pending. Two workers start at the same moment and each claims up to three. With SKIP LOCKED, they must get different orders, and together all five:

OrderClaimIntegrationTest.java
package com.javabytechie.shop.order;

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

import java.math.BigDecimal;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CyclicBarrier;

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.context.annotation.Import;

import com.javabytechie.shop.TestcontainersConfiguration;

@SpringBootTest
@Import(TestcontainersConfiguration.class)
class OrderClaimIntegrationTest {

    @Autowired
    OrderService orderService;

    @Autowired
    OrderRepository repository;

    @BeforeEach
    void setUp() {
        repository.deleteAll();
        for (int i = 1; i <= 5; i++) {
            orderService.place(new PlaceOrderRequest("ORD-300" + i, "buyer" + i + "@example.com", new BigDecimal("10.00")));
        }
    }

    @Test
    void twoWorkersClaimDifferentOrders() {
        assertThat(repository.countByStatus(OrderStatus.PENDING)).isEqualTo(5);

        // Start both workers at the same moment
        CyclicBarrier start = new CyclicBarrier(2);
        CompletableFuture<List<OrderResponse>> workerA = CompletableFuture.supplyAsync(() -> claimAfter(start));
        CompletableFuture<List<OrderResponse>> workerB = CompletableFuture.supplyAsync(() -> claimAfter(start));

        List<String> a = workerA.join().stream().map(OrderResponse::orderNumber).toList();
        List<String> b = workerB.join().stream().map(OrderResponse::orderNumber).toList();

        Set<String> all = new HashSet<>(a);
        all.addAll(b);
        assertThat(a).doesNotContainAnyElementsOf(b);
        assertThat(all).hasSize(5);
        assertThat(repository.countByStatus(OrderStatus.PENDING)).isZero();
        assertThat(repository.countByStatus(OrderStatus.PROCESSING)).isEqualTo(5);
    }

    private List<OrderResponse> claimAfter(CyclicBarrier start) {
        try {
            start.await();
        } catch (Exception e) {
            throw new IllegalStateException(e);
        }
        return orderService.claimPending(3);
    }
}
Console
INFO c.javabytechie.shop.order.OrderService : Claimed 3 pending orders: [ORD-3001, ORD-3002, ORD-3003]
INFO c.javabytechie.shop.order.OrderService : Claimed 2 pending orders: [ORD-3004, ORD-3005]

@SpringBootTest does not roll back, because the service's own transactions commit, so the test cleans the table in @BeforeEach.

6.5 HTTP Test with RestTestClient

The last test starts the real web server on a random port and calls the API with RestTestClient, the HTTP test client added in Spring Framework 7. It is enabled by @AutoConfigureRestTestClient:

OrderApiIntegrationTest.java
package com.javabytechie.shop.order;

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

import java.math.BigDecimal;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.test.web.servlet.client.RestTestClient;

import com.javabytechie.shop.TestcontainersConfiguration;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
@Import(TestcontainersConfiguration.class)
class OrderApiIntegrationTest {

    @Autowired
    RestTestClient client;

    @Autowired
    OrderRepository repository;

    @BeforeEach
    void cleanDatabase() {
        repository.deleteAll();
    }

    @Test
    void placeAndFetchOrder() {
        PlaceOrderRequest request = new PlaceOrderRequest("ORD-4001", "asha@example.com", new BigDecimal("249.00"));
        assertThat(repository.findByOrderNumber("ORD-4001")).isEmpty();

        client.post().uri("/api/orders").body(request).exchange()
                .expectStatus().isCreated()
                .expectHeader().location("/api/orders/ORD-4001");

        client.get().uri("/api/orders/ORD-4001").exchange()
                .expectStatus().isOk()
                .expectBody(OrderResponse.class)
                .isEqualTo(new OrderResponse("ORD-4001", "asha@example.com", new BigDecimal("249.00"), OrderStatus.PENDING));
        assertThat(repository.count()).isEqualTo(1);
    }

    @Test
    void duplicateOrderNumberReturns409() {
        PlaceOrderRequest request = new PlaceOrderRequest("ORD-4002", "ravi@example.com", new BigDecimal("15.00"));
        client.post().uri("/api/orders").body(request).exchange().expectStatus().isCreated();

        client.post().uri("/api/orders").body(request).exchange()
                .expectStatus().isEqualTo(HttpStatus.CONFLICT)
                .expectBody().jsonPath("$.detail").isEqualTo("Order ORD-4002 already exists");
        assertThat(repository.count()).isEqualTo(1);
    }

    @Test
    void unknownOrderReturns404() {
        client.get().uri("/api/orders/ORD-9999").exchange()
                .expectStatus().isNotFound()
                .expectBody().jsonPath("$.detail").isEqualTo("Order not found: ORD-9999");
    }
}

6.6 Run the Tests

Run ./mvnw test. Each container starts in under a second once its image has been downloaded:

Console
INFO tc.postgres:17 : Container postgres:17 started in PT0.830193S
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.order.OrderRepositoryTest
INFO tc.postgres:17 : Container postgres:17 started in PT0.638904S
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.order.OrderClaimIntegrationTest
INFO tc.postgres:17 : Container postgres:17 started in PT0.661588S
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0 -- in com.javabytechie.shop.order.OrderApiIntegrationTest
[INFO] Tests run: 7, Failures: 0, Errors: 0, Skipped: 0

The log shows three containers for three test classes. The next section explains why, and when that can be reduced.

7. Sharing Containers to Speed Up Tests

Spring's test framework caches application contexts and reuses one whenever a later test class has exactly the same configuration. A container declared as a bean lives as long as its context, so sharing the context means sharing the container:

How test contexts map to containersOrderRepositoryTest uses a @DataJpaTest slice, context A, with its own static @Container field, postgres number 1. OrderApiIntegrationTest uses RANDOM_PORT and RestTestClient, context B, which starts postgres number 2 from the @Bean in TestcontainersConfiguration. OrderClaimIntegrationTest uses a full @SpringBootTest with a mock web environment, so its configuration differs and it gets context C and postgres number 3. Another test class with exactly the same setup as C reuses the cached context C and postgres number 3, so no new container starts. Test class Spring context (cached) Container OrderRepositoryTest @DataJpaTest Context A: JPA slice postgres #1 static @Container field OrderApiIntegrationTest RANDOM_PORT + RestTestClient Context B: full app, real server postgres #2 @Bean in TestcontainersConfiguration OrderClaimIntegrationTest @SpringBootTest (mock web) Context C: full app, mock web postgres #3 @Bean in TestcontainersConfiguration Another test class with exactly C's setup reuses cached context C and postgres #3
Figure 2: one container per distinct context; identical test setups share both the context and the container.
  • OrderRepositoryTest has its own static @Container field, so it always gets its own container.
  • OrderClaimIntegrationTest (mock web environment) and OrderApiIntegrationTest (RANDOM_PORT plus @AutoConfigureRestTestClient) both import TestcontainersConfiguration, but their configurations differ. That gives two contexts, and so two containers.

To share more, give integration tests the same annotations, for example through a common base class or a custom annotation, and avoid per-class differences such as extra @MockitoBean fields or property overrides. Also make sure the image is already downloaded on CI agents, and pin the image tag so the cache stays valid.

8. Common Pitfalls and How to Fix Them

8.1 "Could not find a valid Docker environment"

Docker is not running, or Testcontainers cannot reach it. The tests fail before Spring starts:

Console
ERROR o.t.dockerclient.DockerClientProviderStrategy -- Could not find a valid Docker environment. Please check configuration. Attempted configurations were:
	UnixSocketClientProviderStrategy: failed with exception InternalServerErrorException (Status 500: )
	DockerDesktopClientProviderStrategy: failed with exception InternalServerErrorException (Status 500: )As no valid configuration was found, execution cannot continue.
...
Caused by: java.lang.IllegalStateException: Could not find a valid Docker environment. Please see logs and check configuration

How to fix it: start Docker and check that docker ps works. For a remote or non-default Docker setup, set DOCKER_HOST. Testcontainers does not use the Docker CLI's current context, so a context that points elsewhere does not matter.

8.2 "@ServiceConnection field must be static"

Removing static from the container field fails immediately:

Console
java.lang.IllegalStateException: @ServiceConnection field 'postgres' must be static

How to fix it: make the field static. Spring needs the connection details before it builds the context, and the context is shared by all test methods.

8.3 Hard-Coded Hosts and Ports

The container's port 5432 is published on a random host port, 61867 in the run in section 5.4, so a URL such as jdbc:postgresql://localhost:5432/test in a test properties file points at the wrong place, or at a local PostgreSQL you didn't mean to use. How to fix it: let @ServiceConnection supply the connection, or read getJdbcUrl() and getMappedPort(5432) from the container.

8.4 Data Leaking Between Tests

A shared container keeps its data for the whole run. @DataJpaTest rolls back for you, but @SpringBootTest does not, and an HTTP test cannot roll back the server's transactions at all. How to fix it: clean up in @BeforeEach, as the integration tests do, or give each test its own unique data, such as distinct order numbers.

8.5 Floating Image Tags

new PostgreSQLContainer("postgres:latest") silently moves to a new major version one day, and tests start failing (or passing) for reasons unrelated to your change. How to fix it: pin the same major version you run in production, such as postgres:17.

9. When to Use Testcontainers

Good fitPoor fit
Repository tests with native queries, migrations or database-specific featuresPure business logic, which a plain unit test with mocks checks in milliseconds
Integration tests across the web layer, service and databaseController-only tests, where @WebMvcTest with a mocked service is enough
Testing against MongoDB, Redis, Kafka or RabbitMQBuild environments where Docker is not available and cannot be added
Concurrency and locking behaviourVery large test suites that would start dozens of distinct contexts (share them first)

A healthy suite combines both: many fast unit and slice tests, plus a focused set of Testcontainers tests for the parts that touch real infrastructure. The Spring Boot testing tutorial covers the other test types.

10. Best Practices

  • Use the same database engine and major version as production, and pin the image tag.
  • Prefer @ServiceConnection over @DynamicPropertySource for services Spring Boot supports.
  • Declare shared containers once in a @TestConfiguration and import it, instead of copying container fields into every class.
  • Keep test configurations identical where you can, so Spring reuses contexts and containers.
  • Make container fields static when you use @Container.
  • Let Flyway or Liquibase build the schema in tests, so migrations are tested on every run.
  • Clean up data in @SpringBootTest tests, which do not roll back.
  • Assert state before and after the action, not just after.
  • Reuse the configuration for local development with spring-boot:test-run.
  • Keep fast tests fast: use Testcontainers where real infrastructure matters, not for every test.

11. Summary

Testcontainers with Spring Boot replaces in-memory stand-ins with the real thing: a PostgreSQL, MongoDB or Kafka container that starts with your tests and disappears afterwards. Add spring-boot-testcontainers and the module for your service. Declare the container as a static @Container field or, better, as a @Bean in a shared @TestConfiguration, and mark it @ServiceConnection so Spring Boot connects to it automatically. Use it in @DataJpaTest slices and full @SpringBootTest tests, and even to run the app locally with spring-boot:test-run. The main recommendation: test database code against the same engine and version you run in production, and share containers through identical test contexts to keep the suite fast.