Join our Discord Server
Collabnix Team The Collabnix Team is a diverse collective of Docker, Kubernetes, and IoT experts united by a passion for cloud-native technologies. With backgrounds spanning across DevOps, platform engineering, cloud architecture, and container orchestration, our contributors bring together decades of combined experience from various industries and technical domains.

Testcontainers Tutorial: Complete Guide to Integration Testing with Docker (2025)

6 min read

Image

What is Testcontainers?

Testcontainers is a powerful Java library that provides lightweight, throwaway instances of databases, message brokers, web browsers, or anything that can run in a Docker container for integration testing. Instead of mocking external dependencies or maintaining complex test environments, Testcontainers lets you run real instances of your dependencies during tests.

Key Features of Testcontainers

  • Real Dependencies: Test against actual databases, not in-memory alternatives
  • Isolation: Each test gets fresh, clean instances
  • Multiple Language Support: Java, .NET, Go, Python, Node.js, and more
  • CI/CD Friendly: Works seamlessly in Docker-enabled CI environments
  • Zero Configuration: Minimal setup required for most use cases

Why Use Testcontainers for Integration Testing?

Problems with Traditional Integration Testing

Traditional integration testing approaches often suffer from:

// ❌ Traditional approach with mocks
@Test
public void testUserService() {
    // Mocking doesn't test real database behavior
    when(userRepository.findById(1L)).thenReturn(mockUser);
    // This doesn't catch SQL syntax errors, constraint violations, etc.
}

Benefits of Testcontainers

  1. Real Environment Testing: Test against actual databases and services
  2. Easy Setup: No need to maintain shared test databases
  3. Parallel Execution: Each test gets isolated containers
  4. Version Testing: Test against multiple database versions easily
  5. CI/CD Integration: Runs anywhere Docker is available

Getting Started with Testcontainers

Prerequisites

  • Java 8 or higher
  • Docker installed and running
  • Maven or Gradle project

Maven Dependencies

Add these dependencies to your pom.xml:

<dependencies>
    <!-- Core Testcontainers -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>testcontainers</artifactId>
        <version>1.19.0</version>
        <scope>test</scope>
    </dependency>
    
    <!-- JUnit 5 integration -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <version>1.19.0</version>
        <scope>test</scope>
    </dependency>
    
    <!-- PostgreSQL module -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <version>1.19.0</version>
        <scope>test</scope>
    </dependency>
</dependencies>

Gradle Dependencies

For Gradle projects, add to build.gradle:

dependencies {
    testImplementation 'org.testcontainers:testcontainers:1.19.0'
    testImplementation 'org.testcontainers:junit-jupiter:1.19.0'
    testImplementation 'org.testcontainers:postgresql:1.19.0'
}

Your First Testcontainers Test

Let’s create a simple test that demonstrates Testcontainers basics:

import org.junit.jupiter.api.Test;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;

@Testcontainers
class FirstTestcontainersTest {

    @Container
    static GenericContainer<?> redis = new GenericContainer<>(DockerImageName.parse("redis:6-alpine"))
            .withExposedPorts(6379);

    @Test
    void testRedisConnection() {
        String redisHost = redis.getHost();
        Integer redisPort = redis.getMappedPort(6379);
        
        // Your Redis connection logic here
        assertTrue(redis.isRunning());
        assertNotNull(redisHost);
        assertTrue(redisPort > 0);
    }
}

Understanding the Code

  • @Testcontainers: Enables Testcontainers for the test class
  • @Container: Marks a container that should be started before tests
  • static: Ensures the container is shared across all test methods
  • withExposedPorts(): Exposes container ports to the host

Database Testing with Testcontainers

PostgreSQL Integration Testing

Here’s a comprehensive example testing a user service with PostgreSQL:

import org.junit.jupiter.api.BeforeEach;
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.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

@SpringBootTest
@Testcontainers
class UserServiceIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test")
            .withInitScript("schema.sql");

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
    }

    @Autowired
    private UserService userService;

    @Autowired
    private UserRepository userRepository;

    @BeforeEach
    void setUp() {
        userRepository.deleteAll();
    }

    @Test
    void shouldCreateAndFindUser() {
        // Given
        User user = new User("[email protected]", "John Doe");
        
        // When
        User savedUser = userService.createUser(user);
        Optional<User> foundUser = userService.findById(savedUser.getId());
        
        // Then
        assertTrue(foundUser.isPresent());
        assertEquals("[email protected]", foundUser.get().getEmail());
        assertEquals("John Doe", foundUser.get().getName());
    }

    @Test
    void shouldHandleDuplicateEmail() {
        // Given
        User user1 = new User("[email protected]", "User One");
        User user2 = new User("[email protected]", "User Two");
        
        // When
        userService.createUser(user1);
        
        // Then
        assertThrows(DuplicateEmailException.class, () -> {
            userService.createUser(user2);
        });
    }
}

Database Migration Testing

Test your database migrations with Testcontainers:

@Test
void shouldRunMigrationsSuccessfully() {
    // Container starts with clean database
    assertTrue(postgres.isRunning());
    
    // Verify tables were created by migrations
    try (Connection connection = DriverManager.getConnection(
            postgres.getJdbcUrl(), 
            postgres.getUsername(), 
            postgres.getPassword())) {
        
        DatabaseMetaData metaData = connection.getMetaData();
        ResultSet tables = metaData.getTables(null, null, "users", null);
        assertTrue(tables.next(), "Users table should exist");
    }
}

Testing Microservices with Testcontainers

Multi-Container Testing

Test complex scenarios involving multiple services:

@Testcontainers
class MicroservicesIntegrationTest {

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14");

    @Container
    static GenericContainer<?> redis = new GenericContainer<>("redis:6-alpine")
            .withExposedPorts(6379);

    @Container
    static MockServerContainer mockServer = new MockServerContainer(
            DockerImageName.parse("mockserver/mockserver:5.13.2"));

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        // Database configuration
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
        
        // Redis configuration
        registry.add("spring.redis.host", redis::getHost);
        registry.add("spring.redis.port", () -> redis.getMappedPort(6379));
        
        // External service configuration
        registry.add("external.service.url", 
                () -> "http://" + mockServer.getHost() + ":" + mockServer.getServerPort());
    }

    @Test
    void shouldProcessOrderWithAllDependencies() {
        // Setup mock external service
        mockExternalServiceResponse();
        
        // Test your complete workflow
        Order order = orderService.processOrder(createTestOrder());
        
        assertNotNull(order.getId());
        assertEquals(OrderStatus.PROCESSED, order.getStatus());
        
        // Verify cache was updated
        assertTrue(cacheService.exists("order:" + order.getId()));
    }
}

Network Communication Testing

Test service-to-service communication:

@Testcontainers
class ServiceCommunicationTest {

    static Network network = Network.newNetwork();

    @Container
    static GenericContainer<?> userService = new GenericContainer<>("user-service:latest")
            .withNetwork(network)
            .withNetworkAliases("user-service")
            .withExposedPorts(8080);

    @Container
    static GenericContainer<?> orderService = new GenericContainer<>("order-service:latest")
            .withNetwork(network)
            .withNetworkAliases("order-service")
            .withExposedPorts(8080)
            .withEnv("USER_SERVICE_URL", "http://user-service:8080");

    @Test
    void shouldCommunicateBetweenServices() {
        // Test that order service can communicate with user service
        String orderServiceUrl = "http://" + orderService.getHost() + 
                                ":" + orderService.getMappedPort(8080);
        
        // Make request to order service that internally calls user service
        ResponseEntity<Order> response = restTemplate.postForEntity(
                orderServiceUrl + "/orders", 
                createOrderRequest(), 
                Order.class);
        
        assertEquals(HttpStatus.CREATED, response.getStatusCode());
    }
}

Advanced Testcontainers Patterns

Custom Container Configuration

Create reusable container configurations:

public class CustomPostgreSQLContainer extends PostgreSQLContainer<CustomPostgreSQLContainer> {
    
    private static final String IMAGE_VERSION = "postgres:14";
    
    public CustomPostgreSQLContainer() {
        super(IMAGE_VERSION);
        this.withDatabaseName("myapp")
            .withUsername("myuser")
            .withPassword("mypass")
            .withInitScript("init.sql")
            .withTmpFs(Map.of("/var/lib/postgresql/data", "rw"));
    }
    
    public CustomPostgreSQLContainer withCustomConfig() {
        this.withCommand("postgres", "-c", "max_connections=200");
        return this;
    }
}

Container Lifecycle Management

Control container startup and cleanup:

@TestMethodOrder(OrderAnnotation.class)
class LifecycleTest {

    static PostgreSQLContainer<?> postgres;

    @BeforeAll
    static void startContainer() {
        postgres = new PostgreSQLContainer<>("postgres:14");
        postgres.start();
    }

    @AfterAll
    static void stopContainer() {
        if (postgres != null) {
            postgres.stop();
        }
    }

    @Test
    @Order(1)
    void setupData() {
        // Setup test data
    }

    @Test
    @Order(2)
    void testWithExistingData() {
        // Test using data from previous test
    }
}

Wait Strategies

Implement custom wait strategies for complex scenarios:

@Container
static GenericContainer<?> app = new GenericContainer<>("my-app:latest")
        .withExposedPorts(8080)
        .waitingFor(
            Wait.forHttp("/health")
                .forStatusCode(200)
                .withStartupTimeout(Duration.ofMinutes(2))
        );

// Custom wait strategy
@Container
static GenericContainer<?> customApp = new GenericContainer<>("custom-app:latest")
        .withExposedPorts(8080)
        .waitingFor(new WaitStrategy() {
            @Override
            public WaitStrategy withStartupTimeout(Duration startupTimeout) {
                return this;
            }

            @Override
            public void waitUntilReady(WaitStrategyTarget waitStrategyTarget) {
                // Custom readiness logic
            }
        });

Best Practices and Performance Tips

1. Container Reuse

Reuse containers across tests for better performance:

@TestMethodOrder(OrderAnnotation.class)
class OptimizedTest {
    
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14");
    
    @BeforeEach
    void cleanDatabase() {
        // Clean data instead of restarting container
        jdbcTemplate.execute("TRUNCATE TABLE users RESTART IDENTITY CASCADE");
    }
}

2. Use Specific Tags

Always use specific image tags for reproducible tests:

// ✅ Good - specific version
new PostgreSQLContainer<>("postgres:14.5")

// ❌ Avoid - latest can change
new PostgreSQLContainer<>("postgres:latest")

3. Optimize Docker Images

Use lightweight images when possible:

// ✅ Alpine images are smaller and faster
new GenericContainer<>("redis:6-alpine")
new PostgreSQLContainer<>("postgres:14-alpine")

4. Parallel Test Execution

Configure tests for parallel execution:

// In junit-platform.properties
junit.jupiter.execution.parallel.enabled=true
junit.jupiter.execution.parallel.mode.default=concurrent
junit.jupiter.execution.parallel.mode.classes.default=concurrent

5. Resource Limits

Set resource limits for containers:

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14")
        .withCreateContainerCmdModifier(cmd -> cmd.getHostConfig()
                .withMemory(512L * 1024 * 1024) // 512 MB
                .withCpuCount(1L));

6. Test Data Management

Use builders for test data:

public class UserTestDataBuilder {
    private String email = "[email protected]";
    private String name = "Test User";
    
    public UserTestDataBuilder withEmail(String email) {
        this.email = email;
        return this;
    }
    
    public UserTestDataBuilder withName(String name) {
        this.name = name;
        return this;
    }
    
    public User build() {
        return new User(email, name);
    }
}

// Usage in tests
@Test
void testUserCreation() {
    User user = new UserTestDataBuilder()
            .withEmail("[email protected]")
            .withName("John Doe")
            .build();
    
    User saved = userService.save(user);
    assertNotNull(saved.getId());
}

Common Issues and Troubleshooting

1. Docker Not Available

// Check if Docker is available
@Test
void checkDockerAvailability() {
    assertTrue(DockerClientFactory.instance().isDockerAvailable(), 
               "Docker should be available for tests");
}

2. Port Conflicts

Use random ports to avoid conflicts:

// ✅ Good - uses random port
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14");

// ❌ Avoid - fixed port can conflict
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14")
        .withExposedPorts(5432);

3. Slow Startup Times

Optimize container startup:

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14-alpine")
        .withTmpFs(Map.of("/var/lib/postgresql/data", "rw")) // Use tmpfs for speed
        .withCommand("postgres", "-c", "fsync=off") // Disable fsync for tests
        .withStartupTimeout(Duration.ofSeconds(30));

4. Memory Issues

Monitor and limit memory usage:

// Set JVM options for tests
// -Xmx2g -XX:+UseG1GC

@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:14")
        .withCreateContainerCmdModifier(cmd -> cmd.getHostConfig()
                .withMemory(256L * 1024 * 1024)); // Limit container memory

5. Network Connectivity Issues

Debug network problems:

@Test
void debugNetworkConnectivity() {
    // Log container details
    System.out.println("Container ID: " + postgres.getContainerId());
    System.out.println("JDBC URL: " + postgres.getJdbcUrl());
    System.out.println("Host: " + postgres.getHost());
    System.out.println("Port: " + postgres.getMappedPort(5432));
    
    // Test connectivity
    try (Connection conn = DriverManager.getConnection(
            postgres.getJdbcUrl(), 
            postgres.getUsername(), 
            postgres.getPassword())) {
        assertTrue(conn.isValid(5));
    } catch (SQLException e) {
        fail("Failed to connect to database: " + e.getMessage());
    }
}

Testcontainers vs Alternatives

Comparison with Other Testing Approaches

ApproachProsConsBest For
TestcontainersReal dependencies, Isolated, Easy setupRequires Docker, Slower than mocksIntegration testing, Complex scenarios
In-memory databasesFast, No external dependenciesLimited feature support, Not production-likeUnit testing, Simple queries
MockingVery fast, Full controlDoesn’t test real behaviorUnit testing, Isolated components
Shared test DBReal database, Fast setupShared state, Maintenance overheadLegacy systems, Limited CI resources

When to Use Testcontainers

Use Testcontainers when:

  • Testing database-specific features (constraints, triggers, etc.)
  • Testing migrations and schema changes
  • Integration testing with multiple services
  • Testing against specific database versions
  • CI/CD pipelines with Docker support

Don’t use Testcontainers when:

  • Writing pure unit tests
  • Docker is not available in your environment
  • Performance is critical (use sparingly)
  • Testing simple CRUD operations (consider alternatives)

Conclusion

Testcontainers revolutionizes integration testing by providing real, isolated instances of your dependencies. This comprehensive guide covered:

  • Getting started with Testcontainers setup and basic usage
  • Database testing patterns for robust data layer testing
  • Microservices testing with multiple containers and networks
  • Advanced patterns for complex scenarios and custom configurations
  • Best practices for performance and maintainability
  • Troubleshooting common issues and problems

Key Takeaways

  1. Real over Mock: Test against real dependencies when possible
  2. Isolation: Each test should have clean, isolated environments
  3. Performance: Balance test reliability with execution speed
  4. Maintainability: Use patterns that scale with your test suite
  5. CI/CD Ready: Ensure tests work in automated environments

Next Steps

  • Start with simple database tests using provided modules
  • Gradually introduce multi-container scenarios
  • Implement custom containers for your specific services
  • Optimize test performance based on your needs
  • Share patterns and configurations across your team

Testcontainers transforms integration testing from a painful necessity into a powerful development tool. By following the patterns and practices in this guide, you’ll build more reliable software with confidence in your test suite.


Ready to implement Testcontainers in your project? Start with the database testing examples and gradually expand to more complex scenarios. Your future self will thank you for the robust test coverage.

Have Queries? Join https://launchpass.com/collabnix

Image
Collabnix Team The Collabnix Team is a diverse collective of Docker, Kubernetes, and IoT experts united by a passion for cloud-native technologies. With backgrounds spanning across DevOps, platform engineering, cloud architecture, and container orchestration, our contributors bring together decades of combined experience from various industries and technical domains.
Join our Discord Server
Index