DEV Community

Someswar Duddi
Someswar Duddi

Posted on

Integration testing Spring Boot with Testcontainers, PostgreSQL, Kafka and JUnit 5

Mocks are great for unit tests, but they can't tell you whether your SQL works on PostgreSQL, whether your JSON survives a round trip through Kafka, or whether your listener is wired to the right topic. H2 and embedded Kafka get closer, but they aren't the things you run in production.

Testcontainers starts real dependencies in Docker for the duration of a test run. Since Spring Boot 3.1, @ServiceConnection wires them into your application automatically, so the setup is now a handful of lines.

This post tests a small service that consumes OrderPlaced events from Kafka and stores them in PostgreSQL. The examples use Spring Boot 3.5 and the Testcontainers 1.x version it manages. You'll need Docker (or a compatible runtime) available wherever the tests run.

Dependencies

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
<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>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>kafka</artifactId>
    <scope>test</scope>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Boot's dependency management covers all of these, plus Awaitility (pulled in by spring-boot-starter-test), which we'll use for asynchronous assertions.

The code under test

An event, an entity, a repository and a listener:

public record OrderPlaced(String orderId, String customerId, BigDecimal total, Instant placedAt) {}
Enter fullscreen mode Exit fullscreen mode
@Entity
@Table(name = "orders")
public class Order {

    @Id
    private String id;
    private String customerId;
    private BigDecimal total;
    private Instant placedAt;

    protected Order() {}

    public Order(String id, String customerId, BigDecimal total, Instant placedAt) {
        this.id = id;
        this.customerId = customerId;
        this.total = total;
        this.placedAt = placedAt;
    }

    public String getId() { return id; }
    public String getCustomerId() { return customerId; }
    public BigDecimal getTotal() { return total; }
    public Instant getPlacedAt() { return placedAt; }
}
Enter fullscreen mode Exit fullscreen mode
public interface OrderRepository extends JpaRepository<Order, String> {

    List<Order> findByCustomerIdOrderByPlacedAtDesc(String customerId);
}
Enter fullscreen mode Exit fullscreen mode
@Component
public class OrderPlacedListener {

    private final OrderRepository orders;

    public OrderPlacedListener(OrderRepository orders) {
        this.orders = orders;
    }

    @KafkaListener(topics = "orders.placed", groupId = "order-projection")
    public void on(OrderPlaced event) {
        if (orders.existsById(event.orderId())) {
            return; // at-least-once delivery: ignore duplicates
        }
        orders.save(new Order(event.orderId(), event.customerId(), event.total(), event.placedAt()));
    }
}
Enter fullscreen mode Exit fullscreen mode

The Kafka settings in application.yml are the usual JSON serialiser and deserialiser, with auto-offset-reset: earliest so the listener sees messages sent before its partitions were assigned. Notice there's no bootstrap-servers or datasource URL in the test setup below; that's what @ServiceConnection replaces.

Option 1: containers in the test class

The most direct approach declares containers as static fields:

@SpringBootTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@Testcontainers
class OrderPlacedListenerIT {

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

    @Container
    @ServiceConnection
    static KafkaContainer kafka = new KafkaContainer("apache/kafka:3.8.0");

    @Autowired
    KafkaTemplate<String, OrderPlaced> kafkaTemplate;

    @Autowired
    OrderRepository orders;

    @Test
    void storesOrderWhenEventArrives() {
        var event = new OrderPlaced("ord-1", "cust-42", new BigDecimal("19.99"),
                Instant.parse("2026-01-15T10:00:00Z"));

        kafkaTemplate.send("orders.placed", event.orderId(), event);

        await().atMost(Duration.ofSeconds(15)).untilAsserted(() ->
                assertThat(orders.findById("ord-1"))
                        .hasValueSatisfying(order -> {
                            assertThat(order.getCustomerId()).isEqualTo("cust-42");
                            assertThat(order.getTotal()).isEqualByComparingTo("19.99");
                        }));
    }
}
Enter fullscreen mode Exit fullscreen mode

KafkaContainer here is org.testcontainers.kafka.KafkaContainer, which runs the official apache/kafka image in KRaft mode (no ZooKeeper). Spring Boot 3.4 and later support it with @ServiceConnection; on older Boot versions, use the Confluent-based org.testcontainers.containers.KafkaContainer instead.

How the pieces fit:

  • @Testcontainers and @Container hand lifecycle to the JUnit 5 extension. Static fields mean the containers start once for the class, not once per test.
  • @ServiceConnection tells Boot to derive spring.datasource.* and spring.kafka.bootstrap-servers from the running containers. Previously you'd write a @DynamicPropertySource method for this; that still works, and you'll need it for containers Boot doesn't recognise.
  • Awaitility handles the asynchrony. The listener runs on a different thread, so asserting immediately after send would be flaky. untilAsserted retries the AssertJ block until it passes or the timeout expires. Avoid Thread.sleep; it's both slower and less reliable.

ddl-auto=create-drop keeps the example short. If you use Flyway or Liquibase, drop that property and let migrations run against the container. Testing your real migrations on real PostgreSQL is one of the best reasons to use Testcontainers at all.

Option 2: a shared test configuration

Option 1 starts fresh containers for every test class. Once you have a dozen integration test classes, that adds up. The approach Spring Initializr generates is to declare containers as beans:

@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {

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

    @Bean
    @ServiceConnection
    KafkaContainer kafkaContainer() {
        return new KafkaContainer(DockerImageName.parse("apache/kafka:3.8.0"));
    }
}
Enter fullscreen mode Exit fullscreen mode

Test classes then import it:

@SpringBootTest(properties = "spring.jpa.hibernate.ddl-auto=create-drop")
@Import(TestcontainersConfiguration.class)
class OrderQueriesIT {

    @Autowired
    OrderRepository orders;

    @Test
    void returnsCustomerOrdersNewestFirst() {
        orders.save(new Order("q-1", "cust-7", new BigDecimal("5.00"), Instant.parse("2026-01-01T09:00:00Z")));
        orders.save(new Order("q-2", "cust-7", new BigDecimal("8.00"), Instant.parse("2026-01-02T09:00:00Z")));

        assertThat(orders.findByCustomerIdOrderByPlacedAtDesc("cust-7"))
                .extracting(Order::getId)
                .containsExactly("q-2", "q-1");
    }
}
Enter fullscreen mode Exit fullscreen mode

Spring starts the containers with the application context and stops them when the context closes. Because Spring's test framework caches contexts, every class with the same configuration reuses the same context and the same containers.

The trade-off is that shared containers mean shared state. Tests must not depend on an empty database or topic. The habits that keep this manageable:

  • Use unique IDs per test (as above: q-1, ord-1, or random UUIDs) and assert on those, not on table counts.
  • Clean up explicitly where needed, for example with @Sql scripts or a @BeforeEach that deletes rows the test owns.
  • Keep context configuration identical across classes. A stray @MockitoBean or different properties creates a new context, and with it new containers.

Bonus: the same containers for local development

The shared configuration can also run the application locally against throwaway containers. Add a main class under src/test/java:

public class TestOrderServiceApplication {

    public static void main(String[] args) {
        SpringApplication.from(OrderServiceApplication::main)
                .with(TestcontainersConfiguration.class)
                .run(args);
    }
}
Enter fullscreen mode Exit fullscreen mode

Run it from your IDE (or ./mvnw spring-boot:test-run) and you get PostgreSQL and Kafka without a separate docker-compose.yml to keep in sync.

Things to watch

  • Pin image versions. postgres:latest will change under you. Match the major version you run in production.
  • CI needs Docker. Most hosted runners have it. Some locked-down environments don't, so check before you commit to this approach.
  • Start-up time is real. Expect several seconds for Kafka and PostgreSQL to start. Shared containers keep that a one-off cost per run rather than per class.
  • Keep unit tests as unit tests. Integration tests are slower and should cover wiring, SQL, serialisation and configuration, not every branch of your business logic.

Summary

  • @ServiceConnection removes the property plumbing: declare a container and Boot connects to it.
  • Use Awaitility for anything asynchronous, such as Kafka listeners.
  • Move containers into a shared @TestConfiguration once you have more than a couple of test classes, and write tests that tolerate shared state.
  • Test against the same database engine and migrations you run in production.

Top comments (0)