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>
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) {}
@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; }
}
public interface OrderRepository extends JpaRepository<Order, String> {
List<Order> findByCustomerIdOrderByPlacedAtDesc(String customerId);
}
@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()));
}
}
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");
}));
}
}
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:
-
@Testcontainersand@Containerhand lifecycle to the JUnit 5 extension. Static fields mean the containers start once for the class, not once per test. -
@ServiceConnectiontells Boot to derivespring.datasource.*andspring.kafka.bootstrap-serversfrom the running containers. Previously you'd write a@DynamicPropertySourcemethod 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
sendwould be flaky.untilAssertedretries the AssertJ block until it passes or the timeout expires. AvoidThread.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"));
}
}
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");
}
}
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
@Sqlscripts or a@BeforeEachthat deletes rows the test owns. -
Keep context configuration identical across classes. A stray
@MockitoBeanor differentpropertiescreates 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);
}
}
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:latestwill 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
-
@ServiceConnectionremoves 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
@TestConfigurationonce 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)