Skip to main content

Testcontainers — Practical Demo

Hands-on examples for Testcontainers. We progressively add real PostgreSQL, Redis, and Kafka containers to a Spring Boot test suite.

Prerequisites

Docker must be running on your machine. Understand Integration Tests before this demo.


Dependencies

pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</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>
<!-- Redis module (generic container is sufficient for basic Redis) -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<scope>test</scope>
</dependency>

Example 1: PostgreSQL with @ServiceConnection (Spring Boot 3.1+)

The cleanest, most modern approach:

OrderPostgresTest.java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers // highlighted: activates container lifecycle
class OrderPostgresTest {

@Container
@ServiceConnection // highlighted: auto-registers JDBC URL in Spring props
static PostgreSQLContainer<?> postgres = // highlighted: static = shared, not per-test
new PostgreSQLContainer<>("postgres:16");

@Autowired
TestRestTemplate restTemplate;

@Test
void placeOrder_persistsToPostgres() {
ResponseEntity<Order> response = restTemplate.postForEntity(
"/orders", new OrderRequest("laptop", 999.0), Order.class);

assertEquals(HttpStatus.CREATED, response.getStatusCode());
assertNotNull(response.getBody().getId());
}

@Test
void getOrder_returnsPersistedData() {
// First create
ResponseEntity<Order> created = restTemplate.postForEntity(
"/orders", new OrderRequest("book", 15.0), Order.class);
Long id = created.getBody().getId();

// Then retrieve — data survives because we're NOT @Transactional here
ResponseEntity<Order> fetched =
restTemplate.getForEntity("/orders/" + id, Order.class);

assertEquals("book", fetched.getBody().getItemName());
}
}

What to observe:

  • @ServiceConnection reads PostgreSQLContainer's JDBC URL, username, and password and registers them as Spring spring.datasource.* properties — zero boilerplate.
  • static container starts once before the first test and stops after the last — not per-method.
  • Each test method's HTTP request commits its transaction independently (no rollback).

Example 2: PostgreSQL the Pre-3.1 Way (@DynamicPropertySource)

For Spring Boot < 3.1, or to understand how @ServiceConnection works under the hood:

OrderPostgresLegacyTest.java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderPostgresLegacyTest {

@Container
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16")
.withDatabaseName("orders_test");

@DynamicPropertySource // highlighted: bridge from container → Spring props
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl); // highlighted
registry.add("spring.datasource.username", postgres::getUsername);
registry.add("spring.datasource.password", postgres::getPassword);
}

// Tests are identical to Example 1
}

Example 3: Redis Container for Cache Tests

OrderCacheTest.java
@SpringBootTest
@Testcontainers
class OrderCacheTest {

@Container
@ServiceConnection // highlighted: works for Redis too
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379); // highlighted: expose port for Spring to connect to

@Autowired
OrderService orderService;

@Autowired
CacheManager cacheManager;

@Test
void findOrder_cachesMissOnFirstCall() {
// Populate DB
Order order = orderRepository.save(new Order(null, "laptop", 999.0, OrderStatus.PENDING));

// First call — cache miss, hits DB
Order result1 = orderService.findOrder(order.getId());

// Cache should now contain the result
Cache.ValueWrapper cached = cacheManager.getCache("orders")
.get(order.getId());
assertNotNull(cached);
assertEquals("laptop", ((Order) cached.get()).getItemName());
}

@Test
void findOrder_returnsCachedValue_onSecondCall() {
Order order = orderRepository.save(new Order(null, "phone", 499.0, OrderStatus.PENDING));
orderService.findOrder(order.getId()); // first call — populates cache

// Delete from DB — second call should still return cached value
orderRepository.deleteById(order.getId());
Order cached = orderService.findOrder(order.getId());

assertEquals("phone", cached.getItemName()); // from cache, not DB
}
}

Example 4: Kafka Container for Event Tests

OrderEventTest.java
@SpringBootTest
@Testcontainers
class OrderEventTest {

@Container
@ServiceConnection // highlighted: registers bootstrap servers
static KafkaContainer kafka =
new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.6.1")); // highlighted: pin version

@Autowired
OrderEventPublisher eventPublisher;

@Value("${app.kafka.topics.orders}")
String ordersTopic;

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

// Set up a test consumer
Consumer<String, String> consumer = createTestConsumer();
consumer.subscribe(List.of(ordersTopic));

// Publish the event
Order order = new Order(1L, "laptop", 999.0, OrderStatus.PENDING);
eventPublisher.publishOrderCreated(order); // highlighted: the action under test

// Poll in a background thread and count down when message arrives
Executors.newSingleThreadExecutor().submit(() -> {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(10));
records.forEach(r -> received.add(r.value()));
latch.countDown();
});

assertTrue(latch.await(15, TimeUnit.SECONDS));
assertEquals(1, received.size());
assertTrue(received.get(0).contains("laptop"));
}
}

Example 5: Shared Base Class for All Integration Tests

One place to define all containers; all test classes reuse the same started instances:

AbstractIntegrationTest.java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@Testcontainers
public abstract class AbstractIntegrationTest {

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

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

// All subclasses share these containers — started ONCE for the entire suite
}
OrderTest.java
class OrderTest extends AbstractIntegrationTest {

@Autowired TestRestTemplate restTemplate;

@Test
void basicFlow() { /* uses postgres and redis from base class */ }
}
UserTest.java
class UserTest extends AbstractIntegrationTest {

@Autowired TestRestTemplate restTemplate;

@Test
void userFlow() { /* same containers, same Spring context */ }
}

Result: PostgreSQL and Redis start once for the entire test run, not once per class.


Container Startup Observation

Watch the Docker logs or your test output for these lines confirming containers started:

org.testcontainers.DockerClientFactory - Docker host: unix:///var/run/docker.sock
org.testcontainers.DockerClientFactory - Docker version: ...
🐳 [postgres:16] - Starting...
🐳 [postgres:16] - Container postgres:16 started in PT3.456S

First run: image pull + start (~15–30s). Subsequent runs: start only (~3–5s, image cached.


What You've Practiced

ConceptExample
PostgreSQL with @ServiceConnectionExample 1
PostgreSQL with @DynamicPropertySource (pre-3.1)Example 2
Redis container for cache testsExample 3
Kafka container for event testsExample 4
Shared base class for suite-wide containersExample 5

Challenge: Add a Flyway migration that creates the orders table. Write a Testcontainers test that verifies: (1) the migration runs on startup, (2) the table exists, and (3) a row can be inserted and retrieved.