Site de Emmanuel Demey

Using Testcontainers in a Spring Boot Application

Discover how Testcontainers improves developer experience and enables robust integration testing in Spring Boot applications by automating Docker container management.

Testcontainers is a Java library that enables the execution of Docker containers programmatically. In this article, we’ll explore two main use cases for integrating Testcontainers into a Spring Boot application:

  1. Improving Developer Experience (DevEx): Automatically starting services like databases or caches when launching the application locally.
  2. Writing Integration Tests: Testing interactions between your application and external services (databases, message brokers, search engines, etc.).

Use Case 1: Enhancing Developer Experience

The Challenge

When developing a Spring Boot application that depends on external services like PostgreSQL or Redis, developers typically need to:

  • Manually install and configure these services on their machines
  • Ensure everyone on the team uses the same versions
  • Deal with inconsistent environments that can lead to “works on my machine” bugs

The Solution: Automated Service Provisioning

Testcontainers can automatically start required services when you launch your application in development mode. This eliminates manual setup and ensures consistency across your team.

Implementation

Create a configuration class with the @Configuration annotation and restrict it to the local profile:

package com.example.demo.config;

import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Profile;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.GenericContainer;

@TestConfiguration(proxyBeanMethods = false)
@Profile("local")
public 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);
    }
}

The @ServiceConnection annotation automatically configures Spring Boot properties (like database URL, username, password) based on the running container, eliminating manual configuration.

Activating the Configuration

To enable this configuration when running your application locally, add the local profile:

package com.example.demo;

import com.example.demo.config.TestcontainersConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class DemoApplication {

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

Alternatively, set the profile in your IDE or via command line:

./mvnw spring-boot:run -Dspring-boot.run.profiles=local

Benefits

  • Automation: No need to manually install PostgreSQL or Redis on each developer’s machine
  • Consistency: Everyone uses the same service versions defined in code
  • Reproducibility: Bugs are easier to reproduce since environments are identical

Use Case 2: Writing Integration Tests

The Challenge

Unit tests are great for testing individual components, but you also need integration tests to verify that your application correctly interacts with real external services. These tests should:

  • Use actual databases, message brokers, and other services
  • Run in isolation without affecting other tests
  • Be reproducible in CI/CD pipelines
  • Clean up resources automatically

The Solution: Ephemeral Test Containers

Testcontainers allows you to programmatically start Docker containers for your tests, creating isolated, reproducible test environments.

Example Scenario

Let’s test a Spring Boot REST API that:

  1. Receives data via HTTP POST
  2. Stores it in PostgreSQL
  3. Publishes a message to Apache Pulsar
  4. Indexes the data in Elasticsearch
Required Dependencies

Add these dependencies to your pom.xml:

<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>junit-jupiter</artifactId>
    <version>1.19.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <version>1.19.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>pulsar</artifactId>
    <version>1.19.0</version>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>elasticsearch</artifactId>
    <version>1.19.0</version>
    <scope>test</scope>
</dependency>

Approach 1: Manual Container Configuration

This approach gives you fine-grained control over each container’s configuration.

package com.example.demo;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.containers.PulsarContainer;
import org.testcontainers.elasticsearch.ElasticsearchContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringBootTest
@Testcontainers
class DemoApplicationTests {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
        .withDatabaseName("testdb")
        .withUsername("test")
        .withPassword("test");

    @Container
    static PulsarContainer pulsar = new PulsarContainer("apachepulsar/pulsar:3.1.1");

    @Container
    static ElasticsearchContainer elasticsearch = new ElasticsearchContainer(
        "docker.elastic.co/elasticsearch/elasticsearch:8.11.0"
    )
        .withEnv("discovery.type", "single-node")
        .withEnv("xpack.security.enabled", "false");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        // Configure Spring Boot to use the test containers
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);

        registry.add("pulsar.service-url", pulsar::getPulsarBrokerUrl);

        registry.add("spring.elasticsearch.uris",
            () -> "http://" + elasticsearch.getHttpHostAddress());
    }

    @Test
    void contextLoads() {
        // Your integration tests here
    }
}

Key Points:

  • @Testcontainers: Enables automatic container lifecycle management
  • @Container: Marks containers to be started before tests and stopped after
  • @DynamicPropertySource: Dynamically injects container URLs into Spring properties
  • Each container can be individually configured (ports, environment variables, versions, etc.)

Approach 2: Docker Compose Integration

For complex setups with multiple services, using Docker Compose is often simpler and more maintainable.

Create a compose.yml file:
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: test
      POSTGRES_PASSWORD: test
      POSTGRES_DB: testdb
    ports:
      - "5432:5432"

  pulsar:
    image: apachepulsar/pulsar:3.1.1
    command: bin/pulsar standalone
    ports:
      - "6650:6650"
      - "8080:8080"

  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
    ports:
      - "9200:9200"
Use it in your tests:
package com.example.demo;

import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
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.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import java.io.File;

@SpringBootTest
@Testcontainers
class DemoApplicationComposeTests {

    @Container
    static ComposeContainer environment = new ComposeContainer(
        new File("src/test/resources/compose.yml")
    )
        .withExposedService("postgres", 5432, Wait.forListeningPort())
        .withExposedService("pulsar", 6650, Wait.forListeningPort())
        .withExposedService("elasticsearch", 9200, Wait.forHttp("/"));

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        String postgresHost = environment.getServiceHost("postgres", 5432);
        Integer postgresPort = environment.getServicePort("postgres", 5432);
        registry.add("spring.datasource.url",
            () -> String.format("jdbc:postgresql://%s:%d/testdb", postgresHost, postgresPort));
        registry.add("spring.datasource.username", () -> "test");
        registry.add("spring.datasource.password", () -> "test");

        String pulsarHost = environment.getServiceHost("pulsar", 6650);
        Integer pulsarPort = environment.getServicePort("pulsar", 6650);
        registry.add("pulsar.service-url",
            () -> String.format("pulsar://%s:%d", pulsarHost, pulsarPort));

        String elasticHost = environment.getServiceHost("elasticsearch", 9200);
        Integer elasticPort = environment.getServicePort("elasticsearch", 9200);
        registry.add("spring.elasticsearch.uris",
            () -> String.format("http://%s:%d", elasticHost, elasticPort));
    }

    @Test
    void contextLoads() {
        // Your integration tests here
    }
}

Advantages of Docker Compose Approach:

  • Single Configuration: All services defined in one file
  • Orchestration: Testcontainers manages startup order and dependencies
  • Reusability: Same compose file can be used for local development
  • Wait Strategies: Built-in support for waiting until services are ready

Comparison: Manual vs Docker Compose

AspectManual ConfigurationDocker Compose
Setup ComplexityMore code, individual containersSingle YAML file
ControlFine-grained per containerDeclarative, service-level
ReusabilityTest-specificCan share with dev environment
OrchestrationManual coordinationAutomatic via Docker Compose
Best ForSimple scenarios, specific configsComplex multi-service setups

Best Practices

1. Use Specific Image Versions

Always pin specific versions to ensure reproducibility:

// Good
new PostgreSQLContainer<>("postgres:16-alpine")

// Bad (version can change)
new PostgreSQLContainer<>("postgres:latest")

2. Reuse Containers Across Tests

Starting containers is expensive. Reuse them when possible:

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
    .withReuse(true);

Enable reuse in ~/.testcontainers.properties:

testcontainers.reuse.enable=true

3. Use Wait Strategies

Ensure containers are fully ready before running tests:

new PostgreSQLContainer<>("postgres:16-alpine")
    .waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*", 2));

4. Clean Up Test Data

Use @BeforeEach and @AfterEach to ensure clean state between tests:

@BeforeEach
void setUp() {
    // Clean database or prepare test data
    jdbcTemplate.execute("TRUNCATE TABLE users CASCADE");
}

5. Configure Resource Limits

Prevent containers from consuming too many resources:

new PostgreSQLContainer<>("postgres:16-alpine")
    .withCreateContainerCmdModifier(cmd ->
        cmd.getHostConfig()
            .withMemory(512 * 1024 * 1024L)  // 512MB
            .withCpuCount(1L)
    );

Running Tests in CI/CD

Testcontainers works seamlessly in CI/CD pipelines. Most CI environments (GitHub Actions, GitLab CI, Jenkins) support Docker-in-Docker or Docker socket mounting.

GitHub Actions Example:

name: Integration Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Set up JDK 17
        uses: actions/setup-java@v3
        with:
          java-version: "17"
          distribution: "temurin"

      - name: Run integration tests
        run: ./mvnw verify

No special Docker configuration is needed—Testcontainers automatically detects the Docker environment.

Common Pitfalls and Solutions

1. Port Conflicts

Problem: Container ports conflict with services running on the host.

Solution: Let Testcontainers assign random ports:

// Don't specify host port
postgres.getMappedPort(5432);  // Returns random available port

2. Slow Test Startup

Problem: Starting containers for every test class is slow.

Solution: Use @SpringBootTest with webEnvironment = NONE when you don’t need the web server, and enable container reuse.

3. Resource Cleanup Issues

Problem: Containers not being cleaned up properly.

Solution: Use Ryuk (enabled by default) or configure:

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
    .withLabel("testcontainers", "true");

Advanced Features

Custom Container Images

Create custom containers with pre-loaded data:

GenericContainer<?> customDb = new GenericContainer<>(
    DockerImageName.parse("myregistry/custom-postgres:1.0")
)
    .withExposedPorts(5432)
    .withCopyFileToContainer(
        MountableFile.forClasspathResource("init.sql"),
        "/docker-entrypoint-initdb.d/init.sql"
    );

Network Communication Between Containers

Allow containers to communicate:

Network network = Network.newNetwork();

PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine")
    .withNetwork(network)
    .withNetworkAliases("postgres");

GenericContainer<?> app = new GenericContainer<>("my-app:latest")
    .withNetwork(network)
    .withEnv("DB_HOST", "postgres");

Module-Specific Containers

Testcontainers provides specialized modules with convenience methods:

// Kafka
KafkaContainer kafka = new KafkaContainer(
    DockerImageName.parse("confluentinc/cp-kafka:7.5.0")
);

// MongoDB
MongoDBContainer mongo = new MongoDBContainer("mongo:7.0");

// Neo4j
Neo4jContainer<?> neo4j = new Neo4jContainer<>("neo4j:5.13");

Conclusion

Testcontainers is a game-changer for Spring Boot development, offering two powerful use cases:

  1. Developer Experience: Automatically provision services locally, eliminating setup friction and ensuring environment consistency across your team.

  2. Integration Testing: Write robust, isolated tests against real services without complex test infrastructure or manual setup.

By adopting Testcontainers, you can:

  • Reduce “works on my machine” bugs
  • Increase confidence in your integration tests
  • Simplify onboarding for new developers
  • Ensure consistency between development, testing, and production environments

Whether you choose the manual configuration approach for fine-grained control or the Docker Compose approach for simplicity, Testcontainers provides the flexibility to match your team’s needs.

Additional Resources


Original article published in French on VaDuo Consulting Blog