Testcontainers – integration testing

TL;DR: Testcontainers to przełomowa biblioteka Java, która uruchamia prawdziwe bazy danych i serwisy w kontenerach Docker podczas testów. Zamiast mock’ować wszystko, testujesz przeciwko rzeczywistej infrastrukturze. Wynik? Testy które rzeczywiście sprawdzają czy aplikacja działa w prawdziwym środowisku.

## Dlaczego Testcontainers to game-changer

Każdy developer Java zna ten problem: piszesz świetne testy jednostkowe, wszystko przechodzi, a aplikacja w produkcji… nie działa. Dlaczego? Bo mock’owałeś bazę danych, Redis’a, Elasticsearch i wszystko inne. Testy były szybkie, ale nie sprawdzały rzeczywistych integracji.

Testcontainers rozwiązuje ten problem fundamentalnie – pozwala uruchomić prawdziwe serwisy w kontenerach Docker i testować przeciwko nim. To oznacza koniec z H2 „udającą” PostgreSQL’a i początek testów które rzeczywiście coś weryfikują.

Co się nauczysz:

  • Jak uruchamiać prawdziwe bazy danych w kontenerach podczas testów
  • Integrację Testcontainers z JUnit 4 i Spring Boot
  • Konfigurację różnych typów kontenerów (PostgreSQL, Redis, Elasticsearch)
  • Optymalizację czasu wykonywania testów z kontenerami
  • Best practices dla testów integracyjnych z Testcontainers

Wymagania wstępne:

  • Znajomość Java 8+ i Spring Boot 2.x
  • Podstawowa wiedza o Docker i kontenerach
  • Doświadczenie z testami JUnit
  • Zainstalowany Docker na lokalnej maszynie

## Pierwsze kroki z Testcontainers

### Dodanie zależności do projektu

Zacznijmy od dodania Testcontainers do naszego projektu Maven:


    org.testcontainers
    junit-jupiter
    1.12.0
    test


    org.testcontainers
    postgresql
    1.12.0
    test

Testcontainers 1.12.0 to najnowsza stabilna wersja w sierpniu 2019 roku. Biblioteka jest aktywnie rozwijana i zyskuje coraz większą popularność w społeczności Java.

### Pierwszy test z bazą danych

Zobaczmy jak wygląda podstawowy test z PostgreSQL:

@TestMethodOrder(OrderAnnotation.class)
public class UserRepositoryIntegrationTest {
    
    @Container
    static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:11.5")
            .withDatabaseName("testdb")
            .withUsername("test")
            .withPassword("test");
    
    private DataSource dataSource;
    private UserRepository userRepository;
    
    @BeforeAll
    static void startContainer() {
        postgres.start();
    }
    
    @BeforeEach
    void setUp() {
        // Konfiguracja DataSource z parametrów kontenera
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl(postgres.getJdbcUrl());
        config.setUsername(postgres.getUsername());
        config.setPassword(postgres.getPassword());
        
        dataSource = new HikariDataSource(config);
        userRepository = new UserRepository(dataSource);
        
        // Inicjalizacja schematu
        initializeSchema();
    }
    
    @Test
    @Order(1)
    void shouldSaveAndFindUser() {
        // Given
        User user = new User("john.doe@example.com", "John", "Doe");
        
        // When
        Long userId = userRepository.save(user);
        Optional foundUser = userRepository.findById(userId);
        
        // Then
        assertThat(foundUser).isPresent();
        assertThat(foundUser.get().getEmail()).isEqualTo("john.doe@example.com");
        assertThat(foundUser.get().getFirstName()).isEqualTo("John");
    }
    
    private void initializeSchema() {
        try (Connection connection = dataSource.getConnection()) {
            connection.createStatement().execute(
                "CREATE TABLE users (" +
                "id SERIAL PRIMARY KEY, " +
                "email VARCHAR(255) UNIQUE NOT NULL, " +
                "first_name VARCHAR(100), " +
                "last_name VARCHAR(100), " +
                "created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)"
            );
        } catch (SQLException e) {
            throw new RuntimeException("Failed to initialize schema", e);
        }
    }
}
Pro tip: Używaj konkretnych wersji obrazów Docker (jak „postgres:11.5”) zamiast „latest”. To zapewnia powtarzalność testów i unika niespodzianek związanych z aktualizacjami obrazów.

## Integracja ze Spring Boot

Spring Boot 2.x doskonale współpracuje z Testcontainers. Oto jak skonfigurować test integracyjny:

@SpringBootTest
@TestPropertySource(properties = {
    "spring.datasource.url=jdbc:tc:postgresql:11.5:///testdb",
    "spring.datasource.driver-class-name=org.testcontainers.jdbc.ContainerDatabaseDriver"
})
public class OrderServiceIntegrationTest {
    
    @Autowired
    private OrderService orderService;
    
    @Autowired
    private OrderRepository orderRepository;
    
    @Test
    void shouldCreateOrderWithItems() {
        // Given
        CreateOrderRequest request = CreateOrderRequest.builder()
            .customerId(1L)
            .items(Arrays.asList(
                new OrderItem("laptop", 2, new BigDecimal("2999.99")),
                new OrderItem("mouse", 1, new BigDecimal("99.99"))
            ))
            .build();
        
        // When
        Order createdOrder = orderService.createOrder(request);
        
        // Then
        assertThat(createdOrder.getId()).isNotNull();
        assertThat(createdOrder.getCustomerId()).isEqualTo(1L);
        assertThat(createdOrder.getItems()).hasSize(2);
        assertThat(createdOrder.getTotalAmount()).isEqualTo(new BigDecimal("6099.97"));
        
        // Verify persistence
        Optional savedOrder = orderRepository.findById(createdOrder.getId());
        assertThat(savedOrder).isPresent();
        assertThat(savedOrder.get().getItems()).hasSize(2);
    }
}

JDBC URL z tc: Prefix „jdbc:tc:” w URL bazy danych to specjalna składnia Testcontainers, która automatycznie uruchamia odpowiedni kontener. Jest to najszybszy sposób na rozpoczęcie pracy z biblioteką.

## Różne typy kontenerów

### Redis dla cache’owania

public class CacheServiceIntegrationTest {
    
    @Container
    static GenericContainer redis = new GenericContainer<>("redis:5.0.5-alpine")
            .withExposedPorts(6379);
    
    private RedisTemplate redisTemplate;
    private CacheService cacheService;
    
    @BeforeEach
    void setUp() {
        // Konfiguracja Redis connection
        LettuceConnectionFactory connectionFactory = new LettuceConnectionFactory(
            redis.getContainerIpAddress(), 
            redis.getMappedPort(6379)
        );
        connectionFactory.afterPropertiesSet();
        
        redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setDefaultSerializer(new GenericJackson2JsonRedisSerializer());
        redisTemplate.afterPropertiesSet();
        
        cacheService = new CacheService(redisTemplate);
    }
    
    @Test
    void shouldCacheAndRetrieveData() {
        // Given
        String key = "user:123";
        User user = new User("test@example.com", "Test", "User");
        
        // When
        cacheService.put(key, user);
        Optional cachedUser = cacheService.get(key, User.class);
        
        // Then
        assertThat(cachedUser).isPresent();
        assertThat(cachedUser.get().getEmail()).isEqualTo("test@example.com");
    }
}

### Elasticsearch dla wyszukiwania

public class ProductSearchIntegrationTest {
    
    @Container
    static ElasticsearchContainer elasticsearch = new ElasticsearchContainer("docker.elastic.co/elasticsearch/elasticsearch:7.3.0")
            .withEnv("discovery.type", "single-node");
    
    private RestHighLevelClient client;
    private ProductSearchService searchService;
    
    @BeforeEach
    void setUp() {
        client = new RestHighLevelClient(
            RestClient.builder(HttpHost.create(elasticsearch.getHttpHostAddress()))
        );
        searchService = new ProductSearchService(client);
        
        // Create index
        setupProductIndex();
    }
    
    @Test
    void shouldSearchProductsByName() throws Exception {
        // Given
        indexProduct(new Product(1L, "MacBook Pro", "Apple laptop", new BigDecimal("2999.99")));
        indexProduct(new Product(2L, "MacBook Air", "Lightweight Apple laptop", new BigDecimal("1999.99")));
        
        // Wait for indexing
        Thread.sleep(1000);
        
        // When
        List results = searchService.searchByName("MacBook");
        
        // Then
        assertThat(results).hasSize(2);
        assertThat(results).extracting(Product::getName)
            .containsExactlyInAnyOrder("MacBook Pro", "MacBook Air");
    }
}

## Optymalizacja wydajności testów

### Reużycie kontenerów

Największym wyzwaniem z Testcontainers jest czas uruchamiania kontenerów. Oto kilka strategii optymalizacji:

@TestMethodOrder(OrderAnnotation.class)
public class OptimizedIntegrationTest {
    
    // Statyczny kontener współdzielony między testami
    private static PostgreSQLContainer postgres;
    
    @BeforeAll
    static void startSharedContainer() {
        if (postgres == null) {
            postgres = new PostgreSQLContainer<>("postgres:11.5")
                .withDatabaseName("testdb")
                .withUsername("test")
                .withPassword("test")
                .withReuse(true); // Eksperymentalna funkcja reużycia
            postgres.start();
        }
    }
    
    @BeforeEach
    void cleanDatabase() {
        // Czyść bazę przed każdym testem zamiast recreate kontenera
        try (Connection conn = DriverManager.getConnection(
                postgres.getJdbcUrl(), 
                postgres.getUsername(), 
                postgres.getPassword())) {
            
            conn.createStatement().execute("TRUNCATE TABLE users, orders CASCADE");
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
    }
}
Uwaga: Funkcja withReuse(true) jest eksperymentalna w wersji 1.12.0. Używaj ostrożnie w środowiskach CI/CD, gdzie izolacja testów jest krytyczna.

### Równoległe wykonywanie testów



    org.apache.maven.plugins
    maven-surefire-plugin
    2.22.2
    
        classes
        4
        true
        
        integration
    

## Best practices i pułapki

Pułapka: Zostawianie kontenerów po nieudanych testach. Zawsze używaj @Container annotation lub try-with-resources do automatycznego sprzątania.

### Zarządzanie zasobami

public class ResourceManagementTest {
    
    @Test
    void testWithAutoCleanup() {
        // Try-with-resources automatycznie zamyka kontener
        try (PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:11.5")) {
            postgres.start();
            
            // Twoje testy tutaj
            String jdbcUrl = postgres.getJdbcUrl();
            // ...
            
        } // Kontener automatycznie zostanie zatrzymany
    }
}

### Konfiguracja logowania



    
    
     
    
    
        
    

## Troubleshooting częstych problemów

### Problem z połączeniem do Docker daemon

Typowy błąd: „Could not find a valid Docker environment” – sprawdź czy Docker działa i czy użytkownik ma odpowiednie uprawnienia.
@Test
void verifyDockerEnvironment() {
    // Sprawdź dostępność Docker przed testami
    DockerClientFactory.instance().client();
    
    // Test czy można uruchomić kontener
    try (GenericContainer container = new GenericContainer<>("hello-world:latest")) {
        container.start();
        assertThat(container.isRunning()).isTrue();
    }
}

### Timeouty i opóźnienia

@Container
static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:11.5")
    .withStartupTimeout(Duration.ofMinutes(2)) // Zwiększ timeout dla wolnych maszyn
    .waitingFor(Wait.forLogMessage(".*database system is ready to accept connections.*", 2));
Czy Testcontainers są szybsze niż embedded bazy danych?

Nie, kontenery są wolniejsze w starcie, ale oferują znacznie lepszą wierność testów. Trade-off między szybkością a dokładnością – wybierz to co jest ważniejsze dla Twojego projektu.

Jak używać Testcontainers w środowisku CI/CD?

Upewnij się że Docker jest dostępny w CI (Docker-in-Docker lub Docker socket mounting). Rozważ użycie lżejszych obrazów jak Alpine variants dla szybszego startu.

Czy mogę testować microservices z Testcontainers?

Tak! Możesz uruchomić wiele kontenerów jednocześnie i testować komunikację między serwisami. Użyj Docker Compose support w Testcontainers.

Jakie są alternatywy dla Testcontainers?

Embedded databases (H2, HSQLDB), mock’i, lub dedykowane test environments. Każde podejście ma swoje plusy i minusy w kontekście wierności vs szybkości.

Jak debugować testy z kontenerami?

Użyj .withLogConsumer() do śledzenia logów kontenera, sprawdź porty z .getMappedPort(), i rozważ pozostawienie kontenerów do manual inspection.

Czy Testcontainers działają na Windows?

Tak, ale wymagają Docker Desktop for Windows. Performance może być gorszy niż na Linux/macOS ze względu na wirtualizację.

Jak zarządzać danymi testowymi w kontenerach?

Używaj .withCopyFileToContainer() dla skryptów SQL, lub programowo przez connection do bazy. Rozważ też volume mounting dla większych datasets.

🚀 Zadanie dla Ciebie

Stwórz test integracyjny dla aplikacji e-commerce która używa PostgreSQL do przechowywania zamówień i Redis do cache’owania produktów. Test powinien:

  • Uruchomić oba kontenery jednocześnie
  • Stworzyć zamówienie z 3 produktami
  • Sprawdzić czy dane zostały poprawnie zapisane w bazie
  • Zweryfikować czy cache został zaktualizowany

Bonus: Dodaj container health check który czeka na gotowość obu serwisów.

## Przydatne zasoby

– [Testcontainers Documentation](https://www.testcontainers.org/) – oficjalna dokumentacja
– [Testcontainers GitHub](https://github.com/testcontainers/testcontainers-java) – kod źródłowy i przykłady
– [Spring Boot Testcontainers Guide](https://spring.io/blog/2019/07/17/spring-boot-2-2-0-m4-available-now) – oficjalny przewodnik Spring
– [Docker Hub](https://hub.docker.com/) – repozytorium obrazów Docker
– [Testcontainers Slack](https://testcontainers.slack.com/) – społeczność użytkowników

Testcontainers to przyszłość testów integracyjnych w Java. Zamiast udawać rzeczywiste środowisko, po prostu je uruchamiasz. Czy Twoje testy są gotowe na prawdziwą infrastrukturę?

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry