Spring Boot Testing with Testcontainers: The Right Way
Testcontainers spins up real Docker containers for your tests — a real PostgreSQL database, a real Redis, a real Kafka broker. No more mocking JDBC connections or in-memory H2 databases that behave differently from production.
Spring Boot 3.1 added @ServiceConnection, which removes the boilerplate of configuring connection URLs manually. This guide covers the right patterns for fast, reliable integration tests with Testcontainers.
Why Testcontainers Over H2
Teams use H2 in-memory databases for testing because it’s fast. But H2 causes real problems:
- Different SQL dialect than production PostgreSQL (e.g.,
ON CONFLICT DO NOTHINGdoesn’t exist in H2) - Different constraint behavior
- No support for PostgreSQL-specific types (
jsonb,uuid,hstore) - False confidence — tests pass on H2, fail in production
Testcontainers fixes this by running your actual database engine in a container. Tests take 2–4 seconds longer to start (container spin-up) but are dramatically more reliable.
Setup
Dependencies (Maven)
<dependencies>
<!-- Spring Boot Test (includes Testcontainers BOM in Boot 3.1+) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers core + JUnit 5 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- Database-specific modules -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<!-- Optional: WireMock for external API mocking -->
<dependency>
<groupId>org.wiremock.integrations</groupId>
<artifactId>wiremock-spring-boot</artifactId>
<version>3.0.0</version>
<scope>test</scope>
</dependency>
</dependencies>
Spring Boot 3.1+ manages Testcontainers versions via its BOM — no need to specify versions manually.
@ServiceConnection (Spring Boot 3.1+)
Before 3.1, you had to use @DynamicPropertySource to wire container ports into Spring properties:
// OLD WAY (Spring Boot 3.0 and earlier) — verbose
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}
Spring Boot 3.1 introduced @ServiceConnection — it auto-configures the datasource (or Redis, Kafka, etc.) directly from the container:
// NEW WAY (Spring Boot 3.1+) — clean
@SpringBootTest
@Testcontainers
class OrderServiceTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Autowired
private OrderService orderService;
@Test
void shouldCreateOrder() {
// postgres is running, Spring datasource is wired automatically
Order order = orderService.createOrder(new CreateOrderRequest(...));
assertThat(order.getId()).isNotNull();
}
}
@ServiceConnection works with: PostgreSQL, MySQL, MariaDB, MongoDB, Redis, Kafka, RabbitMQ, Elasticsearch, and more.
Shared Containers (Critical for Test Speed)
The most common performance mistake: declaring containers with @Container inside individual test classes. Each test class starts and stops its own container. If you have 50 test classes, you’re starting 50 PostgreSQL containers sequentially — your test suite takes 20+ minutes.
The fix: a shared container base class, started once per JVM:
// src/test/java/com/example/AbstractIntegrationTest.java
@SpringBootTest
@Testcontainers
public abstract class AbstractIntegrationTest {
@Container
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true); // reuses container across test runs (requires testcontainers.properties)
@Container
@ServiceConnection
static final GenericContainer<?> REDIS =
new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
}
Because POSTGRES is static, it’s shared across all test classes that extend AbstractIntegrationTest. Testcontainers starts it once when the first test class loads and keeps it running for the entire test suite.
// All integration tests extend the base class
class OrderRepositoryTest extends AbstractIntegrationTest {
@Autowired
private OrderRepository orderRepository;
@Test
void shouldFindOrderById() { ... }
}
class CustomerRepositoryTest extends AbstractIntegrationTest {
@Autowired
private CustomerRepository customerRepository;
@Test
void shouldFindByEmail() { ... }
}
Both tests share the same PostgreSQL container — started once, not twice.
Container reuse across test runs
Add testcontainers.reuse.enable=true to ~/.testcontainers.properties:
# ~/.testcontainers.properties
testcontainers.reuse.enable=true
With .withReuse(true) on the container, Testcontainers reuses existing containers from previous test runs (identified by container labels). The first run takes 3–4 seconds to spin up; subsequent runs within minutes are instant.
Test Data Isolation
With a shared container, test data from one test can leak into another. Two strategies:
Strategy 1: @BeforeEach cleanup
class OrderRepositoryTest extends AbstractIntegrationTest {
@Autowired
private OrderRepository orderRepository;
@Autowired
private JdbcTemplate jdbcTemplate;
@BeforeEach
void cleanup() {
jdbcTemplate.execute("TRUNCATE orders, order_items CASCADE");
}
@Test
void shouldCountOrders() {
orderRepository.save(new Order(...));
assertThat(orderRepository.count()).isEqualTo(1);
}
}
Downside: truncating large tables with many foreign keys is slow.
Strategy 2: @Transactional rollback (recommended for unit-style tests)
@Transactional // rolls back after each test — no cleanup needed
class OrderRepositoryTest extends AbstractIntegrationTest {
@Test
void shouldSaveOrder() {
// Data inserted here is rolled back automatically after the test
orderRepository.save(new Order(...));
assertThat(orderRepository.count()).isEqualTo(1);
}
}
Works well for repository tests. Doesn’t work for tests that verify async behavior, events, or anything that spans multiple transactions.
Strategy 3: Dedicated test schemas
@BeforeAll
static void createSchema() {
POSTGRES.execInContainer("psql", "-U", "test", "-c",
"CREATE SCHEMA IF NOT EXISTS test_" + testClassName);
}
Each test class gets its own schema — complete isolation without truncation overhead.
Testing with Multiple Containers
A typical microservice test setup:
@SpringBootTest
@Testcontainers
public abstract class AbstractIntegrationTest {
@Container
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
@Container
@ServiceConnection
static final GenericContainer<?> REDIS =
new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Container
static final KafkaContainer KAFKA =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@DynamicPropertySource
static void kafkaProperties(DynamicPropertyRegistry registry) {
// Kafka @ServiceConnection support added in Spring Boot 3.3
// For earlier versions, use DynamicPropertySource
registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers);
}
}
All containers start in parallel — Testcontainers is smart enough to start independent containers concurrently.
WireMock for External APIs
Use WireMock to mock external HTTP services (payment gateways, notification APIs, third-party services):
@SpringBootTest
@EnableWireMock({
@ConfigureWireMock(
name = "payment-service",
property = "payment.service.url" // auto-sets this property to WireMock's URL
)
})
class PaymentServiceTest extends AbstractIntegrationTest {
@InjectWireMock("payment-service")
private WireMockServer paymentWireMock;
@Autowired
private OrderService orderService;
@Test
void shouldProcessPaymentSuccessfully() {
paymentWireMock.stubFor(post(urlEqualTo("/payments"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{"transactionId": "txn-123", "status": "APPROVED"}
""")));
Order order = orderService.placeOrder(new PlaceOrderRequest(...));
assertThat(order.getPaymentStatus()).isEqualTo(PaymentStatus.APPROVED);
paymentWireMock.verify(postRequestedFor(urlEqualTo("/payments")));
}
@Test
void shouldHandlePaymentFailure() {
paymentWireMock.stubFor(post(urlEqualTo("/payments"))
.willReturn(aResponse().withStatus(503)));
assertThatThrownBy(() -> orderService.placeOrder(new PlaceOrderRequest(...)))
.isInstanceOf(PaymentException.class);
}
}
WireMock stubs are reset between tests automatically with @EnableWireMock.
Spring Boot 3.1: DevTools + Testcontainers for Local Dev
Spring Boot 3.1 added a TestApplication pattern that lets you run your app locally using Testcontainers, so developers don’t need local databases installed:
// src/test/java/com/example/TestApplication.java
public class TestApplication {
public static void main(String[] args) {
SpringApplication.from(Application::main)
.with(TestcontainersConfiguration.class)
.run(args);
}
}
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine");
}
@Bean
@ServiceConnection
GenericContainer<?> redisContainer() {
return new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
}
}
Run TestApplication.main() to start the app with real Docker containers — no local PostgreSQL or Redis required. Spring Boot generates this class automatically with spring initializr when Testcontainers is selected.
CI Configuration
GitHub Actions
name: Integration Tests
on: [push, pull_request]
jobs:
integration-test:
runs-on: ubuntu-latest # Docker is available on ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Run integration tests
run: ./mvnw test -Dtest="*IT,*IntegrationTest"
env:
TESTCONTAINERS_RYUK_DISABLED: "true" # optional: disable Ryuk in CI
Docker is available on ubuntu-latest GitHub Actions runners — no extra setup needed.
Parallel test execution
Speed up test suites by running tests in parallel:
<!-- pom.xml -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<forkCount>2</forkCount> <!-- 2 JVM forks -->
<reuseForks>true</reuseForks> <!-- reuse forks between test classes -->
</configuration>
</plugin>
With parallel forks, each JVM gets its own container instances. Container startup is amortized across parallel execution.
Common Mistakes
Mistake 1: Container per test class (not shared)
// SLOW — new container per class
@Testcontainers
class OrderTest {
@Container // <-- instance field, not static
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
}
Make it static or use a shared base class.
Mistake 2: Testcontainers in unit tests
Testcontainers is for integration tests. Don’t use it in unit tests — mock the repository layer instead.
Mistake 3: Hardcoded container versions
// BAD
new PostgreSQLContainer<>("postgres:latest")
// GOOD — pin to a specific minor version
new PostgreSQLContainer<>("postgres:16.2-alpine")
latest changes without warning and can break your tests on a random Monday.
Mistake 4: Missing Docker in CI
If your CI runner doesn’t have Docker, add it. On GitHub Actions, ubuntu-latest has Docker. On self-hosted runners, install it explicitly.
Quick Reference
// Minimal integration test setup
@SpringBootTest
@Testcontainers
class MyIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
@Test
void test() { /* real DB available */ }
}
// Shared container base class
public abstract class AbstractIntegrationTest {
@Container
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine");
}
// WireMock stub
wireMock.stubFor(get("/api").willReturn(aResponse().withStatus(200).withBody("...")));
// Container reuse (add to ~/.testcontainers.properties)
testcontainers.reuse.enable=true
Summary
Testcontainers with @ServiceConnection gives you real-database integration tests with minimal boilerplate. Use a shared static container base class to start each container once per test run, not once per test class. Use @Transactional rollback for test isolation in repository tests. Add WireMock for external API calls. Pin container versions, enable container reuse for local development, and run on ubuntu-latest in CI where Docker is pre-installed.
