Spring Boot Testcontainers: Integration Testing Guide
Spring Boot Testcontainers has transformed how developers write integration tests in modern Java applications. Therefore, instead of relying on mocked dependencies or shared test databases, you can now spin up real services in Docker containers during your test lifecycle. This approach catches bugs that unit tests simply cannot detect, because the database, broker, or cache your code talks to in the test is the same engine you run in production.
Why Integration Testing Needs Real Dependencies
Unit tests verify individual methods in isolation, but they miss issues at system boundaries. Moreover, mocking a database driver hides SQL dialect differences and constraint violations. As a result, many teams discover integration bugs only in staging or production environments, where they are far more expensive to diagnose and fix.
Furthermore, in-memory databases like H2 behave differently from PostgreSQL or MySQL in production. Consequently, tests that pass locally may fail when deployed against the actual database engine. For instance, H2 silently accepts certain syntax that PostgreSQL rejects, and it lacks faithful support for JSONB columns, array types, partial indexes, and advisory locks. Therefore, a green H2 test suite can give false confidence right up to the moment a migration hits the real database.
Modern Java development environment configured for integration testing
Setting Up the Library in Your Project
Getting started requires adding the Testcontainers dependencies to your Maven or Gradle build. Additionally, Spring Boot 3.1+ provides first-class support through the spring-boot-testcontainers module, which is what makes the wiring below so concise:
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private OrderRepository orderRepository;
@Test
void shouldPersistOrderWithItems() {
Order order = new Order("customer-1", List.of(
new OrderItem("SKU-100", 2, 29.99)
));
Order saved = orderRepository.save(order);
assertThat(saved.getId()).isNotNull();
assertThat(saved.getItems()).hasSize(1);
}
}
The @ServiceConnection annotation automatically configures the datasource URL, username, and password. Therefore, you no longer need manual property overrides in your test configuration. Under the hood it registers a ConnectionDetails bean that resolves the container’s mapped host and randomized port at runtime, which is why nothing in your application.yml needs to know about the container.
How the Container Lifecycle Actually Works
Understanding the lifecycle prevents the most common mistakes. The @Testcontainers extension scans for fields annotated with @Container; a static field starts once before all tests in the class and stops after the last one, while an instance field starts and stops around every single test method.
That distinction has real cost implications. A fresh PostgreSQL container typically takes a couple of seconds to become ready, so an instance-scoped container in a class with twenty tests pays that startup forty times. In contrast, a static container pays it once. Therefore, prefer static containers and write tests that clean up their own data, rather than relying on a fresh database per method.
Behind the scenes, the library also launches a tiny companion container called Ryuk. Specifically, Ryuk watches the test JVM and forcibly removes containers, networks, and volumes if the process dies unexpectedly. As a result, a crashed or killed test run does not leak Docker resources across your machine or a CI agent.
Testing with Multiple Containers
Real applications depend on more than just a database. Specifically, you might need Redis for caching, Kafka for messaging, or Elasticsearch for search. The library supports all of these simultaneously, and each has a dedicated module with sensible defaults so you describe intent rather than wiring ports by hand.
@SpringBootTest
@Testcontainers
class CheckoutFlowIntegrationTest {
@Container @ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Container @ServiceConnection
static GenericContainer<?> redis =
new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
@Container @ServiceConnection
static KafkaContainer kafka =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0"));
@Test
void shouldPublishEventAfterCheckout() {
// order persisted in Postgres, session cached in Redis,
// OrderPlaced event produced to Kafka — all real services
}
}
However, running multiple containers increases test startup time. In contrast, using @Container with static fields ensures containers are shared across all test methods in the class. As a result, the overhead is paid only once per test class, and a checkout flow exercising three real backends still starts in a handful of seconds.
Docker containers providing isolated test environments for each service
Performance Optimization Strategies
Slow integration tests discourage developers from running them frequently. Therefore, consider using reuse mode, which keeps containers alive between test runs on a developer’s machine. Additionally, sharing a single container across many test classes — the singleton pattern — avoids repeated startup across the whole suite.
For example, enabling reuse is as simple as adding .withReuse(true) to your container definition and opting in via testcontainers.reuse.enable=true in ~/.testcontainers.properties. Meanwhile, a singleton container declared in an abstract base class starts on first access and is never stopped, so every subclass test reuses the same instance:
abstract class AbstractIntegrationTest {
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine").withReuse(true);
static {
POSTGRES.start(); // started once for the whole JVM, never stopped
}
@DynamicPropertySource
static void props(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
}
}
One subtlety: because a reused or singleton container survives across tests and classes, it accumulates state. Consequently, you must reset data deliberately — truncate tables, flush Redis, or wrap each test in a rolled-back transaction — otherwise tests start passing or failing based on execution order, which is the worst kind of flakiness to debug.
CI/CD Pipeline Configuration
Running these tests in CI requires Docker-in-Docker or a mounted Docker socket. Furthermore, GitHub Actions and GitLab CI both support this out of the box with their standard runners. Specifically, configure resource limits to prevent container sprawl in shared CI environments, and pin exact image tags so a moving latest tag never changes test behavior between runs.
Reuse mode is intentionally a no-op in most CI setups, since each pipeline run gets a clean agent and there is nothing to reuse. Therefore, the bigger CI wins come from caching Docker image layers and constraining parallelism so you do not exhaust the agent’s memory. A common pitfall is image pull rate limits; pulling shared images through an internal registry mirror avoids throttling on busy days.
Automated pipeline executing containerized integration tests
When Not to Use This Approach
This is not a free lunch, and it is worth being honest about the trade-offs. Every developer and CI agent needs a working Docker daemon, which complicates locked-down corporate laptops and some hosted runners. Moreover, container startup adds real seconds to feedback loops, so testing pure business logic that has no external dependency is still better served by fast plain unit tests.
In other words, reserve this technique for the boundaries that genuinely matter: persistence, messaging, caching, and search. Pushing every test through a container inflates suite time without adding coverage where the risk does not live. A balanced suite keeps a broad base of unit tests and a focused layer of containerized integration tests around the edges where dialect, serialization, and protocol bugs actually hide.
Related Reading:
Further Resources:
In conclusion, Spring Boot Testcontainers enables reliable integration testing by running real dependencies in disposable Docker containers. Therefore, adopt this approach for the system boundaries that matter, lean on static and reused containers to keep it fast, and ship with greater confidence that what passed in your tests will hold in production.