Testcontainers in Spring Boot: Real Database Integration Tests Without Mocks
In-memory databases and hand-rolled mocks give you fast tests, but they lie to you. H2 silently ignores PostgreSQL-specific SQL, and your mock UserRepository never tests the JPA query you spent an hour debugging. Testcontainers solves this by spinning up real Docker containers — PostgreSQL, Redis, Kafka, Elasticsearch — for the duration of your test suite and tearing them down afterward. This guide walks through the full Testcontainers setup for Spring Boot 3.x, from dependency configuration to parallel test execution in GitHub Actions CI.
Table of Contents
- Why Testcontainers Over H2 and Mocks
- Project Setup: Dependencies and Docker Requirements
- PostgreSQL Integration Tests with @DataJpaTest
- Singleton Container Pattern: Reusing Containers Across Tests
- Kafka Integration Testing with EmbeddedKafka vs Testcontainers
- Redis and Multi-Container Compose Tests
- Spring Boot 3.1+ ServiceConnection and @ImportTestcontainers
- Running Testcontainers in GitHub Actions CI/CD
- Key Takeaways
1. Why Testcontainers Over H2 and Mocks
H2 covers basic CRUD but falls apart the moment you use a PostgreSQL-specific feature: JSONB columns, pg_trgm full-text indexes, advisory locks, or RETURNING clauses. Many teams only discover the mismatch during a production incident — not in CI. Similarly, mocking a JpaRepository method tests your test setup, not your query. A @Query annotation with a typo in the JPQL compiles fine and only fails at runtime.
Testcontainers wraps the Docker daemon via the Docker Java client. Each container defined in your test class starts before the test and stops after, leaving no state between runs. Since Spring Boot 3.1, the @ServiceConnection annotation auto-configures the DataSource, RedisConnectionFactory, or Kafka properties directly from the container — no manual property overrides required.
2. Project Setup: Dependencies and Docker Requirements
You need Docker running on the machine where tests execute — either your local workstation or the CI agent. Testcontainers communicates with Docker via the Unix socket or TCP, pulling images on demand and caching them locally. The Spring Boot parent BOM (3.1+) includes managed versions for all Testcontainers modules.
<!-- pom.xml -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-bom</artifactId>
<version>1.19.8</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Core Testcontainers -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<!-- PostgreSQL module -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<!-- Kafka module -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>kafka</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Boot test integration -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
</dependencies>3. PostgreSQL Integration Tests with @DataJpaTest
The @DataJpaTest slice loads only the JPA layer — entities, repositories, and data source configuration. By default it configures an in-memory H2 database. To replace H2 with a real PostgreSQL container, annotate the test class with @AutoConfigureTestDatabase(replace = NONE) and declare a PostgreSQLContainer bean with @ServiceConnection.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class OrderRepositoryTest {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>("postgres:16-alpine");
@Autowired
private OrderRepository orderRepository;
@Test
void shouldPersistAndRetrieveOrderWithJsonbMetadata() {
Order order = new Order();
order.setStatus("PENDING");
order.setMetadata("{\"source\": \"web\", \"promoCode\": \"SAVE10\"}"); // JSONB
Order saved = orderRepository.save(order);
Optional<Order> found = orderRepository.findById(saved.getId());
assertThat(found).isPresent();
assertThat(found.get().getMetadata()).contains("promoCode");
}
@Test
void shouldFindOrdersByStatusUsingCustomQuery() {
orderRepository.saveAll(List.of(
new Order("PENDING", null),
new Order("SHIPPED", null),
new Order("PENDING", null)
));
List<Order> pending = orderRepository.findByStatus("PENDING");
assertThat(pending).hasSize(2);
}
}@ServiceConnection replaces the manual @DynamicPropertySource approach. It reads the container's mapped host/port and configures the Spring DataSource automatically. Requires Spring Boot 3.1+.
4. Singleton Container Pattern: Reusing Containers Across Tests
If every test class starts its own PostgreSQL container, your build spends minutes on container startup. The Singleton Container pattern declares the container as a static field in a shared base class, allowing JVM-scoped reuse across all test classes that extend it. Testcontainers' Ryuk side-container handles cleanup when the JVM exits.
// Shared base class — all integration tests extend this
public abstract class AbstractIntegrationTest {
@Container
@ServiceConnection
static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true); // enable container reuse across runs
static {
POSTGRES.start();
}
}
// Test class — inherits the shared container
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class ProductRepositoryTest extends AbstractIntegrationTest {
@Autowired
ProductRepository productRepository;
@Test
void shouldCountProductsByCategory() {
// Container already running — no startup cost
productRepository.saveAll(List.of(
new Product("TV", "ELECTRONICS"),
new Product("Shirt", "CLOTHING"),
new Product("Phone", "ELECTRONICS")
));
assertThat(productRepository.countByCategory("ELECTRONICS")).isEqualTo(2);
}
}
Enable the container reuse feature by adding testcontainers.reuse.enable=true to ~/.testcontainers.properties on developer machines or setting it as an environment variable in CI. Without this property, withReuse(true) is silently ignored, and the container is still stopped after each test session — which is the safe default.
5. Kafka Integration Testing with EmbeddedKafka vs Testcontainers
Spring's @EmbeddedKafka is fast and requires no Docker, but it runs an older Kafka version and doesn't support all broker configurations. For production-fidelity, use KafkaContainer from Testcontainers, which runs the official Confluent Platform image.
@SpringBootTest
@Testcontainers
class OrderEventPublisherTest {
@Container
@ServiceConnection
static KafkaContainer kafka =
new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.1"));
@Autowired
private OrderEventPublisher publisher;
@Autowired
private KafkaTemplate<String, OrderEvent> kafkaTemplate;
@Test
void shouldPublishOrderCreatedEventToKafka() throws Exception {
CountDownLatch latch = new CountDownLatch(1);
List<OrderEvent> received = new ArrayList<>();
// Consumer listening on the orders topic
kafkaTemplate.receive("orders", 0, 0, Duration.ofSeconds(5));
publisher.publishOrderCreated(new OrderCreatedEvent("order-123", "user-456"));
// Assert message arrived within 10 seconds
boolean messageReceived = latch.await(10, TimeUnit.SECONDS);
assertThat(messageReceived).isTrue();
assertThat(received).extracting(OrderEvent::getOrderId).contains("order-123");
}
}6. Redis and Multi-Container Compose Tests
Testcontainers provides a GenericContainer for Redis. For complex setups with multiple services, the DockerComposeContainer reads an existing docker-compose.yml file, which lets you reuse the same infrastructure definition for both local development and tests.
@SpringBootTest
@Testcontainers
class CacheServiceTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis =
new GenericContainer<>("redis:7.2-alpine")
.withExposedPorts(6379);
@Autowired
private ProductCacheService cacheService;
@Test
void shouldCacheProductLookup() {
cacheService.cacheProduct("prod-1", new Product("Laptop", 1299.00));
Optional<Product> cached = cacheService.getCachedProduct("prod-1");
assertThat(cached).isPresent();
assertThat(cached.get().getName()).isEqualTo("Laptop");
}
@Test
void shouldEvictCacheOnUpdate() {
cacheService.cacheProduct("prod-2", new Product("Mouse", 29.99));
cacheService.evict("prod-2");
Optional<Product> cached = cacheService.getCachedProduct("prod-2");
assertThat(cached).isEmpty();
}
}
// Multi-container with Docker Compose
@Testcontainers
class FullStackIntegrationTest {
@Container
static DockerComposeContainer<?> compose =
new DockerComposeContainer<>(new File("src/test/resources/docker-compose-test.yml"))
.withExposedService("postgres_1", 5432, Wait.forListeningPort())
.withExposedService("redis_1", 6379, Wait.forListeningPort())
.withExposedService("kafka_1", 9092, Wait.forListeningPort());
}7. Spring Boot 3.1+ ServiceConnection and @ImportTestcontainers
Spring Boot 3.1 introduced two major improvements: @ServiceConnection for zero-boilerplate property wiring, and @ImportTestcontainers for sharing container definitions across tests via a dedicated configuration class. Together they eliminate most of the repetitive infrastructure code.
// Centralized container configuration
public class TestContainersConfig {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>("postgres:16-alpine");
}
@Bean
@ServiceConnection
RedisContainer redisContainer() {
return new RedisContainer("redis:7.2-alpine");
}
}
// Test class imports the shared config — no @Container declarations needed
@SpringBootTest
@ImportTestcontainers(TestContainersConfig.class)
class OrderServiceIntegrationTest {
@Autowired
private OrderService orderService;
@Test
void shouldCreateOrderAndPublishEvent() {
OrderRequest req = new OrderRequest("user-1", List.of("sku-A", "sku-B"));
Order order = orderService.createOrder(req);
assertThat(order.getId()).isNotNull();
assertThat(order.getStatus()).isEqualTo("PENDING");
}
}8. Running Testcontainers in GitHub Actions CI/CD
GitHub Actions runners have Docker pre-installed. No special configuration is needed — Testcontainers detects the Docker socket automatically. For speed, use Maven's Surefire fork count to run test classes in parallel, and cache the Docker image layer in the Actions cache.
# .github/workflows/ci.yml
name: CI — Integration Tests
on: [push, pull_request]
jobs:
integration-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Pull Docker images in parallel
run: |
docker pull postgres:16-alpine &
docker pull redis:7.2-alpine &
docker pull confluentinc/cp-kafka:7.6.1 &
wait
- name: Run integration tests
run: mvn verify -Pfailsafe -T 2
env:
TESTCONTAINERS_RYUK_DISABLED: false
For Ryuk (the container cleanup side-car), leave TESTCONTAINERS_RYUK_DISABLED as false. Ryuk ensures all containers are removed even if the JVM crashes, preventing orphaned containers that consume CI agent resources. If your CI environment does not allow privileged containers, set TESTCONTAINERS_RYUK_PRIVILEGED=false and use the unprivileged mode.
9. Key Takeaways
- Testcontainers eliminates H2/mock drift by testing against the same database engine as production.
- Use
@ServiceConnection(Spring Boot 3.1+) to remove boilerplate@DynamicPropertySourcewiring. - Apply the Singleton Container pattern via a shared base class to avoid per-class container startup cost.
- Enable
withReuse(true)andtestcontainers.reuse.enable=trueon developer machines for sub-second repeated test runs. - Use
@ImportTestcontainersto centralize container beans and share them across multiple test classes. - Pre-pull Docker images in CI with
docker pullbeforemvn verifyto reduce first-run latency. - Keep Ryuk enabled in CI to prevent orphaned containers draining runner resources.
10. Advanced Testcontainers: Custom Images and Network Configuration
While the official Testcontainers modules cover most common infrastructure needs, real-world microservices often require custom Docker images — for example, a PostgreSQL image with specific extensions pre-installed, a custom Kafka configuration, or an in-house service dependency. Testcontainers provides GenericContainer as a flexible base for any Docker image, and ImageFromDockerfile to build images from a Dockerfile at test time without maintaining a separate CI build step for test images.
// Build a custom Postgres image with pgvector extension at test time
GenericContainer<?> customPostgres = new GenericContainer<>(
new ImageFromDockerfile()
.withDockerfileFromBuilder(builder ->
builder
.from("postgres:16-alpine")
.run("apk add --no-cache build-base && "
+ "cd /tmp && git clone https://github.com/pgvector/pgvector.git && "
+ "cd pgvector && make && make install")
.build()
)
)
.withExposedPorts(5432)
.withEnv("POSTGRES_DB", "testdb")
.withEnv("POSTGRES_USER", "test")
.withEnv("POSTGRES_PASSWORD", "test")
.waitingFor(Wait.forListeningPort());
// Reference a pre-built image from a private registry
GenericContainer<?> internalService = new GenericContainer<>(
DockerImageName.parse("registry.internal.example.com/inventory-service:latest"))
.withExposedPorts(8080)
.waitingFor(Wait.forHttp("/actuator/health").forStatusCode(200));
Container networking becomes critical when your service under test depends on other containerized services. Testcontainers supports Docker networks so containers can communicate with each other by hostname, simulating production service-discovery topology. Create an isolated bridge network with Network.newNetwork() and assign network aliases to containers so they can resolve each other without knowing externally mapped ports.
@Testcontainers
class OrderServiceIntegrationTest {
static final Network NETWORK = Network.newNetwork();
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withNetwork(NETWORK)
.withNetworkAliases("postgres");
@Container
static GenericContainer<?> inventoryService = new GenericContainer<>("inventory-service:test")
.withNetwork(NETWORK)
.withNetworkAliases("inventory")
.withEnv("DB_URL", "jdbc:postgresql://postgres:5432/testdb")
.dependsOn(postgres)
.waitingFor(Wait.forHttp("/actuator/health").forStatusCode(200));
@DynamicPropertySource
static void configure(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", postgres::getJdbcUrl);
// The order service calls inventory via http://inventory:8080
registry.add("inventory.service.url",
() -> "http://inventory:" + inventoryService.getMappedPort(8080));
}
}Network aliases are the key to container-to-container communication. Without a shared network, each container only knows its own externally mapped host port. With a shared network and aliases, containers resolve each other by hostname exactly as they do in Docker Compose or Kubernetes. This enables end-to-end integration tests spanning multiple services without any infrastructure changes to production code.
When building images from Dockerfiles at test time, Testcontainers caches the built image using a hash of the Dockerfile content. Subsequent test runs reuse the cached image, avoiding redundant Docker builds. For large base images or slow build steps, consider tagging the image explicitly and referencing it via DockerImageName.parse() with the pre-built tag. You can also set .withDeleteOnExit(false) to preserve a built image across JVM restarts, trading storage for startup speed. Always pin image tags in tests — using :latest can cause non-deterministic failures when the upstream image changes between CI runs.
11. Testcontainers Cloud: Running Tests Without Local Docker
Testcontainers Cloud, offered by the Testcontainers team (now part of Docker), addresses a common pain point: not every developer has Docker installed, and many CI environments restrict Docker-in-Docker. Testcontainers Cloud offloads container execution to a managed remote environment while keeping the test code completely unchanged — the same @Container annotations, same @ServiceConnection wiring, same assertions. The cloud environment handles container lifecycle while the JVM running tests sees local ports as usual.
Setup requires a Testcontainers Cloud account and a lightweight agent on the CI runner or developer machine. The agent intercepts Docker API calls and routes container creation to the managed cloud environment over a secure tunnel. Containers start with mapped ports that appear local to the JVM, so no test code changes are needed. The key CI benefit is that GitHub-hosted runners (which have Docker available but limited resources) no longer need to pull multi-GB images themselves — the cloud environment caches popular images like PostgreSQL, Kafka, and Redis.
# .github/workflows/ci.yml — integrate Testcontainers Cloud
steps:
- name: Setup Testcontainers Cloud
uses: atomicjar/testcontainers-cloud-setup-action@v1
with:
token: ${{ secrets.TC_CLOUD_TOKEN }}
- name: Run integration tests
run: mvn verify -Pfailsafe
# DOCKER_HOST is automatically configured by the agent
# No changes to test code requiredTestcontainers Cloud also provides a Turbo mode that parallelises container operations — starting multiple containers concurrently and streaming logs back in real time. For a test suite that ordinarily waits 30–60 seconds for containers to start before the first test method runs, Turbo mode can reduce this overhead by 50–70% by overlapping container readiness checks. For organizations running hundreds of CI builds per day, this translates to meaningful infrastructure cost reduction.
Security-conscious teams should review which data flows through the Testcontainers Cloud tunnel. Test data including credentials, personal information used as fixtures, and internal service identifiers is transmitted to remote containers. For most CI test scenarios this is acceptable under the same data handling policies as cloud build agents. However, in PCI-DSS, HIPAA, or FedRAMP environments, evaluate whether on-premises Docker or a self-hosted Testcontainers Cloud instance satisfies compliance requirements before adopting the hosted service.
12. Performance Optimization: Reducing Test Suite Execution Time
As a project grows, integration test suites using Testcontainers can become the dominant factor in CI build time. A suite with 50 test classes each independently starting PostgreSQL containers can easily take 20–40 minutes. Four complementary strategies bring this under control: singleton container reuse (covered in Section 4), parallel JVM fork execution, developer-side withReuse(true), and selective profiling to identify which test classes account for the majority of startup time.
<!-- Enable parallel test execution with Maven Surefire -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- Run test classes in parallel across 4 JVM forks -->
<forkCount>4</forkCount>
<reuseForks>true</reuseForks>
<parallel>classes</parallel>
<threadCount>4</threadCount>
<systemPropertyVariables>
<surefire.forkNumber>${surefire.forkNumber}</surefire.forkNumber>
</systemPropertyVariables>
</configuration>
</plugin>
When using parallel JVM forks with Testcontainers, each fork gets its own set of Docker containers. The singleton pattern (shared static containers) works within a single JVM fork but not across forks. Keep forkCount between 2–4 to balance parallelism with Docker resource usage — each fork may start its own PostgreSQL container, and 8 simultaneous PostgreSQL containers on a 4-core CI agent will degrade rather than improve throughput. Monitor Docker memory usage using docker stats during test runs to find the right fork count for your CI agent spec.
Container reuse with withReuse(true) is the most impactful optimization for developer inner loops. After the first test run, containers remain running and are reused by subsequent runs, reducing per-run startup overhead from 10–30 seconds to near-zero. Enable this by setting testcontainers.reuse.enable=true in ~/.testcontainers.properties. This setting is machine-local and does not affect CI builds, which should always start clean. Note that reused containers accumulate data between runs — make sure your tests handle pre-existing rows gracefully, typically by using unique test identifiers or truncating tables in a @BeforeEach.
// Enable container reuse for local development
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
.withReuse(true) // Reuse across JVM restarts (requires ~/.testcontainers.properties)
.withDatabaseName("testdb")
.withUsername("test")
.withPassword("test");
// ~/.testcontainers.properties (developer machine only — not committed to source control)
testcontainers.reuse.enable=true| Strategy | Time Saved | Best Environment | Key Caveat |
|---|---|---|---|
| Singleton Container | One startup per test class group | All environments | Tests must not corrupt shared state |
| withReuse(true) | Sub-second re-runs after first run | Local development only | Stale data between runs |
| Parallel Forks (forkCount=4) | Up to 4× throughput on multi-core | CI with 4+ cores, 8+ GB RAM | Each fork uses separate Docker containers |
| Testcontainers Cloud | Offloads Docker from CI agent | Large teams, resource-limited runners | Account required; data leaves machine |
13. Testing with WireMock and Testcontainers for External APIs
Most microservices call external APIs — payment gateways, notification services, identity providers, or other internal microservices. Testing these integrations is tricky: hitting real external services in CI creates flakiness and rate-limit errors, while simple in-process mocks do not validate actual HTTP serialization, headers, retry logic, or timeout behaviour. The solution is WireMock running as a Testcontainer, which provides a real HTTP server that you configure with stubbed responses, giving you end-to-end HTTP validation without external network dependencies.
<!-- Add WireMock Testcontainers module -->
<dependency>
<groupId>org.wiremock.integrations.testcontainers</groupId>
<artifactId>wiremock-testcontainers-module</artifactId>
<version>1.0-alpha-13</version>
<scope>test</scope>
</dependency>@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class PaymentGatewayIntegrationTest {
@Container
static WireMockContainer wireMock = new WireMockContainer("wiremock/wiremock:3.3.1")
.withMappingFromResource("stubs/payment-success.json");
@DynamicPropertySource
static void configureWireMock(DynamicPropertyRegistry registry) {
registry.add("payment.gateway.url", wireMock::getBaseUrl);
}
@Test
void chargeCard_success_returnsConfirmation() {
// Stub configured in src/test/resources/stubs/payment-success.json
// OR configure programmatically:
wireMock.stubFor(WireMock.post("/v1/charges")
.withRequestBody(matchingJsonPath("$.amount"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{"chargeId": "ch_test_123", "status": "succeeded"}
""")));
PaymentResult result = paymentService.charge(new ChargeRequest("4111111111111111", 99.99));
assertThat(result.status()).isEqualTo("succeeded");
wireMock.verify(1, postRequestedFor(urlEqualTo("/v1/charges")));
}
@Test
void chargeCard_gateway503_throwsPaymentException() {
wireMock.stubFor(WireMock.post("/v1/charges")
.willReturn(aResponse().withStatus(503).withFixedDelay(5_000)));
assertThatThrownBy(() -> paymentService.charge(new ChargeRequest("4111111111111111", 99.99)))
.isInstanceOf(PaymentGatewayException.class)
.hasMessageContaining("gateway unavailable");
}
}
WireMock stubs can be loaded from JSON mapping files in src/test/resources/wiremock/mappings/ or configured programmatically as shown above. The JSON format matches WireMock standalone server format exactly, meaning stubs developed locally can be committed to version control and reused across teams. This is especially valuable for consumer-driven contract testing: frontend teams can provide WireMock stub files that describe the API responses they depend on, and backend tests can verify the service actually returns those responses.
Combining WireMock containers with real Testcontainers databases gives you full-stack integration tests: real PostgreSQL for persistence, real Kafka for messaging, and WireMock for external HTTP APIs — all running locally and in CI without external dependencies. This combination is significantly more valuable than unit tests alone because it validates HTTP client configuration (RestTemplate/WebClient setup, timeout settings, serialization), Spring retry and circuit breaker integration (Resilience4j wrapping the HTTP call), and the entire request-to-database flow including transactional boundaries and error rollback behaviour.
WireMock also supports response templating using Handlebars expressions, which allows stubs to echo request data back in responses. This is useful for testing scenarios like "return the resource ID I just POSTed" without writing custom stub logic. For chaos engineering tests, WireMock's fault injection — including TCP connection resets, chunked response corruption, and configurable random delays — lets you verify that your retry policies, circuit breakers, and timeout configurations behave correctly under realistic failure conditions that are otherwise difficult to reproduce in tests.
Leave a Comment
Related Posts
Software Engineer · Java · Spring Boot · Microservices