Testcontainers with @ServiceConnection
Run tests against real PostgreSQL, Redis and other services in Docker, with Spring Boot wiring the connection details for you.
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {
@Bean @ServiceConnection
PostgreSQLContainer postgres() { return new PostgreSQLContainer("postgres:18-alpine"); }
}Testcontainers starts real services in Docker for the length of a test run: the same PostgreSQL version as production, a real Redis, a real message broker. Tests then catch the bugs that embedded databases hide: SQL dialect differences, migrations that fail on the real engine, types such as jsonb, and locking behaviour.
@ServiceConnection
A container starts on a random host port, so its URL is only known at run time. @ServiceConnection (from org.springframework.boot.testcontainers.service.connection) tells Spring Boot to read the host, port, user and password from the container and build the connection details from them. Your spring.datasource.url is ignored for that service; no @DynamicPropertySource code is needed.
There are two ways to declare the container.
- A static field in the test class with
@Testcontainersand@Container. The JUnit extension starts it before the class and stops it after. Simple, but each class that declares it gets its own container. - A
@Beanin a@TestConfiguration. Spring starts the container with the application context and stops it when the context closes. Every test class that imports the configuration shares the cached context, and with it one container. For a larger suite this is the better default.
Testcontainers 2
Spring Boot 4.1 manages Testcontainers 2. Its modules are named testcontainers-postgresql, testcontainers-junit-jupiter and so on, and the PostgreSQL class lives in org.testcontainers.postgresql without a type parameter: new PostgreSQLContainer("postgres:18-alpine"). Code copied from older guides with PostgreSQLContainer<?> and the org.testcontainers.containers package should be moved to the new names.
Practical points
- Pin the image tag to the major version you run in production.
latestchanges under you and turns a green build red on a random day. - Docker must be available wherever tests run. Hosted CI runners such as GitHub's Ubuntu images have it; locked-down build agents may not.
- Generic containers are matched by image name. For a plain
GenericContainer, Spring Boot uses the image name (redisforredis:8-alpine) to pick the connection details. When the image has another name, such as a company-built image, set@ServiceConnection(name = "redis"). - Flyway runs against the container at startup, so a broken migration fails the tests rather than the deployment.
The same configuration can run the application locally: a test-scope main method that uses SpringApplication.from(...).with(...) starts the app with its containers, so new developers need only Docker to get going.
Example
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.transaction.annotation.Transactional;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;
class SchemaOnPostgresIT {
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:18-alpine");
JdbcClient jdbc;
void runsAgainstTheRealEngine() {
String version = jdbc.sql("show server_version").query(String.class).single();
assertThat(version).startsWith("18.");
}
void migrationsCreatedTheOrdersTable() {
Integer tables = jdbc.sql("""
select count(*) from information_schema.tables
where table_schema = 'public' and table_name = 'purchase_orders'
""")
.query(Integer.class)
.single();
assertThat(tables).isEqualTo(1);
}
void storesJsonbDocuments() {
jdbc.sql("create temporary table audit (payload jsonb)").update();
jdbc.sql("insert into audit values (cast(:json as jsonb))")
.param("json", "{\"event\": \"paid\", \"amount\": 30}")
.update();
String event = jdbc.sql("select payload ->> 'event' from audit").query(String.class).single();
assertThat(event).isEqualTo("paid");
}
}
When to use it
- Test Flyway migrations and native queries against the same PostgreSQL major version that production runs.
- Give new team members a working local setup: run one test-scope main class and the app starts with its database.
- Test a Redis-backed rate limiter against a real Redis instead of a mock that ignores expiry and atomicity.
More examples
One shared configuration, also used to run the app locally
<code>TestcontainersConfiguration</code> is the single place that says which services the application needs in tests. Integration tests import it, so they share one context and one container. <code>SpringApplication.from(...).with(...)</code> starts the real <code>main</code> method plus the test configuration, which gives every developer a working local database with no installation beyond Docker. In real code the three types live in separate files; they share one block here so it compiles on its own.
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.postgresql.PostgreSQLContainer;
@SpringBootApplication
class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
// src/test/java: shared by every integration test through @Import(TestcontainersConfiguration.class)
@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {
@Bean
@ServiceConnection
PostgreSQLContainer postgres() {
return new PostgreSQLContainer("postgres:18-alpine");
}
}
// src/test/java: run this class, or ./mvnw spring-boot:test-run, to start the app with Docker-backed services.
public class TestShopApplication {
public static void main(String[] args) {
SpringApplication.from(ShopApplication::main)
.with(TestcontainersConfiguration.class)
.run(args);
}
}
Redis from a generic container
A <code>GenericContainer</code> has no service-specific class, so Spring Boot falls back to the image name to decide which connection details to create. <code>name = "redis"</code> states it explicitly, which keeps working if the image is later swapped for a mirror or a company-built image with a different name. <code>withExposedPorts(6379)</code> is required: it is the port inside the container, and Testcontainers maps it to a random free port on the host. The test then talks to a real Redis, including its atomic <code>INCR</code>.
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
@SpringBootTest
@Testcontainers
class RateLimitStoreIT {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:8-alpine"))
.withExposedPorts(6379);
@Autowired
StringRedisTemplate redisTemplate;
@Test
void countsRequestsPerClient() {
redisTemplate.opsForValue().increment("rate:client-1");
Long count = redisTemplate.opsForValue().increment("rate:client-1");
assertThat(count).isEqualTo(2);
}
}
Running the container tests in CI
Nothing in the workflow mentions PostgreSQL: Testcontainers pulls and starts the image itself, so CI uses the same database version as the developer's laptop. Caching the Maven repository keeps the build quick, and the first pull of each image is the main extra cost. If your CI agents have no Docker, run these tests in a job that does, rather than falling back to an embedded database.
# .github/workflows/build.yml
name: build
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest # Docker is preinstalled on this runner
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25'
cache: maven
- name: Unit, slice and integration tests
run: ./mvnw --batch-mode verify
Discussion