1. Introduction
Testing code that depends on databases and external services is difficult. Many teams rely on shared test environments or in-memory databases like H2, but both approaches are error-prone. Shared environments break due to data conflicts or configuration changes, while in-memory databases often behave differently from production systems.
Testcontainers solves this by running real dependencies inside Docker containers during tests. Each test run gets its own isolated, disposable environment that closely matches production. This is especially valuable for database integration testing, where using the actual database engine leads to more reliable tests and fewer production surprises.
2. Docker
Let’s start with a quick refresher on Docker containers. This article assumes you already have some familiarity with Docker and its core components. That said, we’ll briefly revisit the high-level concepts to align terminology and build a shared mental model before moving forward.
Docker file
A Dockerfile defines the steps required to build a Docker image. For programmers, a useful way to think about a Dockerfile is as a class definition. It acts as a template from which Docker Engine creates a Docker image, and that image can then be used to create multiple container instances, similar to creating objects from a class. Docker Engine processes the Dockerfile and builds a Docker image, which can be executed anywhere as long as the host system uses a compatible operating system kernel.
A Dockerfile allows you to:
- Choose a base image
- Install system packages and dependencies
- Copy application files
- Set environment variables and configuration
- Expose ports
- Define entry points
Docker Image
A Docker image is a packaged snapshot of an application and everything it needs to run, including required libraries, dependencies, and a minimal base filesystem.
Docker Engine creates the image by processing a Dockerfile. Once built, the image is immutable, meaning it never changes, and all containers created from it behave consistently.
Docker images are not heavyweight because they do not include a full operating system or kernel. Instead, they use a minimal filesystem and a layered design, where layers are shared and reused across images to keep them small and efficient.
Docker Container
A Docker container is a running instance of a Docker image, created and started by Docker Engine.
Containers provide an isolated environment for applications. Each container has its own filesystem, process space, network interfaces, and environment variables. Containers are isolated from one another, so processes in one container cannot see or interfere with processes in another.
At the same time, containers share the host operating system kernel. This shared kernel is what makes containers lightweight compared to virtual machines.
Containers are mutable at runtime, but any changes made inside a container are discarded when the container is removed unless explicitly persisted using volumes.
3. Testcontainers
How Testcontainers Works
At a high level, Testcontainers starts Docker containers as part of your test execution.
When a test begins, Testcontainers pulls the required Docker image (if it’s not already available), starts a container, and waits until the service inside the container is ready to accept connections. Your test code then connects to this container using dynamically provided hostnames, ports, and credentials.
Once the tests finish, Testcontainers automatically stops and removes the containers. This ensures each test run starts from a clean, isolated state with no leftover data or configuration.
All of this happens programmatically inside your test framework, so developers do not need to manually start databases or manage shared environments. The same tests can be run locally and in CI with consistent behavior, as long as Docker is available.
Here are some helpful Docker commands when working with Testcontainers:
# Verify Docker is running
docker info
# Pull an image manually (example: MySQL)
docker pull mysql:8.0
# List downloaded MySQL images
docker images | grep mysql
MySQL Integration Test
Gradle dependencies:
dependencies {
testImplementation "org.junit.jupiter:junit-jupiter:5.10.1"
// Testcontainers core + JUnit 5 support
testImplementation "org.testcontainers:testcontainers:1.19.7"
testImplementation "org.testcontainers:junit-jupiter:1.19.7"
// MySQL Testcontainer
testImplementation "org.testcontainers:mysql:1.19.7"
// MySQL JDBC driver
testImplementation "mysql:mysql-connector-j:8.3.0"
}
test {
useJUnitPlatform()
}
Integration test
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.MySQLContainer;
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;
import static org.junit.jupiter.api.Assertions.assertEquals;
@Testcontainers
class MySqlIT {
@Container
static final MySQLContainer<?> mysql =
new MySQLContainer<>("mysql:8.0")
.withDatabaseName("test_db")
.withUsername("test")
.withPassword("test");
@Test
void canInsertAndQuery() throws Exception {
try (Connection conn =
DriverManager.getConnection(mysql.getJdbcUrl(), mysql.getUsername(), mysql.getPassword());
Statement stmt = conn.createStatement()) {
stmt.execute("CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(100))");
stmt.execute("INSERT INTO users (id, name) VALUES (1, 'FK')");
try (ResultSet rs = stmt.executeQuery("SELECT name FROM users WHERE id = 1")) {
rs.next();
assertEquals("FK", rs.getString(1));
}
}
}
}
Kafka Integration Test
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.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
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.testcontainers.utility.DockerImageName;
import java.time.Duration;
import java.util.Collections;
import java.util.Properties;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Testcontainers
class KafkaIT {
@Container
static final KafkaContainer kafka =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.5.3"));
@Test
void canProduceAndConsume() {
String topic = "events";
// Producer
Properties producerProps = new Properties();
producerProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers());
producerProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
producerProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
try (KafkaProducer<String, String> producer = new KafkaProducer<>(producerProps)) {
producer.send(new ProducerRecord<>(topic, "k1", "hello-from-testcontainers"));
producer.flush();
}
// Consumer
Properties consumerProps = new Properties();
consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, kafka.getBootstrapServers());
consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "it-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 (KafkaConsumer<String, String> consumer = new KafkaConsumer<>(consumerProps)) {
consumer.subscribe(Collections.singletonList(topic));
boolean found = false;
long deadlineMs = System.currentTimeMillis() + 5_000;
while (!found && System.currentTimeMillis() < deadlineMs) {
ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(250));
found = records.records(topic).stream()
.anyMatch(r -> "hello-from-testcontainers".equals(r.value()));
}
assertTrue(found, "Expected message was not consumed within timeout");
}
}
}
Top comments (0)