Java Testcontainers Integration Tests
How to use Testcontainers in JUnit5 to spin up real Postgres, Redis, and Kafka containers for integration tests that are reliable and reproducible.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
Testcontainers spins up real Docker containers (Postgres, Redis, Kafka, etc.) during your test lifecycle. Instead of mocking database behavior, you test against the real thing — same SQL dialect, same connection pooling, same edge cases. Containers are created before tests run and destroyed after, leaving no residue.
When to Use
- Integration tests that need a real database (Postgres, MySQL, MongoDB)
- Testing code that uses database-specific features (JSONB, arrays, full-text search)
- Verifying migration scripts against a real schema
- Testing message consumers with a real Kafka or RabbitMQ broker
- Testing against a real Redis for cache invalidation logic
When NOT to Use
- Unit tests — mocks are faster and sufficient for logic-only tests
- CI environments without Docker — Testcontainers requires a Docker daemon
- Tests that run in seconds — container startup adds 5-15 seconds per container
- Testing HTTP clients — use WireMock instead (lighter and faster)
Solution
Setup with Maven
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers</artifactId>
<version>1.20.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<version>1.20.0</version>
<scope>test</scope>
</dependency>
Setup with Gradle
testImplementation 'org.testcontainers:testcontainers:1.20.0'
testImplementation 'org.testcontainers:junit-jupiter:1.20.0'
testImplementation 'org.testcontainers:postgresql:1.20.0'
Postgres container with JUnit5
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.Statement;
@Testcontainers
class PostgresIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
@Test
void shouldCreateAndQueryTable() throws Exception {
try (Connection conn = DriverManager.getConnection(
postgres.getJdbcUrl(),
postgres.getUsername(),
postgres.getPassword())) {
try (Statement stmt = conn.createStatement()) {
stmt.execute("CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT UNIQUE)");
stmt.execute("INSERT INTO users (email) VALUES ('alice@example.com')");
ResultSet rs = stmt.executeQuery("SELECT * FROM users");
rs.next();
assert rs.getString("email").equals("alice@example.com");
}
}
}
}
Redis container
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import redis.clients.jedis.Jedis;
@Testcontainers
class RedisIntegrationTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
@Test
void shouldSetAndGetValue() {
String host = redis.getHost();
Integer port = redis.getMappedPort(6379);
try (Jedis jedis = new Jedis(host, port)) {
jedis.set("test-key", "test-value");
assert jedis.get("test-key").equals("test-value");
}
}
}
Kafka container
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.KafkaContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.apache.kafka.clients.consumer.Consumer;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.Producer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import java.time.Duration;
import java.util.Collections;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicReference;
@Testcontainers
class KafkaIntegrationTest {
@Container
static KafkaContainer kafka = new KafkaContainer("confluentinc/cp-kafka:7.6.0");
@Test
void shouldProduceAndConsume() {
Properties producerProps = new Properties();
producerProps.put("bootstrap.servers", kafka.getBootstrapServers());
producerProps.put("key.serializer", StringSerializer.class.getName());
producerProps.put("value.serializer", StringSerializer.class.getName());
try (Producer<String, String> producer = new KafkaProducer<>(producerProps)) {
producer.send(new ProducerRecord<>("test-topic", "key", "hello-kafka"));
}
Properties consumerProps = new Properties();
consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers());
consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "test-group");
consumerProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
try (Consumer<String, String> consumer = new KafkaConsumer<>(consumerProps)) {
consumer.subscribe(Collections.singletonList("test-topic"));
ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(10));
assert records.iterator().hasNext();
assert records.iterator().next().value().equals("hello-kafka");
}
}
}
Shared container pattern for faster tests
public abstract class BaseIntegrationTest {
static final PostgreSQLContainer<?> SHARED_POSTGRES;
static {
SHARED_POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
.withDatabaseName("testdb")
.withReuse(true);
SHARED_POSTGRES.start();
}
protected String getJdbcUrl() {
return SHARED_POSTGRES.getJdbcUrl();
}
}
Using @DynamicPropertySource with Spring Boot
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
@Testcontainers
@SpringBootTest
class UserRepositoryIntegrationTest extends BaseIntegrationTest {
@DynamicPropertySource
static void configureProperties(DynamicPropertySource registry) {
registry.add("spring.datasource.url", SHARED_POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", SHARED_POSTGRES::getUsername);
registry.add("spring.datasource.password", SHARED_POSTGRES::getPassword);
}
@Autowired
private UserRepository userRepository;
@Test
void shouldSaveAndFindUser() {
User user = new User("alice@example.com", "Alice");
userRepository.save(user);
User found = userRepository.findByEmail("alice@example.com");
assertThat(found).isNotNull();
assertThat(found.getName()).isEqualTo("Alice");
}
}
Variants
Using ComposeContainer for multi-service setups
@Container
static DockerComposeContainer<?> compose = new DockerComposeContainer<>(
new File("docker-compose-test.yml")
).withExposedService("postgres", 5432)
.withExposedService("redis", 6379);
Using Network for container-to-container communication
Network network = Network.newNetwork();
PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withNetwork(network)
.withNetworkAliases("db");
GenericContainer<?> app = new GenericContainer<>("myapp:latest")
.withNetwork(network)
.withEnv("DB_HOST", "db")
.dependsOn(postgres);
Best Practices
-
For a deeper guide, see JUnit5 Soft Assertions with AssertJ.
-
Use
withReuse(true)and settestcontainers.reuse.enable=truein.testcontainers.propertiesto keep containers between test runs -
Use
staticcontainers with@Container— they start once per test class, not per test -
Use
@DynamicPropertySourcewith Spring Boot to inject container URLs into application config -
Set container startup timeout:
withStartupTimeout(Duration.ofMinutes(2))for slow images -
Use
withInitScript("init.sql")to run schema setup when the container starts -
Pull images before CI:
docker pull postgres:16-alpineto avoid timeout on first run
Common Mistakes
- Not using
staticcontainers: non-static containers start/stop per test method, adding 10-30 seconds per test. - Forgetting Docker requirement: Testcontainers needs Docker running. In CI, use a Docker-in-Docker setup or Docker socket binding.
- Using latest tags:
postgres:latestcan change between runs, breaking tests. Pin to a specific version. - Not cleaning up data between tests: use
@Transactional+@Rollbackor truncate tables in@BeforeEach. - Ignoring container startup time: if a test takes 15 seconds, 12 of which are container startup, consider shared containers or reuse mode.
FAQ
How do I speed up Testcontainers in CI?
Use withReuse(true) to keep containers alive between test runs. In CI, pre-pull images in a setup step. Use shared static containers instead of per-test containers.
Can I run Testcontainers without Docker Desktop?
Yes. On Linux, use the Docker daemon directly. On macOS/Windows, use Colima or Rancher Desktop as a lightweight Docker runtime.
How do I run schema migrations inside a container?
Use withInitScript("init.sql") for simple schemas. For Flyway or Liquibase, start the container, get the JDBC URL, and run migrations programmatically in @BeforeAll.
Can I use Testcontainers with Kotlin?
Yes. The API is the same. Use companion object for static containers:
companion object {
@Container
@JvmStatic
val postgres = PostgreSQLContainer("postgres:16-alpine")
}
How do I debug a container that won’t start?
Use withLogTo(new Slf4jLogConsumer(logger)) to see container logs. Check Docker disk space, image availability, and port conflicts.
How do I speed up test execution with Testcontainers?
Use @Testcontainers with container reuse: set testcontainers.reuse.enable=true in .testcontainers.properties. This keeps containers alive between test runs instead of destroying and recreating them. Also use withReuse(true) on container definitions.
Can I use Testcontainers with Spring Boot?
Yes. Add the testcontainers Spring Boot starter dependency. Use @ServiceConnection on container fields to auto-configure Spring Data sources:
@SpringBootTest
@Testcontainers
class MyIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
} Related Resources
JUnit5 Soft Assertions with AssertJ
How to use AssertJ soft assertions in JUnit5 to collect multiple assertion failures in a single test instead of stopping at the first failure.
RecipeDesign Effective Integration Tests for Reliable Systems
How to write integration tests that verify component interactions using test containers, API contracts, consumer-driven contracts, and contract testing in Java, TypeScript, and Python.
RecipeStub External HTTP Services with WireMock
How to use WireMock in Java tests to stub external HTTP services, including response templating, delay simulation, and stateful mock behavior.
GuideJUnit 5: Extensions, Parameterized Tests, Dynamic Tests
Master JUnit 5 for modern Java testing: extensions model, parameterized tests, dynamic tests, test interfaces, lifecycle, conditional execution, and JUnit Platform integration.