Integration Testing with TestContainers in Spring Boot: A Practical Handbook
Writing
JAVA ENGINEERING
December 26, 20254 min read

Integration Testing with TestContainers in Spring Boot: A Practical Handbook

Stop writing brittle tests with H2. Learn how to use Testcontainers to run your integration tests against real Dockerized databases in Spring Boot 3.1+.

javaspring-boottestingdockertestcontainersintegration-testing

If you are writing integration tests in 2025 and you are still using H2 (an in-memory database), you are doing it wrong.

I know, that sounds harsh. But I’ve seen too many production bugs happen because H2 behaves slightly differently than PostgreSQL. H2 is case-insensitive by default in some modes; Postgres is not. H2 doesn't support JSONB properly; Postgres does.

Testing against a "fake" database gives you a false sense of security.

Mock vs Real Database

The solution? Testcontainers.

What is Testcontainers?

Testcontainers is a Java library that allows your JUnit tests to spin up real Docker containers for your dependencies (Postgres, Redis, Kafka, Elasticsearch, etc.) on the fly.

Instead of mocking your database, you literally boot up a real PostgreSQL instance inside Docker, run your tests against it, and then throw it away.

Testcontainers Architecture

How do you set up Testcontainers with Spring Boot 3.1+?

Before Spring Boot 3.1, setting up Testcontainers was a bit verbose. You had to manually define @DynamicPropertySource to inject the database URL into your Spring context.

Spring Boot 3.1 changed the game with a new feature called Service Connections. It automatically configures the connection details for you.

Let's look at the code.

1. Add Dependencies

First, you need the standard test dependencies plus Testcontainers.

<dependencies>
    <!-- Standard Spring Boot Test -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
 
    <!-- Testcontainers -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</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>

2. Writing the Test

Here is the magic. Look how clean this is compared to the old way.

@SpringBootTest
@Testcontainers // 1. Enable Testcontainers support
class CustomerRepositoryTest {
 
    // 2. Define the Container
    @Container
    @ServiceConnection // 3. The Magic Annotation!
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
 
    @Autowired
    private CustomerRepository customerRepository;
 
    @Test
    void shouldSaveAndRetrieveCustomer() {
        Customer customer = new Customer("John Doe", "john@example.com");
        customerRepository.save(customer);
 
        Optional<Customer> found = customerRepository.findByEmail("john@example.com");
 
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("John Doe");
    }
}

Wait, where is spring.datasource.url? You don't need it. The @ServiceConnection annotation tells Spring Boot: "Hey, this is a Postgres container. Please look at its mapped port and automatically configure the datasource to point to it."

It just works.

How do you share a single container across all tests?

The code above works perfectly, but there is a catch: It starts a new Postgres container for every test class.

If you have 50 test classes, that's 50 Docker startups. Your CI pipeline will take forever.

To fix this, we use the Singleton Pattern. We start the container once and share it across all tests.

Base Test Class:

public abstract class BaseIntegrationTest {
 
    static final PostgreSQLContainer<?> postgres;
 
    static {
        // Start the container manually in a static block
        postgres = new PostgreSQLContainer<>("postgres:16-alpine");
        postgres.start();
    }
 
    // Still use ServiceConnection for auto-configuration!
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
       // Manual configuration if not using @ServiceConnection on a static field in the same class
       // OR simpler: just use @ServiceConnection on the abstract class if using Boot 3.1+
    }
    
    // The Modern Singleton Way (Spring Boot 3.1+)
    @TestConfiguration(proxyBeanMethods = false)
    static class TestContainersConfig {
        
        @Bean
        @ServiceConnection
        public PostgreSQLContainer<?> postgreSQLContainer() {
            return new PostgreSQLContainer<>("postgres:16-alpine");
        }
    }
}

Author's Note: Actually, there is an even simpler way in Spring Boot 3.1 called TestConfiguration files, but to keep it simple, just know that sharing containers is key to performance.

How do you handle dirty Spring contexts with Testcontainers?

Since you are reusing the same database, data from Test A might leak into Test B.

You have two options:

  1. @Transactional: Annotate your test methods with @Transactional. Spring will roll back the transaction at the end of the test, leaving the DB clean. (Recommended for most cases).
  2. Manual Cleanup: customerRepository.deleteAll() in an @AfterEach block.

Summary

Testcontainers has moved from "nice to have" to "essential".

  • Reliability: You test against the real thing.
  • Portability: It works on any machine with Docker (Mac, Windows, Linux, CI).
  • Simplicity: With Spring Boot 3.1+, the configuration is almost zero.

Stop mocking your database. It deserves better.

For further reading, see the Testcontainers official documentation, the Spring Boot 3.1 Service Connections reference, and the JUnit 5 User Guide.

Keep Reading

Happy testing! 🧪

Frequently Asked Questions

What is Testcontainers and why should I use it instead of H2?

Testcontainers is a Java library that spins up real Docker containers (Postgres, Redis, Kafka, etc.) during your JUnit tests. Unlike H2, which emulates database behavior with subtle differences, Testcontainers runs the exact same database engine you use in production, eliminating false-positive test results.

How does @ServiceConnection work in Spring Boot 3.1+?

The @ServiceConnection annotation automatically detects the container type and configures the corresponding Spring Boot connection properties (like datasource URL, username, password). It replaces the manual @DynamicPropertySource approach, reducing boilerplate significantly.

How do I avoid slow CI builds with Testcontainers?

Use the Singleton Pattern — start a single container in a static initializer block of a base test class and share it across all test classes. This avoids spinning up a new Docker container per test class. Also use @Transactional on tests so data is rolled back automatically without needing container restarts.

Last updated: April 2, 2026

Rabinarayan Patra

Rabinarayan Patra

SDE II at Amazon. Previously at ThoughtClan Technologies building systems that processed 700M+ daily transactions. I write about Java, Spring Boot, microservices, and the things I figure out along the way. More about me →

X (Twitter)LinkedIn

Stay in the loop

Get the latest articles on system design, frontend & backend development, and emerging tech trends — straight to your inbox. No spam.