Testcontainers with @ServiceConnection

Run tests against real PostgreSQL, Redis and other services in Docker, with Spring Boot wiring the connection details for you.

Syntax@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 @Testcontainers and @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 @Bean in 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. latest changes 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 (redis for redis: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

Example · java
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;

@SpringBootTest
@Testcontainers
@Transactional
class SchemaOnPostgresIT {

    @Container
    @ServiceConnection
    static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:18-alpine");

    @Autowired
    JdbcClient jdbc;

    @Test
    void runsAgainstTheRealEngine() {
        String version = jdbc.sql("show server_version").query(String.class).single();

        assertThat(version).startsWith("18.");
    }

    @Test
    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);
    }

    @Test
    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.

Example · java
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>.

Example · java
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.

Example · yaml
# .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

  • Be the first to comment on this lesson.