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
@ServiceConnectioneliminates@DynamicPropertySourceboilerplate 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.