Integration Testing with Testcontainers in Spring Boot 3

1. Overview

Integration tests running on H2 often show green results on your machine but still fail on production running PostgreSQL. The reason is that SQL syntax, data types, and transaction behavior differ between the two database management systems. Testcontainers solves this by starting a real database in a Docker container right when tests run.

In this article, you’ll configure Testcontainers for a Spring Boot 3.x application and use @ServiceConnection to wire the container into Spring. Then you’ll share a single container across multiple test classes and understand common pitfalls.

2. Setup

2.1. Dependencies

You need Docker running on your machine, or a compatible Docker daemon. Then add the following dependencies to pom.xml:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.postgresql</groupId>
        <artifactId>postgresql</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-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>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

The Spring Boot parent already manages the Testcontainers version, so you don’t need to declare <version>. The spring-boot-testcontainers artifact is available from Spring Boot 3.1 onward and provides the @ServiceConnection annotation.

2.2. Sample Code

We’ll use a simple Order entity:

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String customerEmail;

    private BigDecimal total;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    protected Order() {
    }

    public Order(String customerEmail, BigDecimal total, OrderStatus status) {
        this.customerEmail = customerEmail;
        this.total = total;
        this.status = status;
    }

    public Long getId() { return id; }
    public String getCustomerEmail() { return customerEmail; }
    public BigDecimal getTotal() { return total; }
    public OrderStatus getStatus() { return status; }
}
public enum OrderStatus { NEW, PAID, CANCELLED }

The repository has a native query using PostgreSQL’s ILIKE:

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query(value = "select * from orders where customer_email ilike :pattern",
           nativeQuery = true)
    List<Order> searchByEmail(@Param("pattern") String pattern);

    List<Order> findByStatus(OrderStatus status);
}

Native queries are the type of code that easily “works on H2 but breaks on Postgres”, or vice versa. This is exactly where you should test against a real database.

3. Testing a Repository with @ServiceConnection

3.1. Declaring the Container

@DataJpaTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {

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

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void givenOrders_whenSearchByEmailIgnoringCase_thenReturnMatches() {
        orderRepository.save(new Order("An.Nguyen@example.com", new BigDecimal("150000"), OrderStatus.NEW));
        orderRepository.save(new Order("binh@example.com", new BigDecimal("90000"), OrderStatus.PAID));

        List<Order> result = orderRepository.searchByEmail("%an.nguyen%");

        assertThat(result)
            .extracting(Order::getCustomerEmail)
            .containsExactly("An.Nguyen@example.com");
    }
}

Each component works as follows:

  • @Testcontainers enables the JUnit 5 extension. The extension finds fields with @Container and manages their lifecycle.
  • @Container on a static field: the container starts once before all tests in the class and stops after the last test. If the field is not static, the container will be recreated for each test method.
  • @ServiceConnection: Spring Boot reads the host, random port, username, and password from the container and creates a JdbcConnectionDetails bean. This way you don’t have to manually configure spring.datasource.url.
  • @AutoConfigureTestDatabase(replace = NONE): by default, @DataJpaTest may replace the DataSource with an embedded database. From Spring Boot 3.4, the framework automatically recognizes a DataSource from @ServiceConnection. However, explicit declaration helps the code work correctly on all 3.x versions.
  • ddl-auto=create-drop: with a non-embedded database, Spring Boot doesn’t create the schema automatically. In a real project, you should use Flyway or Liquibase so that tests run the production migration correctly.

When you run the tests, the logs will look like:

Creating container for image: postgres:16-alpine
Container postgres:16-alpine is starting: 3f9c1a...
Container postgres:16-alpine started in PT2.1S
HikariPool-1 - Added connection org.postgresql.jdbc.PgConnection@6a1b...

The first run will be slower because Docker has to pull the image. Subsequent runs typically take only a few seconds.

3.2. The Old Way: @DynamicPropertySource

Before Spring Boot 3.1, or for containers not yet supported by @ServiceConnection, you push properties manually:

@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);
}

This approach still works. However, for PostgreSQL, MySQL, Redis, Kafka, MongoDB, and many other popular services, @ServiceConnection is more concise and less prone to property name typos.

4. Testing the Full Application with @SpringBootTest

Repository tests only load the JPA layer. To verify the HTTP → service → database flow, you need to load the entire context. Suppose you have the following controller:

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderRepository orderRepository;

    public OrderController(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public record CreateOrderRequest(String customerEmail, BigDecimal total) {}

    @PostMapping
    public ResponseEntity<Order> create(@RequestBody CreateOrderRequest request) {
        Order saved = orderRepository.save(
            new Order(request.customerEmail(), request.total(), OrderStatus.NEW));
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Order> get(@PathVariable Long id) {
        return ResponseEntity.of(orderRepository.findById(id));
    }
}

4.1. Sharing a Container Using @TestConfiguration

Instead of declaring the container in each test class, you make it a bean:

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfig {

    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>("postgres:16-alpine");
    }
}

Here the container is managed by Spring, not JUnit. Spring starts the container before creating the DataSource and stops it when the context closes. Spring TestContext caches contexts between test classes with the same configuration, so those classes share a single container.

4.2. Writing Tests

@SpringBootTest(
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
    properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@Import(TestcontainersConfig.class)
class OrderControllerIT {

    @Autowired
    private TestRestTemplate restTemplate;

    @Autowired
    private OrderRepository orderRepository;

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

    @Test
    void whenCreateOrder_thenItIsPersistedAndReadable() {
        var request = new OrderController.CreateOrderRequest(
            "chi@example.com", new BigDecimal("250000"));

        ResponseEntity<Order> created =
            restTemplate.postForEntity("/orders", request, Order.class);

        assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        Long id = created.getBody().getId();

        Order fetched = restTemplate.getForObject("/orders/" + id, Order.class);

        assertThat(fetched.getCustomerEmail()).isEqualTo("chi@example.com");
        assertThat(fetched.getTotal()).isEqualByComparingTo("250000");
        assertThat(fetched.getStatus()).isEqualTo(OrderStatus.NEW);
        assertThat(orderRepository.findByStatus(OrderStatus.NEW)).hasSize(1);
    }
}

A few things to note:

  • RANDOM_PORT starts a real server. TestRestTemplate automatically points to that port.
  • This test does not have @Transactional because HTTP requests run on a different thread than the test. Data is actually committed to Postgres, so you must clean up in @BeforeEach.
  • isEqualByComparingTo compares BigDecimal by value. This avoids the issue of 250000 differing from 250000.00 due to different scales.
  • The Order class needs to be deserialized by Jackson. If you encounter deserialization errors, check the default constructor and getters, or use a separate record DTO for the response.

4.3. Reusing the Configuration When Running Locally

TestcontainersConfig has another lesser-known use: running the application locally without needing to install Postgres. Create the following class in src/test/java:

public class TestOrderApplication {

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

Then run the command:

./mvnw spring-boot:test-run

The application starts with a Postgres container. The container stops when you shut down the application.

5. Common Pitfalls

5.1. Static @Container in Multiple Classes Sharing Context

This is the most annoying bug. Suppose you have an abstract base class containing @Container static with @ServiceConnection, and multiple test classes inherit from it:

OrderControllerIT  -> container starts at port 55001, context is cached
CustomerControllerIT -> JUnit stops old container, starts new container at port 55017
                    -> Spring reuses cached context, still points to 55001
                    -> Connection refused / HikariPool timeout

JUnit stops the container after each class, but Spring keeps the old context in cache. You have two ways to fix this:

  1. Use a bean container as in section 4.1. The container lifecycle is then tied to the context. This is the recommended approach.
  2. Use a singleton container: omit @Container and @Testcontainers, then start manually in a static block. Ryuk will clean up the container when the JVM exits.
public abstract class AbstractIntegrationTest {

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

    static {
        POSTGRES.start();
    }
}

5.2. Using the latest Tag

Note: Always pin the image version (postgres:16-alpine), preferably matching the version in production. The latest tag can cause tests to behave unexpectedly when a new image is released.

5.3. Data Leaking Between Tests

@DataJpaTest rolls back after each test by default, but @SpringBootTest with RANDOM_PORT does not. When sharing a container, data from the previous class still remains in the database. Clean up data in @BeforeEach, or use @Sql to run a TRUNCATE script. Don’t write tests that depend on execution order.

5.4. CI Environment Without Docker

If Docker is not running, tests will report an error:

java.lang.IllegalStateException: Could not find a valid Docker environment.

You need to ensure the CI runner has Docker. If you want to skip tests when Docker is unavailable instead of reporting a failure, use @Testcontainers(disabledWithoutDocker = true). Think carefully before enabling this option, because silently skipped tests are easy to forget.

5.5. Speed Up Locally with Reuse

To keep the container alive between test runs locally, enable reuse in the ~/.testcontainers.properties file:

testcontainers.reuse.enable=true

Then call .withReuse(true) when creating the container. Don’t enable this option on CI, as containers won’t be cleaned up automatically.

6. When Not to Use Testcontainers

Testcontainers makes tests more reliable, but each test will be slower and requires Docker. Some cases where you shouldn’t use it:

  • Pure logic like pricing, validation, or mapping: writing unit tests with mocks or real objects is sufficient. Unit tests run hundreds of times faster.
  • Different context configuration for each test class: each configuration creates a new context. If the container is tied to the context, you’ll have multiple containers running in parallel. Consolidate tests into a few shared configurations.
  • Third-party services over HTTP: typically use WireMock instead of trying to run the actual service in a container.

A reasonable ratio is: most tests should be unit tests, while integration tests with Testcontainers focus on infrastructure-touching areas like repositories, native queries, migrations, and message brokers.

7. Conclusion

In this article, you’ve learned to use Testcontainers to run integration tests on real PostgreSQL. @ServiceConnection eliminates the need for manual datasource configuration. Making the container a bean in @TestConfiguration both helps share a single container across multiple test classes and allows you to run the application locally with spring-boot:test-run.

Key takeaways:

  • Pin the image version to match production.
  • Don’t mix static @Container with Spring’s context cache.
  • Clean up data yourself when tests don’t run within a transaction.
  • Use Testcontainers only where you truly need a real database.
spring-boottestcontainersintegration-testjunit5postgresql