Testcontainers Spring Boot integration testing with real databases
Md Sanwar Hossain
Md Sanwar Hossain
Senior Software Engineer · Spring Boot Testing Series
Testing April 4, 2026 20 min read Spring Boot Testing Series

Testcontainers in Spring Boot: Real Database Integration Tests Without Mocks

In-memory databases and hand-rolled mocks give you fast tests, but they lie to you. H2 silently ignores PostgreSQL-specific SQL, and your mock UserRepository never tests the JPA query you spent an hour debugging. Testcontainers solves this by spinning up real Docker containers — PostgreSQL, Redis, Kafka, Elasticsearch — for the duration of your test suite and tearing them down afterward. This guide walks through the full Testcontainers setup for Spring Boot 3.x, from dependency configuration to parallel test execution in GitHub Actions CI.

Table of Contents

  1. Why Testcontainers Over H2 and Mocks
  2. Project Setup: Dependencies and Docker Requirements
  3. PostgreSQL Integration Tests with @DataJpaTest
  4. Singleton Container Pattern: Reusing Containers Across Tests
  5. Kafka Integration Testing with EmbeddedKafka vs Testcontainers
  6. Redis and Multi-Container Compose Tests
  7. Spring Boot 3.1+ ServiceConnection and @ImportTestcontainers
  8. Running Testcontainers in GitHub Actions CI/CD
  9. Key Takeaways

1. Why Testcontainers Over H2 and Mocks

Testcontainers integration testing architecture | mdsanwarhossain.me
Testcontainers Integration Testing Architecture — mdsanwarhossain.me

H2 covers basic CRUD but falls apart the moment you use a PostgreSQL-specific feature: JSONB columns, pg_trgm full-text indexes, advisory locks, or RETURNING clauses. Many teams only discover the mismatch during a production incident — not in CI. Similarly, mocking a JpaRepository method tests your test setup, not your query. A @Query annotation with a typo in the JPQL compiles fine and only fails at runtime.

Testcontainers wraps the Docker daemon via the Docker Java client. Each container defined in your test class starts before the test and stops after, leaving no state between runs. Since Spring Boot 3.1, the @ServiceConnection annotation auto-configures the DataSource, RedisConnectionFactory, or Kafka properties directly from the container — no manual property overrides required.

Rule of Thumb: Use Testcontainers for any test that touches the database layer, a cache, or a message broker. Reserve Mockito for pure business logic with no I/O.

2. Project Setup: Dependencies and Docker Requirements

You need Docker running on the machine where tests execute — either your local workstation or the CI agent. Testcontainers communicates with Docker via the Unix socket or TCP, pulling images on demand and caching them locally. The Spring Boot parent BOM (3.1+) includes managed versions for all Testcontainers modules.

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

<dependencies>
    <!-- Core Testcontainers -->
    <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>
    <!-- Kafka module -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>kafka</artifactId>
        <scope>test</scope>
    </dependency>
    <!-- Spring Boot test integration -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

3. PostgreSQL Integration Tests with @DataJpaTest

The @DataJpaTest slice loads only the JPA layer — entities, repositories, and data source configuration. By default it configures an in-memory H2 database. To replace H2 with a real PostgreSQL container, annotate the test class with @AutoConfigureTestDatabase(replace = NONE) and declare a PostgreSQLContainer bean with @ServiceConnection.

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {

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

    @Autowired
    private OrderRepository orderRepository;

    @Test
    void shouldPersistAndRetrieveOrderWithJsonbMetadata() {
        Order order = new Order();
        order.setStatus("PENDING");
        order.setMetadata("{\"source\": \"web\", \"promoCode\": \"SAVE10\"}"); // JSONB
        Order saved = orderRepository.save(order);

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

        assertThat(found).isPresent();
        assertThat(found.get().getMetadata()).contains("promoCode");
    }

    @Test
    void shouldFindOrdersByStatusUsingCustomQuery() {
        orderRepository.saveAll(List.of(
            new Order("PENDING", null),
            new Order("SHIPPED", null),
            new Order("PENDING", null)
        ));

        List<Order> pending = orderRepository.findByStatus("PENDING");

        assertThat(pending).hasSize(2);
    }
}
Important: @ServiceConnection replaces the manual @DynamicPropertySource approach. It reads the container's mapped host/port and configures the Spring DataSource automatically. Requires Spring Boot 3.1+.

4. Singleton Container Pattern: Reusing Containers Across Tests

Singleton container pattern Testcontainers Spring Boot | mdsanwarhossain.me
Singleton Container Pattern — mdsanwarhossain.me

If every test class starts its own PostgreSQL container, your build spends minutes on container startup. The Singleton Container pattern declares the container as a static field in a shared base class, allowing JVM-scoped reuse across all test classes that extend it. Testcontainers' Ryuk side-container handles cleanup when the JVM exits.

// Shared base class — all integration tests extend this
public abstract class AbstractIntegrationTest {

    @Container
    @ServiceConnection
    static final PostgreSQLContainer<?> POSTGRES =
            new PostgreSQLContainer<>("postgres:16-alpine")
                .withReuse(true); // enable container reuse across runs

    static {
        POSTGRES.start();
    }
}

// Test class — inherits the shared container
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ProductRepositoryTest extends AbstractIntegrationTest {

    @Autowired
    ProductRepository productRepository;

    @Test
    void shouldCountProductsByCategory() {
        // Container already running — no startup cost
        productRepository.saveAll(List.of(
            new Product("TV", "ELECTRONICS"),
            new Product("Shirt", "CLOTHING"),
            new Product("Phone", "ELECTRONICS")
        ));
        assertThat(productRepository.countByCategory("ELECTRONICS")).isEqualTo(2);
    }
}

Enable the container reuse feature by adding testcontainers.reuse.enable=true to ~/.testcontainers.properties on developer machines or setting it as an environment variable in CI. Without this property, withReuse(true) is silently ignored, and the container is still stopped after each test session — which is the safe default.

5. Kafka Integration Testing with EmbeddedKafka vs Testcontainers

Spring's @EmbeddedKafka is fast and requires no Docker, but it runs an older Kafka version and doesn't support all broker configurations. For production-fidelity, use KafkaContainer from Testcontainers, which runs the official Confluent Platform image.

@SpringBootTest
@Testcontainers
class OrderEventPublisherTest {

    @Container
    @ServiceConnection
    static KafkaContainer kafka =
            new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));

    @Autowired
    private OrderEventPublisher publisher;

    @Autowired
    private KafkaTemplate<String, OrderEvent> kafkaTemplate;

    @Test
    void shouldPublishOrderCreatedEventToKafka() throws Exception {
        CountDownLatch latch = new CountDownLatch(1);
        List<OrderEvent> received = new ArrayList<>();

        // Consumer listening on the orders topic
        kafkaTemplate.receive("orders", 0, 0, Duration.ofSeconds(5));

        publisher.publishOrderCreated(new OrderCreatedEvent("order-123", "user-456"));

        // Assert message arrived within 10 seconds
        boolean messageReceived = latch.await(10, TimeUnit.SECONDS);
        assertThat(messageReceived).isTrue();
        assertThat(received).extracting(OrderEvent::getOrderId).contains("order-123");
    }
}

6. Redis and Multi-Container Compose Tests

Testcontainers provides a GenericContainer for Redis. For complex setups with multiple services, the DockerComposeContainer reads an existing docker-compose.yml file, which lets you reuse the same infrastructure definition for both local development and tests.

@SpringBootTest
@Testcontainers
class CacheServiceTest {

    @Container
    @ServiceConnection(name = "redis")
    static GenericContainer<?> redis =
            new GenericContainer<>("redis:7.2-alpine")
                .withExposedPorts(6379);

    @Autowired
    private ProductCacheService cacheService;

    @Test
    void shouldCacheProductLookup() {
        cacheService.cacheProduct("prod-1", new Product("Laptop", 1299.00));

        Optional<Product> cached = cacheService.getCachedProduct("prod-1");

        assertThat(cached).isPresent();
        assertThat(cached.get().getName()).isEqualTo("Laptop");
    }

    @Test
    void shouldEvictCacheOnUpdate() {
        cacheService.cacheProduct("prod-2", new Product("Mouse", 29.99));
        cacheService.evict("prod-2");

        Optional<Product> cached = cacheService.getCachedProduct("prod-2");

        assertThat(cached).isEmpty();
    }
}

// Multi-container with Docker Compose
@Testcontainers
class FullStackIntegrationTest {

    @Container
    static DockerComposeContainer<?> compose =
        new DockerComposeContainer<>(new File("src/test/resources/docker-compose-test.yml"))
            .withExposedService("postgres_1", 5432, Wait.forListeningPort())
            .withExposedService("redis_1", 6379, Wait.forListeningPort())
            .withExposedService("kafka_1", 9092, Wait.forListeningPort());
}

7. Spring Boot 3.1+ ServiceConnection and @ImportTestcontainers

Spring Boot 3.1 introduced two major improvements: @ServiceConnection for zero-boilerplate property wiring, and @ImportTestcontainers for sharing container definitions across tests via a dedicated configuration class. Together they eliminate most of the repetitive infrastructure code.

// Centralized container configuration
public class TestContainersConfig {

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

    @Bean
    @ServiceConnection
    RedisContainer redisContainer() {
        return new RedisContainer("redis:7.2-alpine");
    }
}

// Test class imports the shared config — no @Container declarations needed
@SpringBootTest
@ImportTestcontainers(TestContainersConfig.class)
class OrderServiceIntegrationTest {

    @Autowired
    private OrderService orderService;

    @Test
    void shouldCreateOrderAndPublishEvent() {
        OrderRequest req = new OrderRequest("user-1", List.of("sku-A", "sku-B"));
        Order order = orderService.createOrder(req);

        assertThat(order.getId()).isNotNull();
        assertThat(order.getStatus()).isEqualTo("PENDING");
    }
}

8. Running Testcontainers in GitHub Actions CI/CD

GitHub Actions runners have Docker pre-installed. No special configuration is needed — Testcontainers detects the Docker socket automatically. For speed, use Maven's Surefire fork count to run test classes in parallel, and cache the Docker image layer in the Actions cache.

# .github/workflows/ci.yml
name: CI — Integration Tests

on: [push, pull_request]

jobs:
  integration-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: maven

      - name: Pull Docker images in parallel
        run: |
          docker pull postgres:16-alpine &
          docker pull redis:7.2-alpine &
          docker pull confluentinc/cp-kafka:7.6.1 &
          wait

      - name: Run integration tests
        run: mvn verify -Pfailsafe -T 2
        env:
          TESTCONTAINERS_RYUK_DISABLED: false

For Ryuk (the container cleanup side-car), leave TESTCONTAINERS_RYUK_DISABLED as false. Ryuk ensures all containers are removed even if the JVM crashes, preventing orphaned containers that consume CI agent resources. If your CI environment does not allow privileged containers, set TESTCONTAINERS_RYUK_PRIVILEGED=false and use the unprivileged mode.

9. Key Takeaways

Leave a Comment

Related Posts

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Java · Spring Boot · Microservices

Last updated: April 4, 2026