SupportWriter space ↗
← Back to the journal
Spring Boot

Integration testing in Spring Boot with Testcontainers

Run integration tests against real PostgreSQL databases in Docker with Testcontainers and @ServiceConnection, and reuse the same setup for local development.

DPutu Adi Guna Permana · 05 Oct 2026 · 6 min read

Why not just use H2?

Many Spring Boot projects run their tests against an in-memory database such as H2, while production runs on PostgreSQL or MySQL. That is fast, but the two databases are not the same. JSON columns, window functions, ON CONFLICT upserts, case sensitivity, specific data types, and even query plans can behave differently. Tests pass, and the bug shows up in production.

Testcontainers solves this by starting real databases, message brokers, and other services in throwaway Docker containers for the duration of your tests. Spring Boot has built-in support that makes the setup almost effortless.

The examples target Spring Boot 3.1 or later with JUnit 5. You need Docker (or a compatible runtime such as Podman or Colima) running on your machine and in CI.

Dependencies

Spring Initializr adds the right dependencies when you select Testcontainers together with your database. With Maven, the test dependencies look like this:

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

Spring Boot manages the Testcontainers version, so you do not need to specify it. Artifact and package names can change between major Testcontainers versions; if an import does not resolve, check the Testcontainers documentation for the version your Spring Boot release manages.

Your first container-backed test

Here is a repository test that runs against a real PostgreSQL database:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class ProductRepositoryTest {

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

    @Autowired
    ProductRepository repository;

    @Test
    void findsProductsByCategoryIgnoringCase() {
        repository.save(new Product("Mechanical keyboard", "Accessories"));
        repository.save(new Product("USB-C hub", "accessories"));
        repository.save(new Product("Monitor", "Displays"));

        List<Product> result = repository.findByCategoryIgnoreCase("ACCESSORIES");

        assertThat(result).hasSize(2);
    }
}

What each annotation does:

  • @DataJpaTest starts only the JPA slice: entities, repositories, and the data source.
  • @AutoConfigureTestDatabase(replace = NONE) stops Spring from swapping your database for an embedded one.
  • @Testcontainers and @Container let JUnit start the container before the tests and stop it afterwards. A static field means one container is shared by every test in the class.
  • @ServiceConnection is the key Spring Boot feature: it reads the container's host, port, username, and password and configures the DataSource automatically. No URLs to copy, no properties to override.

Testcontainers maps the database to a random free port, so tests never clash with a PostgreSQL instance already running on your machine.

Testing the full application

The same approach works for end-to-end tests that start the whole app and call it over HTTP:

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
class OrderApiIntegrationTest {

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

    @Autowired
    TestRestTemplate rest;

    @Test
    void createsAndFetchesAnOrder() {
        var request = new CreateOrderRequest("putu@example.com", "Mechanical keyboard", 1);

        ResponseEntity<OrderResponse> created = rest.postForEntity("/api/orders", request, OrderResponse.class);
        assertThat(created.getStatusCode()).isEqualTo(HttpStatus.CREATED);

        long id = created.getBody().id();
        ResponseEntity<OrderResponse> fetched = rest.getForEntity("/api/orders/" + id, OrderResponse.class);

        assertThat(fetched.getBody().productName()).isEqualTo("Mechanical keyboard");
    }
}

If you manage your schema with Flyway or Liquibase, migrations run against the container on startup, which means your tests also verify that the migrations themselves work on the real database engine.

Sharing containers across test classes

Starting a container takes a few seconds. When many test classes need the same database, define the containers once in a test configuration and import it:

@TestConfiguration(proxyBeanMethods = false)
class TestcontainersConfiguration {

    @Bean
    @ServiceConnection
    PostgreSQLContainer postgresContainer() {
        return new PostgreSQLContainer("postgres:16-alpine");
    }

    @Bean
    @ServiceConnection(name = "redis")
    GenericContainer<?> redisContainer() {
        return new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
    }
}
@SpringBootTest
@Import(TestcontainersConfiguration.class)
class CheckoutServiceTest {
    // ...
}

Spring manages the container lifecycle as beans: they start with the application context, and because Spring caches test contexts, every test class with the same configuration reuses the same running containers.

@ServiceConnection supports many services out of the box, including PostgreSQL, MySQL, MariaDB, Oracle, SQL Server, MongoDB, Redis, Kafka, RabbitMQ, Elasticsearch, and Neo4j. For a GenericContainer, the name attribute tells Spring which kind of connection it provides.

Services without a service connection

For anything not covered by @ServiceConnection, fall back to @DynamicPropertySource and set properties yourself:

@Container
static GenericContainer<?> mailpit = new GenericContainer<>("axllent/mailpit:latest")
        .withExposedPorts(1025, 8025);

@DynamicPropertySource
static void mailProperties(DynamicPropertyRegistry registry) {
    registry.add("spring.mail.host", mailpit::getHost);
    registry.add("spring.mail.port", () -> mailpit.getMappedPort(1025));
}

Using containers during local development

The same TestcontainersConfiguration can power your local development environment. Create a launcher in src/test/java:

public class TestShopApplication {

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

Run TestShopApplication from your IDE (or ./mvnw spring-boot:test-run) and the app starts with a fresh PostgreSQL and Redis, without anyone installing those services locally. New team members can clone the repository and run the app within minutes.

As an alternative, Spring Boot's Docker Compose support (spring-boot-docker-compose) can start services defined in a compose.yaml file when the app launches.

Making tests fast and reliable

  • Pin image versions. Use postgres:16-alpine, not postgres:latest, so a new release cannot break your build overnight. Match the major version you run in production.
  • Share containers. Prefer static fields or a shared test configuration over a new container per test.
  • Isolate data. Clean up between tests, for example with @Transactional tests that roll back, or by truncating tables in a @BeforeEach method. Do not rely on test execution order.
  • Keep unit tests too. Testcontainers is for integration tests. Pure business logic is still best tested with plain, fast unit tests.
  • Prepare CI. GitHub Actions' Ubuntu runners include Docker. In other environments, make sure the build agent can start containers.

Exercise

Write a @DataJpaTest that verifies a native PostgreSQL query using jsonb operators, for example finding products whose attributes column contains {"color": "black"}. Run the same test against H2 and observe why a real database matters.

← Explore more notes