Spring Boot Integration Tests with Testcontainers (Locally and in GitHub Actions)

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

This page is the practical centre of the Docker section: how to write integration tests for a Spring Boot 4.1.x service (Java 25) that run against real PostgreSQL, Kafka and a mocked downstream API in Docker, and how to run them the same way on a laptop and in GitHub Actions. It uses Testcontainers for Java 2.0.5 (managed by Spring Boot 4.1), Docker Engine 29.8.x or Docker Desktop 4.91 or later, and the current GitHub Actions majors (actions/checkout@v7, actions/setup-java@v6, docker/login-action@v4). The basics of @SpringBootTest with a @Container field are on Unit and Integration Testing and are not repeated (a Quartz-on-a-real-database variant is on Testing Scheduled Jobs); how Testcontainers itself works is on Testcontainers Fundamentals.

The split: *Test without Docker, *IT with Docker

The house convention keeps two kinds of tests apart by name:

  • *Test — unit and slice tests, run by Surefire in the test phase. They never need Docker, so mvn test works on any machine, offline, in seconds.

  • *IT — integration tests, run by Failsafe in the integration-test phase and checked in verify. They start containers.

Why two plugins, the default include patterns and the JaCoCo wiring are explained on Maven Quality Plugins; this page adds the Docker side.

Dependencies

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-test</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-testcontainers</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers-junit-jupiter</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers-postgresql</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.testcontainers</groupId>
  <artifactId>testcontainers-kafka</artifactId>
  <scope>test</scope>
</dependency>
<dependency>
  <groupId>org.springframework.kafka</groupId>
  <artifactId>spring-kafka-test</artifactId>
  <scope>test</scope>
</dependency>

No versions: spring-boot-dependencies manages all of them, including the Testcontainers BOM at 2.0.5. The artifact names are the 2.0 ones (testcontainers-junit-jupiter, testcontainers-<module>); see the 2.0 model.

Three ways to wire containers into the application context

1. @Container with @ServiceConnection

A static @Container field annotated with @ServiceConnection is the shortest path for a single container: Spring Boot derives the connection properties from the container type. This is the pattern already shown on Unit and Integration Testing and, for brokers, on Spring Boot Testing for Messaging.

Declaring containers as @Bean`s in a `@TestConfiguration lets Spring manage their lifecycle: they start before the beans that depend on them, stop after them, and — because Spring’s test context cache reuses a context across test classes with the same configuration — are shared by every test class that imports the configuration.

package com.example.orders;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.kafka.KafkaContainer;
import org.testcontainers.postgresql.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;

@TestConfiguration(proxyBeanMethods = false)
public class ContainersConfiguration {

    @Bean
    @ServiceConnection
    PostgreSQLContainer postgres() {
        return new PostgreSQLContainer(DockerImageName.parse("postgres:18.1"));
    }

    @Bean
    @ServiceConnection
    KafkaContainer kafka() {
        return new KafkaContainer(DockerImageName.parse("apache/kafka:4.2.1"));
    }
}
@SpringBootTest
@Import(ContainersConfiguration.class)
class OrderRepositoryIT {
    // ... tests; JDBC and Kafka connection details come from the container beans
}

Derived from Spring Boot’s Using Spring Beans and Service Connections. Existing @Container declarations in an interface or class can be imported instead with @ImportTestcontainers(MyContainers.class) on the test configuration.

3. @DynamicPropertySource and DynamicPropertyRegistrar

When no ConnectionDetails exists for a technology — a custom client’s base URL, a WireMock endpoint, a Compose service — register the properties yourself. In a test class use a static @DynamicPropertySource method; in a configuration class declare a DynamicPropertyRegistrar bean that receives the container, which guarantees the container has started before the properties are read:

@Bean
DynamicPropertyRegistrar paymentsProperties(GenericContainer<?> wiremock) {
    return registry -> registry.add("clients.payments.base-url",
            () -> "http://%s:%d".formatted(wiremock.getHost(), wiremock.getMappedPort(8080)));
}

Derived from Dynamic Properties.

The connection-details table

Spring Boot recognises these containers for @ServiceConnection (Spring Boot 4.1; abbreviated):

Connection details Container

JdbcConnectionDetails, R2dbcConnectionDetails, FlywayConnectionDetails, LiquibaseConnectionDetails

Any JdbcDatabaseContainer (PostgreSQL, MySQL, MariaDB, Oracle, SQL Server, …​)

KafkaConnectionDetails

org.testcontainers.kafka.KafkaContainer, ConfluentKafkaContainer, RedpandaContainer

RabbitConnectionDetails, RabbitStreamConnectionDetails

org.testcontainers.rabbitmq.RabbitMQContainer

MongoConnectionDetails

org.testcontainers.mongodb.MongoDBContainer, MongoDBAtlasLocalContainer

ElasticsearchConnectionDetails, CassandraConnectionDetails, CouchbaseConnectionDetails, Neo4jConnectionDetails, PulsarConnectionDetails

The corresponding module containers

DataRedisConnectionDetails

com.redis.testcontainers.RedisContainer, or a GenericContainer on redis, redis/redis-stack or redis/redis-stack-server

Otlp*ConnectionDetails, ZipkinConnectionDetails

otel/opentelemetry-collector-contrib, grafana.LgtmStackContainer, openzipkin/zipkin

The complete table is in Service Connections. A GenericContainer on an image with a different name can still be matched with @ServiceConnection(name = "redis").

The house harness: one compose.yaml for everything

The repository’s convention (generated by .claude/skills/iru-setup-java-springboot-testcontainers) goes one step further: the integration tests start the same compose.yaml that developers run locally (see Compose for Local Development), through a Testcontainers ComposeContainer in one abstract base class. Local runs and CI then cannot drift apart: a service, version or healthcheck changed for development is exactly what the tests use.

One compose.yaml started two ways: by spring-boot-docker-compose for a local run with fixed ports and by a Testcontainers ComposeContainer during mvn verify with mapped ports

The rules of the harness:

  • One ComposeContainer, started once per JVM — a static final field started in a static initializer (the singleton pattern), not per test class. Restarting Kafka and PostgreSQL per class makes the suite unusably slow. Tests therefore share state: each *IT cleans up what it creates or uses unique keys.

  • Real wait strategies — Wait.forHealthcheck() for every service, which is why every service in the file has a healthcheck. Wait.forListeningPort() alone lets tests run against a database that is still initialising.

  • Mapped ports through @DynamicPropertySource — ComposeContainer exposes services through random ports; every property is built from getServiceHost/getServicePort, never from a fixed port.

  • spring.docker.compose.skip.in-tests=true — Spring Boot’s own Compose support must not start a second copy of the stack during tests. It is the Spring Boot 4.1 default; the house application-local.yaml sets it explicitly anyway.

  • The compose file path is module-relative — Failsafe runs with the module directory as the working directory, so new File("compose.yaml") is wrong in a multi-module build. The base class below walks up from user.dir to the directory that contains compose.yaml, so it works from any module depth.

  • Narrow per-adapter bases — an adapter module that needs only PostgreSQL uses containers as beans (option 2) instead of the full stack; the Compose harness is for full-context tests in the boot module.

Downstream APIs: WireMock or Microcks

Integration tests must never call a real downstream service. The house stack contains a WireMock container whose mappings (under wiremock/mappings/) are generated from the client’s OpenAPI contract; Microcks is the alternative when gRPC or GraphQL clients exist, because it mocks REST, gRPC and GraphQL from the same contracts. Both are just Compose services here; for per-adapter tests their Testcontainers modules (org.wiremock.integrations.testcontainers:wiremock-testcontainers-module, io.github.microcks:microcks-testcontainers) start a single mock in seconds.

Testcontainers at development time

The same container configuration can also back a local run, without Compose: a TestOrdersApplication in src/test/java launches the real application with the test configuration attached:

package com.example.orders;

import org.springframework.boot.SpringApplication;

public class TestOrdersApplication {

    public static void main(String[] args) {
        SpringApplication.from(OrdersApplication::main)
                .with(ContainersConfiguration.class)
                .run(args);
    }
}

Run it from the IDE or with ./mvnw spring-boot:test-run. Annotating container beans with @RestartScope keeps them alive across DevTools restarts. Derived from Testcontainers Support (development-time services). The house setup prefers Compose for local runs, but this is a good fit for services without a compose.yaml.

Running the tests locally

docker info > /dev/null && echo "Docker is running"   # Docker Desktop or Docker Engine must be up
./mvnw test                         # unit tests only -- no Docker needed
./mvnw verify                       # unit tests, then Failsafe runs the *IT classes against containers
./mvnw verify -DskipITs             # everything except the integration tests
./mvnw verify -Dit.test=OrderIT     # a single integration test class
docker compose down                 # stop a running local dev stack first: it holds the same host ports

Derived from the Maven Failsafe plugin documentation and Docker’s Run your Java tests guide.

Faster local iterations: turn on reuse mode for single containers (testcontainers.reuse.enable=true in ~/.testcontainers.properties plus .withReuse(true)), which keeps them running between runs (see Reuse mode). It must stay off in CI.

Troubleshooting:

Symptom Fix

Could not find a valid Docker environment

Start Docker Desktop, or point DOCKER_HOST at the daemon (Colima, Podman, remote host).

Ryuk fails to start (permission denied, privileged containers not allowed)

Typical of rootless Podman or restricted sockets: set TESTCONTAINERS_RYUK_DISABLED=true and make sure the build cleans up.

Colima or Podman: containers start but connections time out

Set TESTCONTAINERS_HOST_OVERRIDE to the VM address and, for Colima, TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock (see Supported environments).

Apple silicon: no matching manifest for linux/arm64 or very slow start-up

The image is linux/amd64-only and runs under emulation (or not at all). Prefer multi-platform images; otherwise set the platform explicitly and accept the cost.

Bind for 127.0.0.1:5432 failed: port is already allocated

The local dev stack is still running — docker compose down before mvn verify.

Running the tests in GitHub Actions

GitHub-hosted ubuntu-latest runners ship Docker Engine and the Compose plugin, so the same mvn verify works in CI with no extra setup. The house build.yml:

# .github/workflows/build.yml
name: build

on:
  push:
    branches: [main, develop]
  pull_request:

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - name: Check out
        uses: actions/checkout@v7

      - name: Set up JDK 25
        uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '25'
          cache: maven

      - name: Log in to Docker Hub (authenticated pulls avoid the anonymous rate limit)
        if: ${{ vars.DOCKERHUB_USERNAME != '' }}
        uses: docker/login-action@v4
        with:
          username: ${{ vars.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Build, unit tests (Surefire) and integration tests (Failsafe)
        run: mvn -B -ntp clean verify

      - name: Upload test reports
        if: failure()
        uses: actions/upload-artifact@v7
        with:
          name: test-reports
          path: |
            **/target/surefire-reports/**
            **/target/failsafe-reports/**
sequenceDiagram participant GH as GitHub Actions runner (ubuntu-latest) participant M as Maven participant TC as Testcontainers participant D as Docker Engine on the runner participant R as Docker Hub GH->>GH: actions/checkout@v7 GH->>GH: actions/setup-java@v6 (Temurin 25, Maven cache) GH->>GH: docker/login-action@v4 (writes ~/.docker/config.json) GH->>M: mvn -B -ntp clean verify M->>M: Surefire runs *Test (no Docker) M->>TC: Failsafe runs *IT, AbstractIT static init TC->>D: start Ryuk, then docker compose up (compose.yaml) D->>R: authenticated pulls (postgres, kafka, wiremock, docker cli) D-->>TC: services healthy TC-->>M: mapped ports, tests run M-->>GH: verify result alt failure GH->>GH: upload surefire and failsafe reports end

Derived from the READMEs of actions/checkout, actions/setup-java, docker/login-action and actions/upload-artifact, Docker’s Java guide, and Testcontainers' Patterns for running tests inside a Docker container. The workflow shape (ubuntu-latest, mvn -B -ntp clean verify, reports on failure) matches .claude/skills/iru-setup-java-springboot-github-workflows; the action majors are the current ones.

Docker Hub rate limits in CI

GitHub-hosted runners share IP addresses, so anonymous pulls quickly hit Docker Hub’s limit of 100 pulls per 6 hours per address. docker/login-action with a Docker Hub access token (stored as the DOCKERHUB_TOKEN secret) raises the allowance — Testcontainers reads the credentials it writes to ~/.docker/config.json. Alternatives: mirror the images into GHCR and reference them there, or set TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX to a pull-through mirror (see Registries and Docker Hub).

services: job containers — and why they are worse here

GitHub Actions can start containers next to a job:

    services:
      postgres:
        image: postgres:18.1
        env:
          POSTGRES_DB: orders
          POSTGRES_USER: orders
          POSTGRES_PASSWORD: orders-dev-only      # development-only
        ports:
          - 5432:5432
        options: >-
          --health-cmd "pg_isready -U orders -d orders"
          --health-interval 5s --health-timeout 3s --health-retries 20

This works, but compared with Testcontainers it has fixed ports, a configuration that exists only in CI (local runs need a different setup), no wait strategies beyond Docker health checks, no per-test lifecycle, and no parity with the compose.yaml developers use. Keep Testcontainers; use services: only for tests that cannot start Docker containers themselves. Derived from GitHub’s Communicating with Docker service containers and Creating PostgreSQL service containers.

container: jobs and Docker-in-Docker

Running the whole job inside a container (jobs.<id>.container) changes the picture: Testcontainers then needs the host’s Docker socket mounted into the job container and must reach sibling containers through the Docker network rather than localhost. Testcontainers documents the "Docker wormhole" pattern (mount /var/run/docker.sock) and advises against true Docker-in-Docker. On GitHub-hosted runners, simply do not use container: for jobs that run Testcontainers. See GitHub’s Running jobs in a container and Testcontainers' DinD patterns.

Capacity, timeouts and parallelism

  • A standard GitHub-hosted ubuntu-latest runner has 4 vCPUs and 16 GB of RAM for public repositories and 2 vCPUs and 8 GB for private ones (14 GB of SSD either way); PostgreSQL, Kafka and WireMock fit comfortably. Larger stacks (Elasticsearch, Couchbase) may need larger runners or Testcontainers Cloud.

  • Set timeout-minutes on the job and withStartupTimeout on the harness, so a stuck pull fails fast.

  • Maven -T 1C parallelises modules; Failsafe forkCount parallelises test JVMs. The Compose harness publishes the fixed host ports declared in compose.yaml, so two forks would collide — keep forkCount at 1 for Compose-based `*IT`s and parallelise by module instead. Containers-as-beans tests use random ports and can run in parallel forks.

  • Broker-specific CI concerns (topic creation, Awaitility timeouts) are covered in Spring Boot Testing for Messaging.

Derived from GitHub’s GitHub-hosted runners and the runner images repository.

A complete worked example

The five pieces below belong together: the same service names (postgres, kafka, wiremock), container ports (5432, 9092, 8080) and property names appear in each.

compose.yaml (repository root)

# compose.yaml -- local development and integration tests. Development-only credentials.
name: orders

services:
  postgres:
    image: postgres:18.1
    environment:
      POSTGRES_DB: orders
      POSTGRES_USER: orders
      POSTGRES_PASSWORD: orders-dev-only            # development-only
    ports:
      - "127.0.0.1:5432:5432"
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U orders -d orders"]
      interval: 5s
      timeout: 3s
      retries: 20

  kafka:
    image: apache/kafka:4.2.1
    ports:
      - "127.0.0.1:9092:9092"
    environment:
      KAFKA_NODE_ID: 1
      KAFKA_PROCESS_ROLES: broker,controller
      KAFKA_LISTENERS: CONTROLLER://:29093,PLAINTEXT_HOST://:9092,PLAINTEXT://:19092
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT_HOST://localhost:9092,PLAINTEXT://kafka:19092
      KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
      KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
      KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:29093
      KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
      KAFKA_SHARE_COORDINATOR_STATE_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_SHARE_COORDINATOR_STATE_TOPIC_MIN_ISR: 1
      KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
    healthcheck:
      test: ["CMD-SHELL", "/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092 > /dev/null"]
      interval: 10s
      timeout: 10s
      retries: 20

  wiremock:
    image: wiremock/wiremock:3.13.2
    command: ["--global-response-templating", "--disable-banner"]
    ports:
      - "127.0.0.1:8585:8080"
    volumes:
      - ./wiremock:/home/wiremock:ro
    healthcheck:
      test: ["CMD", "curl", "-fsS", "http://localhost:8080/__admin/health"]
      interval: 5s
      timeout: 3s
      retries: 20
    labels:
      org.springframework.boot.ignore: "true"

Kafka advertises localhost:9092 to clients on the host, so after the bootstrap connection through Testcontainers' mapped port, the Kafka client continues on the published 127.0.0.1:9092 — one more reason the local stack must be down during mvn verify. The WireMock mapping the test relies on:

{
  "request": { "method": "POST", "urlPath": "/payments/authorizations" },
  "response": {
    "status": 201,
    "headers": { "Content-Type": "application/json" },
    "jsonBody": { "authorizationId": "auth-{{randomValue length=8 type='ALPHANUMERIC'}}", "status": "AUTHORIZED" }
  }
}

(saved as wiremock/mappings/payments-authorize.json; generated from the payments contract in the house setup).

AbstractIT (test sources of the boot module)

package com.example.orders;

import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;

import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.ComposeContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import org.testcontainers.utility.DockerImageName;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ActiveProfiles("it")
public abstract class AbstractIT {

    /** The same compose.yaml a developer runs locally: one definition, no drift. Started once per JVM. */
    protected static final ComposeContainer ENVIRONMENT = new ComposeContainer(
                    DockerImageName.parse("docker:29.8.1-cli"), composeFile())
            .withCopyFilesInContainer("wiremock")   // only compose.yaml and the bind-mounted mappings, not the whole root
            .withExposedService("postgres", 5432, Wait.forHealthcheck())
            .withExposedService("kafka", 9092, Wait.forHealthcheck())
            .withExposedService("wiremock", 8080, Wait.forHealthcheck())
            .withStartupTimeout(Duration.ofMinutes(3));

    static {
        ENVIRONMENT.start();   // Ryuk removes the stack when the JVM exits
    }

    @DynamicPropertySource
    static void environmentProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", () -> "jdbc:postgresql://%s:%d/orders".formatted(
                ENVIRONMENT.getServiceHost("postgres", 5432), ENVIRONMENT.getServicePort("postgres", 5432)));
        registry.add("spring.datasource.username", () -> "orders");
        registry.add("spring.datasource.password", AbstractIT::composeDatabasePassword);
        registry.add("spring.kafka.bootstrap-servers", () -> "%s:%d".formatted(
                ENVIRONMENT.getServiceHost("kafka", 9092), ENVIRONMENT.getServicePort("kafka", 9092)));
        registry.add("clients.payments.base-url", () -> "http://%s:%d".formatted(
                ENVIRONMENT.getServiceHost("wiremock", 8080), ENVIRONMENT.getServicePort("wiremock", 8080)));
    }

    /** The development-only value from compose.yaml, kept in one place. */
    private static String composeDatabasePassword() {
        return "orders-dev-only";
    }

    /** Failsafe runs in the module directory; walk up to the directory that holds compose.yaml. */
    private static File composeFile() {
        for (Path dir = Path.of(System.getProperty("user.dir")).toAbsolutePath(); dir != null; dir = dir.getParent()) {
            Path candidate = dir.resolve("compose.yaml");
            if (Files.isRegularFile(candidate)) {
                return candidate.toFile();
            }
        }
        throw new IllegalStateException("compose.yaml not found above " + System.getProperty("user.dir"));
    }
}

The containerised (DockerImageName, File…​) constructor copies every file in the directory holding compose.yaml — here the repository root, with .git, target/ and the rest — into the container that runs docker compose, unless withCopyFilesInContainer(…​) names what to copy instead: the Compose file itself is always copied, and the listed paths (resolved relative to it) are the ./wiremock directory the wiremock service bind-mounts. Derived from the Build Working Directory section of the Docker Compose Module page.

src/test/resources/application-it.yaml keeps Spring Boot’s own Compose support out of the way:

spring:
  docker:
    compose:
      skip:
        in-tests: true

OrderIT

package com.example.orders;

import static org.assertj.core.api.Assertions.assertThat;

import java.time.Duration;

import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.kafka.test.utils.KafkaTestUtils;
import org.springframework.web.client.RestClient;

class OrderIT extends AbstractIT {

    @LocalServerPort
    int port;

    @Value("${spring.kafka.bootstrap-servers}")
    String bootstrapServers;

    @Autowired
    JdbcClient jdbc;

    @Test
    void placingAnOrderAuthorizesPaymentStoresItAndPublishesAnEvent() {
        var response = RestClient.create("http://localhost:" + port)
                .post().uri("/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .body("""
                      {"customerId": "c-%s", "amount": 42.50}
                      """.formatted(System.nanoTime()))          // unique key: the stack is shared
                .retrieve()
                .toEntity(String.class);

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);

        String orderId = response.getHeaders().getLocation().getPath().replace("/orders/", "");
        String status = jdbc.sql("select status from orders where id = ?")
                .param(orderId)
                .query(String.class)
                .single();
        assertThat(status).isEqualTo("PAYMENT_AUTHORIZED");        // WireMock answered AUTHORIZED

        ConsumerRecord<?, ?> event = KafkaTestUtils.getOneRecord(
                bootstrapServers, "order-it-" + orderId, "orders.placed", 0, false, true, Duration.ofSeconds(30));
        assertThat(event.key()).hasToString(orderId);
    }
}

The endpoint, table and topic (POST /orders, orders, orders.placed) stand for the service’s own; everything Docker-related is complete as shown. Derived from Spring Boot’s Dynamic Properties, Testcontainers' Docker Compose Module and Docker’s Testing a Spring Boot REST API with Testcontainers guide.

Failsafe in pom.xml

With spring-boot-starter-parent, Failsafe’s version, its integration-test/verify executions and its classesDirectory (so tests run against the classes, not the repackaged jar) are already managed; declaring the plugin activates it, and the only house addition is the fork count. The explicit <executions> block below is what a build without the starter parent needs:

<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-failsafe-plugin</artifactId>
  <executions>
    <execution>
      <goals>
        <goal>integration-test</goal>
        <goal>verify</goal>
      </goals>
    </execution>
  </executions>
  <configuration>
    <forkCount>1</forkCount>          <!-- one JVM: the Compose harness publishes fixed host ports -->
  </configuration>
</plugin>

Failsafe’s default includes (/IT*.java, /IT.java, */*ITCase.java) pick up OrderIT; the Surefire exclusion and JaCoCo integration are on Maven Quality Plugins. Derived from the Failsafe usage page and Using the Parent POM.

.github/workflows/build.yml

Exactly the workflow shown under Running the tests in GitHub Actions: check out, Temurin 25 with the Maven cache, an optional authenticated Docker Hub login, mvn -B -ntp clean verify, and the Surefire/Failsafe reports uploaded when something fails. Nothing Docker-specific needs installing on ubuntu-latest.