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 NOTHING doesn’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.

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

Abhay

Abhay Pratap Singh

DevOps Engineer passionate about automation, cloud infrastructure, and self-hosted tools. I write about Kubernetes, Terraform, DNS, and everything in between.