Setting Up Testcontainers in a Java Project

Setting up Testcontainers takes about five minutes. The dependencies are on Maven Central, the BOM manages all version alignment, and Spring Boot 3 has first-class support that eliminates most of the configuration boilerplate. By the end of this article, you will have a Spring Boot project with Testcontainers running, a PostgreSQL container backing your first integration test, and a clear understanding of how to add any additional container module.


What You’ll Learn

  • How the Testcontainers BOM works and why you need it
  • Which dependencies to add for Spring Boot, PostgreSQL, and JUnit 5
  • How to configure Maven and Gradle for Testcontainers tests
  • How to set up Logback so you can see container startup logs
  • How @ServiceConnection eliminates @DynamicPropertySource boilerplate in Spring Boot 3.1+

Project Structure

This tutorial series uses a single Spring Boot project throughout — an e-commerce order management API. If you are following along, generate a project from Spring Initializr with:

  • Spring Boot 3.3.x
  • Java 21
  • Dependencies: Spring Web, Spring Data JPA, PostgreSQL Driver, Spring Boot DevTools

The project structure:

src/
├── main/
│   ├── java/com/devopsmonk/orders/
│   │   ├── domain/
│   │   │   ├── Order.java
│   │   │   └── OrderStatus.java
│   │   └── repository/
│   │       └── OrderRepository.java
│   └── resources/
│       └── application.yml
└── test/
    ├── java/com/devopsmonk/orders/
    │   └── repository/
    │       └── OrderRepositoryTest.java
    └── resources/
        ├── application-test.yml
        └── logback-test.xml

Maven Setup

The BOM

Testcontainers releases multiple modules together. Rather than specifying the version for each module individually, import the BOM (Bill of Materials) in your dependencyManagement section. The BOM ensures all Testcontainers modules use the same compatible version.

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

With the BOM imported, individual module dependencies do not need version numbers:

<dependencies>
    <!-- Core Testcontainers + JUnit 5 integration -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- PostgreSQL module -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Spring Boot BOM

If your project uses the Spring Boot parent POM, Spring Boot 3.1+ already includes Testcontainers in its dependency management with a compatible version. You can skip the Testcontainers BOM entirely and let Spring Boot manage the version:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.3.5</version>
</parent>

With the Spring Boot parent, just add the dependencies without a version:

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

spring-boot-testcontainers is the Spring Boot integration artifact. It provides @ServiceConnection support, which Article 5 covers in detail.

Maven Surefire Plugin

JUnit 5 requires Maven Surefire 2.22.0 or higher. Most modern Spring Boot projects already include this. Verify:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-surefire-plugin</artifactId>
    <version>3.3.1</version>
</plugin>

Gradle Setup

Gradle with Kotlin DSL (build.gradle.kts)

plugins {
    id("org.springframework.boot") version "3.3.5"
    id("io.spring.dependency-management") version "1.1.6"
    java
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-web")
    runtimeOnly("org.postgresql:postgresql")

    testImplementation("org.springframework.boot:spring-boot-starter-test")
    testImplementation("org.springframework.boot:spring-boot-testcontainers")
    testImplementation("org.testcontainers:junit-jupiter")
    testImplementation("org.testcontainers:postgresql")
}

tasks.withType<Test> {
    useJUnitPlatform()
}

Gradle with Groovy DSL (build.gradle)

plugins {
    id 'org.springframework.boot' version '3.3.5'
    id 'io.spring.dependency-management' version '1.1.6'
    id 'java'
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    runtimeOnly 'org.postgresql:postgresql'

    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.boot:spring-boot-testcontainers'
    testImplementation 'org.testcontainers:junit-jupiter'
    testImplementation 'org.testcontainers:postgresql'
}

test {
    useJUnitPlatform()
}

Configuring Logback for Test Output

When Testcontainers starts a container, it pulls the Docker image, starts the container, and waits for it to be ready. By default, this logging is suppressed. Adding a logback-test.xml in src/test/resources gives you visibility:

<!-- src/test/resources/logback-test.xml -->
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <logger name="org.testcontainers" level="INFO"/>
    <logger name="com.github.dockerjava" level="WARN"/>
    <logger name="tc" level="INFO"/>

    <root level="INFO">
        <appender-ref ref="STDOUT"/>
    </root>
</configuration>

With this configuration, you will see output like:

10:23:15.341 [main] INFO  tc.postgres:16-alpine - Container postgres:16-alpine is starting
10:23:16.882 [main] INFO  tc.postgres:16-alpine - Container postgres:16-alpine started in PT1.541S

The tc logger prefix is used by Testcontainers for container-specific log lines.


The Domain Model

The articles in this series use an Order entity throughout. Here is the minimal version:

// src/main/java/com/devopsmonk/orders/domain/Order.java
@Entity
@Table(name = "orders")
public class Order {

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

    @Column(nullable = false)
    private String customerId;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private OrderStatus status;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal totalAmount;

    @CreationTimestamp
    private LocalDateTime createdAt;

    protected Order() {}

    public Order(String customerId, OrderStatus status, BigDecimal totalAmount) {
        this.customerId = customerId;
        this.status = status;
        this.totalAmount = totalAmount;
    }

    // getters omitted for brevity
}
// src/main/java/com/devopsmonk/orders/domain/OrderStatus.java
public enum OrderStatus {
    PENDING, CONFIRMED, SHIPPED, DELIVERED, CANCELLED
}
// src/main/java/com/devopsmonk/orders/repository/OrderRepository.java
public interface OrderRepository extends JpaRepository<Order, Long> {

    List<Order> findByCustomerId(String customerId);

    List<Order> findByStatus(OrderStatus status);

    @Query("SELECT o FROM Order o WHERE o.totalAmount > :amount")
    List<Order> findOrdersAboveAmount(@Param("amount") BigDecimal amount);
}

Your First Testcontainers Test

With dependencies in place, here is the first integration test. This uses the pre-Spring Boot 3.1 approach with @DynamicPropertySource so you can see all the moving parts. The cleaner @ServiceConnection approach is covered in Article 5.

// src/test/java/com/devopsmonk/orders/repository/OrderRepositoryTest.java
@SpringBootTest
@Testcontainers
class OrderRepositoryTest {

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

    @DynamicPropertySource
    static void configureDataSource(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
        registry.add("spring.jpa.hibernate.ddl-auto", () -> "create-drop");
    }

    @Autowired
    private OrderRepository orderRepository;

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

    @Test
    void shouldSaveAndFindOrder() {
        Order order = new Order("customer-1", OrderStatus.PENDING, BigDecimal.valueOf(99.99));
        Order saved = orderRepository.save(order);

        Optional<Order> found = orderRepository.findById(saved.getId());

        assertThat(found).isPresent();
        assertThat(found.get().getCustomerId()).isEqualTo("customer-1");
        assertThat(found.get().getStatus()).isEqualTo(OrderStatus.PENDING);
    }

    @Test
    void shouldFindOrdersByCustomer() {
        orderRepository.save(new Order("customer-1", OrderStatus.PENDING, BigDecimal.valueOf(50.00)));
        orderRepository.save(new Order("customer-1", OrderStatus.CONFIRMED, BigDecimal.valueOf(75.00)));
        orderRepository.save(new Order("customer-2", OrderStatus.PENDING, BigDecimal.valueOf(100.00)));

        List<Order> customer1Orders = orderRepository.findByCustomerId("customer-1");

        assertThat(customer1Orders).hasSize(2);
        assertThat(customer1Orders).extracting(Order::getCustomerId)
            .containsOnly("customer-1");
    }

    @Test
    void shouldFindOrdersAboveAmount() {
        orderRepository.save(new Order("customer-1", OrderStatus.PENDING, BigDecimal.valueOf(50.00)));
        orderRepository.save(new Order("customer-1", OrderStatus.PENDING, BigDecimal.valueOf(150.00)));
        orderRepository.save(new Order("customer-1", OrderStatus.PENDING, BigDecimal.valueOf(200.00)));

        List<Order> largeOrders = orderRepository.findOrdersAboveAmount(BigDecimal.valueOf(100.00));

        assertThat(largeOrders).hasSize(2);
        assertThat(largeOrders).extracting(Order::getTotalAmount)
            .allMatch(amount -> amount.compareTo(BigDecimal.valueOf(100.00)) > 0);
    }
}

Breaking down the key annotations:

@SpringBootTest starts the full Spring application context. Testcontainers works with other test slice annotations too (@DataJpaTest, @WebMvcTest) — Article 16 covers slices in detail.

@Testcontainers is a JUnit 5 extension that activates the @Container lifecycle management. Without this annotation, @Container does nothing.

@Container on a static field means the container starts before the first test in the class and stops after the last test. All tests in the class share the same container instance. A non-static @Container would start and stop a new container for each test method — much slower.

@DynamicPropertySource allows overriding Spring properties after the application context is built but before beans are created. This is how you inject the container’s dynamic JDBC URL into the datasource configuration.


Running the Tests

# Maven
./mvnw test -pl . -Dtest=OrderRepositoryTest

# Gradle
./gradlew test --tests "com.devopsmonk.orders.repository.OrderRepositoryTest"

Expected output:

10:23:15.341 [main] INFO  tc.postgres:16-alpine - Container postgres:16-alpine is starting
10:23:16.882 [main] INFO  tc.postgres:16-alpine - Container postgres:16-alpine started in PT1.541S

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::                (v3.3.5)

...
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0

The first run is slower because Docker needs to pull the postgres:16-alpine image. Subsequent runs use the cached image.


Available Container Modules

Add any of these modules to your test dependencies as needed:

<!-- MySQL -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mysql</artifactId>
    <scope>test</scope>
</dependency>

<!-- MongoDB -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>mongodb</artifactId>
    <scope>test</scope>
</dependency>

<!-- Kafka -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>kafka</artifactId>
    <scope>test</scope>
</dependency>

<!-- RabbitMQ -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>rabbitmq</artifactId>
    <scope>test</scope>
</dependency>

<!-- Elasticsearch -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>elasticsearch</artifactId>
    <scope>test</scope>
</dependency>

<!-- LocalStack (AWS) -->
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>localstack</artifactId>
    <scope>test</scope>
</dependency>

<!-- WireMock -->
<dependency>
    <groupId>org.wiremock.integrations</groupId>
    <artifactId>wiremock-testcontainers-java</artifactId>
    <version>1.0-alpha-14</version>
    <scope>test</scope>
</dependency>

Common Pitfalls

Forgetting @Testcontainers on the test class. @Container annotated fields do nothing without @Testcontainers. The container will not start. Your test will fail with a connection refused error.

Using a non-static @Container field for expensive containers. A non-static container starts and stops for every test method. PostgreSQL takes 1–2 seconds to start. With 20 tests, that is 20–40 extra seconds. Use static fields for class-level sharing, and only use non-static when you genuinely need a fresh container per test.

Not pinning the image version. Use postgres:16-alpine, not postgres:latest. Pinning ensures reproducibility.


Summary

Adding Testcontainers to a Spring Boot project requires three things: the spring-boot-testcontainers artifact, the JUnit Jupiter module, and the specific container module for the technology you want to test. The Spring Boot parent POM manages the versions. @Testcontainers, @Container, and @DynamicPropertySource are the three annotations that wire everything together.

The next article covers JUnit 5 lifecycle management in depth — static vs instance containers, the @BeforeAll approach, and the singleton pattern for sharing containers across the entire test suite.

Next: JUnit 5 Integration — Lifecycle and Annotations