Back to Blog
Integration Testing with TestContainers in Spring Boot: A Practical Handbook
Java Engineering

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+.

December 26, 20254 min readRabi
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

The Modern Way: 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.

Optimization: The Singleton Pattern

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.

Dealing with Dirty Contexts

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.

Happy testing! 🧪

Join the Community

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

No spam. Unsubscribe at any time.

Liked the blog?

Share it with your friends and help them learn something new!