Testing Spring Boot Applications With Testcontainers — Real Databases, Real Brokers, Real Tests

by Eric Hanson, Backend Developer at Clean Systems Consulting

Why H2 isn't enough

H2 is convenient — no Docker, no external dependencies, instant startup. It's also a different database than PostgreSQL. The differences that cause production failures after passing H2 tests:

  • H2 doesn't support tsvector, jsonb, LATERAL joins, window functions, advisory locks, ON CONFLICT DO UPDATE, or RETURNING clauses in the same form as PostgreSQL
  • PostgreSQL's transaction isolation semantics differ from H2's
  • Indexes defined in Flyway migrations work differently — H2 may not enforce the same constraint behavior
  • PostgreSQL-specific query plans behave differently from H2's query optimizer

Testcontainers starts a real PostgreSQL (or MySQL, or any database) in Docker for the duration of the test suite. The test runs against the same database engine as production. What passes in CI passes in production.

Setup

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>

Spring Boot 3.1+ provides a TestcontainersPropertySourceFactoryBean that integrates Testcontainers with Spring's property system. Spring Boot 3.4+ supports @ServiceConnection for zero-configuration Testcontainers wiring.

PostgreSQL with @DataJpaTest

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

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test")
        .withReuse(true);  // reuse across test classes — faster CI

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired OrderRepository orderRepository;
    @Autowired TestEntityManager entityManager;

    @Test
    void findByStatusWithFullTextSearch_returnsMatchingOrders() {
        // Uses PostgreSQL tsvector — would fail with H2
        User user = entityManager.persistAndFlush(new User("alice@example.com"));
        Order order = entityManager.persistAndFlush(
            new Order(user, OrderStatus.PENDING, "special delivery instructions"));
        entityManager.clear();

        List<Order> results = orderRepository.searchByNotes("delivery");

        assertThat(results).hasSize(1);
        assertThat(results.get(0).getId()).isEqualTo(order.getId());
    }

    @Test
    void upsertOrder_handlesConflictCorrectly() {
        // Tests ON CONFLICT DO UPDATE — PostgreSQL-specific
        orderRepository.upsertOrder("ref-123", OrderStatus.PENDING, Money.of(100, "USD"));
        orderRepository.upsertOrder("ref-123", OrderStatus.PROCESSING, Money.of(100, "USD"));

        List<Order> orders = orderRepository.findByReference("ref-123");
        assertThat(orders).hasSize(1);
        assertThat(orders.get(0).getStatus()).isEqualTo(OrderStatus.PROCESSING);
    }
}

@AutoConfigureTestDatabase(replace = NONE) prevents Spring Boot from substituting an embedded database. @DynamicPropertySource registers the container's connection details before the Spring context starts — ensuring Flyway migrations and Hibernate initialization use the container.

withReuse(true) keeps the container running between test classes and even between test runs. The container is identified by its configuration hash — if the configuration hasn't changed, the existing container is reused. This dramatically reduces test suite time when multiple test classes use PostgreSQL.

To enable reuse globally:

# ~/.testcontainers.properties (in home directory, not project)
testcontainers.reuse.enable=true

Shared container across the test suite

Starting a new container per test class adds 3–10 seconds per class. For a test suite with 20 JPA test classes, that's 60–200 seconds of container startup time. Share a single container:

// Base class — shared container
public abstract class PostgresIntegrationTest {

    static final PostgreSQLContainer<?> postgres;

    static {
        postgres = new PostgreSQLContainer<>("postgres:16-alpine")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");
        postgres.start();
    }

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }
}

// Test classes extend the base
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class OrderRepositoryTest extends PostgresIntegrationTest {
    // Container is shared — no individual startup cost
}

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class UserRepositoryTest extends PostgresIntegrationTest {
    // Same container, same startup
}

The static initializer starts the container once per JVM. All test classes that extend PostgresIntegrationTest use it. JVM shutdown stops the container automatically via the Ryuk container that Testcontainers manages.

Spring Boot 3.1+ @ServiceConnection

Spring Boot 3.1 introduced @ServiceConnection — automatic property registration from a container:

@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {

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

    @Container
    @ServiceConnection
    static RedisContainer redis = new RedisContainer(DockerImageName.parse("redis:7-alpine"));

    // No @DynamicPropertySource needed — @ServiceConnection handles it
    @Autowired OrderService orderService;

    @Test
    void processOrder_persistsAndCachesResult() {
        Order order = orderService.processOrder(new CreateOrderRequest(...));

        assertThat(orderRepository.findById(order.getId())).isPresent();
        assertThat(redisTemplate.hasKey("order:" + order.getId())).isTrue();
    }
}

@ServiceConnection inspects the container type and automatically registers the appropriate Spring Boot properties. PostgreSQLContainer registers spring.datasource.*; RedisContainer registers spring.data.redis.*. No @DynamicPropertySource boilerplate.

Kafka with Testcontainers

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>kafka</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class OrderKafkaIntegrationTest {

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

    @Autowired OrderKafkaPublisher publisher;
    @Autowired KafkaTemplate<String, Object> kafkaTemplate;

    @Test
    void publishOrderPlaced_consumedByInventoryConsumer() throws Exception {
        CountDownLatch latch = new CountDownLatch(1);
        AtomicReference<OrderPlacedEvent> receivedEvent = new AtomicReference<>();

        // Register a test consumer
        @KafkaListener(topics = "orders.placed", groupId = "test-consumer")
        class TestConsumer {
            public void consume(OrderPlacedEvent event) {
                receivedEvent.set(event);
                latch.countDown();
            }
        }

        publisher.publishOrderPlaced(testOrder());

        assertThat(latch.await(10, TimeUnit.SECONDS)).isTrue();
        assertThat(receivedEvent.get().orderId()).isEqualTo(testOrder().getId());
    }
}

For testing Kafka consumers without a producer, use KafkaTestUtils:

@Test
void inventoryConsumer_reservesInventoryOnOrderPlaced() throws Exception {
    String topic = "orders.placed";
    OrderPlacedEvent event = new OrderPlacedEvent("ord-1", "user-1",
        List.of(new OrderItem("product-1", 2)), Money.of(100, "USD"));

    // Produce a test message directly
    ProducerFactory<String, Object> producerFactory = new DefaultKafkaProducerFactory<>(
        KafkaTestUtils.producerProps(kafka.getBootstrapServers()));
    KafkaTemplate<String, Object> template = new KafkaTemplate<>(producerFactory);
    template.send(topic, event.orderId(), event).get(5, TimeUnit.SECONDS);

    // Wait for consumer to process
    Awaitility.await()
        .atMost(Duration.ofSeconds(10))
        .pollInterval(Duration.ofMillis(500))
        .until(() -> inventoryRepository.existsByOrderId("ord-1"));

    InventoryReservation reservation = inventoryRepository.findByOrderId("ord-1");
    assertThat(reservation.getProductId()).isEqualTo("product-1");
    assertThat(reservation.getQuantity()).isEqualTo(2);
}

Awaitility polls a condition until it's true or a timeout expires — the right tool for asynchronous assertion in consumer tests. Avoid Thread.sleep() for consumer tests; the timing is non-deterministic and either too slow (wasting CI time) or too fast (flaky tests).

RabbitMQ with Testcontainers

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>rabbitmq</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class OrderRabbitMQIntegrationTest {

    @Container
    static RabbitMQContainer rabbitmq = new RabbitMQContainer(
        DockerImageName.parse("rabbitmq:3.12-management-alpine"));

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.rabbitmq.host", rabbitmq::getHost);
        registry.add("spring.rabbitmq.port", rabbitmq::getAmqpPort);
        registry.add("spring.rabbitmq.username", () -> "guest");
        registry.add("spring.rabbitmq.password", () -> "guest");
    }

    @Autowired OrderEventPublisher publisher;
    @Autowired RabbitTemplate rabbitTemplate;

    @Test
    void publishedMessage_isConsumedAndProcessed() {
        Order order = testOrder();
        publisher.publishOrderPlaced(order);

        Awaitility.await()
            .atMost(Duration.ofSeconds(10))
            .until(() -> shippingRepository.existsByOrderId(order.getId()));

        ShipmentRecord record = shippingRepository.findByOrderId(order.getId());
        assertThat(record.getStatus()).isEqualTo(ShipmentStatus.SCHEDULED);
    }
}

Redis with Testcontainers

@SpringBootTest
@Testcontainers
class CacheIntegrationTest {

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

    @Autowired ProductService productService;
    @Autowired StringRedisTemplate redisTemplate;

    @Test
    void getProduct_cachesMissOnFirstCall_hitOnSecond() {
        // First call — cache miss, loads from DB
        Product first = productService.getProduct(1L);

        // Verify cached
        assertThat(redisTemplate.hasKey("products::1")).isTrue();

        // Second call — cache hit (verify by checking query count or mock behavior)
        Product second = productService.getProduct(1L);
        assertThat(second).isEqualTo(first);
    }

    @Test
    void updateProduct_evictsCache() {
        productService.getProduct(1L);  // populate cache
        assertThat(redisTemplate.hasKey("products::1")).isTrue();

        productService.updateProduct(1L, new UpdateProductRequest("new name", 2000L));
        assertThat(redisTemplate.hasKey("products::1")).isFalse();
    }
}

Container lifecycle and CI performance

Container startup time adds up. The strategies for keeping CI fast:

Use Alpine images. postgres:16-alpine is ~250MB; postgres:16 is ~400MB. Smaller images pull faster and start faster in CI environments where the image isn't cached.

Reuse containers. withReuse(true) and testcontainers.reuse.enable=true keep containers alive between test runs. Requires cleaning data between tests (transactions, truncation, or randomized test data) but eliminates per-class startup.

Share containers within a suite. The static base class pattern starts one container per JVM run. For a CI run with 50 test classes, the difference between one startup and 50 startups is significant.

Parallelize at the suite level, not at the container level. Run test suites in parallel across CI workers; each worker shares one container. Don't start multiple containers within a single test run for performance reasons — the overhead of coordination outweighs the parallelism benefit for typical test suites.

Cache Docker images in CI. Configure your CI pipeline to cache the Docker layer cache between runs. docker pull postgres:16-alpine on every CI run adds 30–60 seconds. A cached layer cache reduces this to near zero.

The complete picture: Testcontainers with shared containers, Alpine images, and CI layer caching adds 15–30 seconds to a test suite that previously used H2 — and catches entire categories of production bugs that H2 silently passes.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Fat Models, Skinny Controllers — and Why I Moved Beyond Both

The fat models, skinny controllers mantra fixed one problem and created another. Here is what the architecture actually looks like when you take it to its logical conclusion.

Read more

Why Documentation Is More Important Than Code in Large Systems

Code builds the system. Documentation keeps it alive. When things scale, what you write about the system matters more than the code itself.

Read more

How to Handle Contract Termination Professionally

Hearing “we need to end the contract” can feel like a punch in the gut. It’s awkward, stressful, and sometimes confusing.

Read more

Why the Nordics Are the Best Region to Work With an Async Backend Contractor

Your team already writes specs before building. Your standups are fifteen minutes. Your Confluence pages actually get updated. You might not realize it, but you're already set up for async contracting better than most companies in Europe.

Read more