Testcontainers Spring Boot: sancısız entegrasyon testleri

Spring Boot 3.4 ile Testcontainers yapılandırması için eksiksiz rehber. Güvenilir ve tekrarlanabilir entegrasyon testleri için Docker konteynerlerinde PostgreSQL, Redis ve Kafka.

Testcontainers, PostgreSQL, Redis ve Kafka ile Spring Boot entegrasyon testleri

Entegrasyon testleri, Spring Boot uygulama geliştirmede önemli bir zorluk oluşturur. Gerçek bir PostgreSQL veritabanı veya Kafka broker'ı üzerinde test yapmak ağır bir altyapının sürdürülmesini gerektirir. Testcontainers bu sorunu, testler sırasında talep üzerine Docker konteynerleri başlatarak çözer ve izole, tekrarlanabilir ortamlar garanti eder.

Spring Boot 3.4 ve Testcontainers

Spring Boot 3.4, otomatik yapılandırma ile yerel Testcontainers desteği içerir. spring-boot-testcontainers bağımlılıkları kurulumu büyük ölçüde basitleştirir ve testler arasında konteyner yeniden kullanımına olanak tanır.

Testcontainers ve Spring Boot entegrasyonunu anlamak

Testcontainers, test yürütme sırasında Docker konteynerleri başlatmak için bir Java API sunar. Dış bağımlılıkları mocklamak ya da paylaşılan test veritabanlarını sürdürmek yerine, her test çalıştırması kendi izole örneğini elde eder.

Mimari üç ana bileşene dayanır: Docker'ı yöneten Testcontainers kütüphanesi, her teknoloji için özelleşmiş modüller (PostgreSQL, Redis, Kafka) ve bağlantı parametrelerini otomatik olarak enjekte eden Spring Boot entegrasyonu.

xml
<!-- pom.xml -->
<dependencies>
    <!-- Main Testcontainers dependency -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-testcontainers</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- PostgreSQL module -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>postgresql</artifactId>
        <scope>test</scope>
    </dependency>

    <!-- JUnit 5 support -->
    <dependency>
        <groupId>org.testcontainers</groupId>
        <artifactId>junit-jupiter</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

<dependencyManagement>
    <dependencies>
        <!-- Testcontainers BOM for version management -->
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>testcontainers-bom</artifactId>
            <version>1.20.4</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

Testcontainers BOM'u, projede kullanılan tüm modüller arasında sürüm tutarlılığı sağlar.

PostgreSQL ile temel yapılandırma

En yaygın kullanım senaryosu, gerçek bir PostgreSQL veritabanına karşı test yapmaktır. Spring Boot 3.4 iki yaklaşım sunar: otomatik yapılandırma için @ServiceConnection anotasyonu veya @DynamicPropertySource aracılığıyla manuel yapılandırma.

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

    // Starts a PostgreSQL container before tests
    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
        DockerImageName.parse("postgres:16-alpine")
    );

    @Autowired
    private UserRepository userRepository;

    @Test
    void shouldSaveAndRetrieveUser() {
        // Given: a user to persist
        User user = new User();
        user.setEmail("test@example.com");
        user.setName("Test User");

        // When: save and retrieve
        User saved = userRepository.save(user);
        Optional<User> found = userRepository.findById(saved.getId());

        // Then: user is correctly persisted
        assertThat(found).isPresent();
        assertThat(found.get().getEmail()).isEqualTo("test@example.com");
    }

    @Test
    void shouldFindUserByEmail() {
        // Given: a user in database
        User user = new User();
        user.setEmail("search@example.com");
        user.setName("Search User");
        userRepository.save(user);

        // When: search by email
        Optional<User> found = userRepository.findByEmail("search@example.com");

        // Then: user is found
        assertThat(found).isPresent();
        assertThat(found.get().getName()).isEqualTo("Search User");
    }
}

@ServiceConnection anotasyonu konteyner türünü otomatik olarak algılar ve karşılık gelen Spring özelliklerini (spring.datasource.url, spring.datasource.username vb.) yapılandırır. Bu yaklaşım, tekrarlayan yapılandırma kodunu ortadan kaldırır.

Konteyner yaşam döngüsü

Statik bir alanda @Container ile konteyner, sınıfın tüm testlerinden önce bir kez başlar ve son testten sonra durur. Test başına bir konteyner için, statik olmayan bir örnek alanı kullanılmalıdır.

@DynamicPropertySource ile manuel yapılandırma

Bazı senaryolar enjekte edilen özellikler üzerinde daha ince kontrol gerektirir. @DynamicPropertySource anotasyonu, yapılandırma değerlerinin açıkça tanımlanmasına olanak tanır.

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

    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
        DockerImageName.parse("postgres:16-alpine")
    )
        // Specific container configuration
        .withDatabaseName("orders_test")
        .withUsername("test_user")
        .withPassword("test_password")
        // SQL initialization script
        .withInitScript("db/init-orders.sql");

    // Manual injection of dynamic properties
    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        // JDBC URL generated dynamically with mapped port
        registry.add("spring.datasource.url", postgres::getJdbcUrl);
        registry.add("spring.datasource.username", postgres::getUsername);
        registry.add("spring.datasource.password", postgres::getPassword);
        // Additional properties if needed
        registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
    }

    @Autowired
    private OrderRepository orderRepository;

    @Autowired
    private EntityManager entityManager;

    @Test
    void shouldPersistOrderWithItems() {
        // Given: an order with items
        Order order = new Order();
        order.setOrderNumber("ORD-2026-001");
        order.setStatus(OrderStatus.PENDING);

        OrderItem item = new OrderItem();
        item.setProductId(1L);
        item.setQuantity(2);
        item.setUnitPrice(BigDecimal.valueOf(29.99));
        order.addItem(item);

        // When: save the order
        Order saved = orderRepository.save(order);
        entityManager.flush();
        entityManager.clear();

        // Then: order and its items are persisted
        Order found = orderRepository.findById(saved.getId()).orElseThrow();
        assertThat(found.getItems()).hasSize(1);
        assertThat(found.getItems().get(0).getQuantity()).isEqualTo(2);
    }
}

withInitScript başlatma betiği, test yürütülmeden önce şemayı hazırlar veya referans verileri ekler.

Tam Spring Boot entegrasyon testleri

Tüm bileşenler yüklenmiş şekilde uygulamanın tamamını test etmek için @SpringBootTest, @DataJpaTest yerine geçer. Bu yapılandırma, tam Spring bağlamını PostgreSQL konteyneri ile birlikte başlatır.

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

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
        DockerImageName.parse("postgres:16-alpine")
    );

    @Autowired
    private UserService userService;

    @Autowired
    private TestRestTemplate restTemplate;

    @Test
    void shouldCreateUserViaApi() {
        // Given: a creation request
        CreateUserRequest request = new CreateUserRequest(
            "api@example.com",
            "API User",
            "securePassword123"
        );

        // When: call the REST API
        ResponseEntity<UserResponse> response = restTemplate.postForEntity(
            "/api/users",
            request,
            UserResponse.class
        );

        // Then: user is created successfully
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED);
        assertThat(response.getBody()).isNotNull();
        assertThat(response.getBody().email()).isEqualTo("api@example.com");
    }

    @Test
    void shouldRetrieveUserById() {
        // Given: an existing user
        UserResponse created = userService.createUser(
            new CreateUserRequest("retrieve@example.com", "Retrieve User", "password")
        );

        // When: retrieve by ID
        ResponseEntity<UserResponse> response = restTemplate.getForEntity(
            "/api/users/" + created.id(),
            UserResponse.class
        );

        // Then: user is returned
        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
        assertThat(response.getBody().name()).isEqualTo("Retrieve User");
    }
}

Otomatik yapılandırılan TestRestTemplate, rastgele bir portta başlatılan sunucuyu hedef alır ve paralel testler arasında port çakışmalarını önler.

Spring Boot mülakatlarında başarılı olmaya hazır mısın?

İnteraktif simülatörler, flashcards ve teknik testlerle pratik yap.

Testler arasında konteyner yeniden kullanımı

Bir Docker konteyneri başlatmak birkaç saniye sürer. Test yürütmesini hızlandırmak için Spring Boot 3.4, merkezi bir yapılandırma aracılığıyla konteyner yeniden kullanımına olanak tanır.

TestcontainersConfiguration.javajava
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {

    // Reusable container bean across all tests
    @Bean
    @ServiceConnection
    PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>(DockerImageName.parse("postgres:16-alpine"))
            .withReuse(true)
            .withLabel("reuse.UUID", "e06d7a87-7d7d-472e-a047-7c2c6d4b5f7a");
    }

    @Bean
    @ServiceConnection
    RedisContainer redisContainer() {
        return new RedisContainer(DockerImageName.parse("redis:7-alpine"))
            .withReuse(true)
            .withLabel("reuse.UUID", "b3c8f9d2-4a5e-4c8d-9f2a-1b3c5d7e9f0a");
    }
}

Testler aynı konteynerleri paylaşmak için bu yapılandırmayı içe aktarır.

ProductServiceIntegrationTest.javajava
@SpringBootTest
@Import(TestcontainersConfiguration.class)
class ProductServiceIntegrationTest {

    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

    @Test
    void shouldCacheProductDetails() {
        // Given: a product in database
        Product product = new Product();
        product.setName("Cached Product");
        product.setPrice(BigDecimal.valueOf(99.99));
        productRepository.save(product);

        // When: two successive calls
        ProductDto first = productService.getProductById(product.getId());
        ProductDto second = productService.getProductById(product.getId());

        // Then: second call uses cache
        assertThat(first).isEqualTo(second);
    }
}

Konteyner yeniden kullanımını etkinleştirmek için ~/.testcontainers.properties dosyasına yapılandırma eklenmelidir:

properties
# ~/.testcontainers.properties
testcontainers.reuse.enable=true
Veri temizleme

Konteyner yeniden kullanımıyla, çalıştırmalar arasında veriler kalır. Her testten önce tabloları temizlemek için @Sql veya @BeforeEach kullanılması ya da test sınıfı başına farklı bir şema yapılandırılması önerilir.

Redis ve dağıtık önbellek ile testler

Testcontainers, önbellek işlevlerini test etmek için Redis'i destekler. Redis modülü, kullanıma hazır önceden yapılandırılmış bir konteyner sağlar.

CacheServiceIntegrationTest.javajava
@SpringBootTest
@Testcontainers
class CacheServiceIntegrationTest {

    @Container
    @ServiceConnection
    static RedisContainer redis = new RedisContainer(
        DockerImageName.parse("redis:7-alpine")
    );

    @Autowired
    private CacheService cacheService;

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Test
    void shouldStoreAndRetrieveFromCache() {
        // Given: a value to cache
        String key = "user:123";
        String value = "{\"id\":123,\"name\":\"Cached User\"}";

        // When: store in cache
        cacheService.put(key, value, Duration.ofMinutes(10));

        // Then: value is retrievable
        String cached = cacheService.get(key);
        assertThat(cached).isEqualTo(value);
    }

    @Test
    void shouldExpireAfterTtl() throws InterruptedException {
        // Given: a value with short TTL
        String key = "expiring:key";
        cacheService.put(key, "temporary", Duration.ofSeconds(1));

        // When: wait for expiration
        Thread.sleep(1500);

        // Then: key has expired
        String cached = cacheService.get(key);
        assertThat(cached).isNull();
    }

    @Test
    void shouldIncrementCounter() {
        // Given: a counter key
        String counterKey = "page:views:homepage";

        // When: multiple increments
        Long first = redisTemplate.opsForValue().increment(counterKey);
        Long second = redisTemplate.opsForValue().increment(counterKey);
        Long third = redisTemplate.opsForValue().increment(counterKey);

        // Then: counter increments correctly
        assertThat(first).isEqualTo(1);
        assertThat(second).isEqualTo(2);
        assertThat(third).isEqualTo(3);
    }
}

Spring Boot, @ServiceConnection aracılığıyla RedisContainer'ı otomatik algılar ve spring.data.redis.host ile spring.data.redis.port'u yapılandırır.

Kafka ve asenkron mesajlaşma ile testler

Olay tabanlı uygulamalar gerçek bir Kafka broker'ı ile test gerektirir. Testcontainers, testlere uygun tek düğümlü bir küme başlatan bir Kafka modülü sağlar.

OrderEventIntegrationTest.javajava
@SpringBootTest
@Testcontainers
@EmbeddedKafka(partitions = 1, topics = {"order-events"})
class OrderEventIntegrationTest {

    @Container
    @ServiceConnection
    static KafkaContainer kafka = new KafkaContainer(
        DockerImageName.parse("confluentinc/cp-kafka:7.6.0")
    );

    @Autowired
    private KafkaTemplate<String, OrderEvent> kafkaTemplate;

    @Autowired
    private OrderEventConsumer orderEventConsumer;

    @Test
    void shouldPublishAndConsumeOrderEvent() throws Exception {
        // Given: an order event
        OrderEvent event = new OrderEvent(
            "ORD-2026-100",
            OrderEventType.CREATED,
            LocalDateTime.now()
        );

        // When: publish to Kafka
        kafkaTemplate.send("order-events", event.orderId(), event).get();

        // Then: event is consumed (with timeout)
        await()
            .atMost(Duration.ofSeconds(10))
            .untilAsserted(() -> {
                assertThat(orderEventConsumer.getReceivedEvents())
                    .hasSize(1)
                    .first()
                    .extracting(OrderEvent::orderId)
                    .isEqualTo("ORD-2026-100");
            });
    }
}
OrderEventConsumer.javajava
@Component
public class OrderEventConsumer {

    private final List<OrderEvent> receivedEvents = new CopyOnWriteArrayList<>();

    @KafkaListener(topics = "order-events", groupId = "test-group")
    public void consume(OrderEvent event) {
        receivedEvents.add(event);
    }

    public List<OrderEvent> getReceivedEvents() {
        return List.copyOf(receivedEvents);
    }

    public void clear() {
        receivedEvents.clear();
    }
}

Awaitility kütüphanesi, testlerde kırılgan Thread.sleep çağrılarını önleyerek zaman aşımı ile asenkron iddiaları yönetir.

Docker Compose ile çoklu konteyner yapılandırması

Birbirine bağlı birden fazla servis gerektiren karmaşık uygulamalar için Testcontainers, Docker Compose dosyalarını destekler.

yaml
# src/test/resources/docker-compose-test.yml
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: testdb
      POSTGRES_USER: testuser
      POSTGRES_PASSWORD: testpass
    ports:
      - "5432"

  redis:
    image: redis:7-alpine
    ports:
      - "6379"

  localstack:
    image: localstack/localstack:3.0
    environment:
      SERVICES: s3,sqs
      DEFAULT_REGION: eu-west-1
    ports:
      - "4566"
FullStackIntegrationTest.javajava
@SpringBootTest
@Testcontainers
class FullStackIntegrationTest {

    @Container
    static DockerComposeContainer<?> environment = new DockerComposeContainer<>(
        new File("src/test/resources/docker-compose-test.yml")
    )
        .withExposedService("postgres", 5432)
        .withExposedService("redis", 6379)
        .withExposedService("localstack", 4566)
        .waitingFor("postgres", Wait.forListeningPort())
        .waitingFor("redis", Wait.forListeningPort())
        .waitingFor("localstack", Wait.forLogMessage(".*Ready\\.$", 1));

    @DynamicPropertySource
    static void configureProperties(DynamicPropertyRegistry registry) {
        // PostgreSQL configuration
        String postgresHost = environment.getServiceHost("postgres", 5432);
        Integer postgresPort = environment.getServicePort("postgres", 5432);
        registry.add("spring.datasource.url",
            () -> "jdbc:postgresql://" + postgresHost + ":" + postgresPort + "/testdb");
        registry.add("spring.datasource.username", () -> "testuser");
        registry.add("spring.datasource.password", () -> "testpass");

        // Redis configuration
        String redisHost = environment.getServiceHost("redis", 6379);
        Integer redisPort = environment.getServicePort("redis", 6379);
        registry.add("spring.data.redis.host", () -> redisHost);
        registry.add("spring.data.redis.port", () -> redisPort);

        // LocalStack S3 configuration
        String localstackHost = environment.getServiceHost("localstack", 4566);
        Integer localstackPort = environment.getServicePort("localstack", 4566);
        registry.add("aws.s3.endpoint",
            () -> "http://" + localstackHost + ":" + localstackPort);
    }

    @Autowired
    private FileStorageService fileStorageService;

    @Test
    void shouldUploadFileToS3() {
        // Given: a file to upload
        byte[] content = "Test file content".getBytes();
        String fileName = "test-file.txt";

        // When: upload to S3 via LocalStack
        String url = fileStorageService.upload(fileName, content);

        // Then: file is accessible
        assertThat(url).contains(fileName);
        byte[] downloaded = fileStorageService.download(fileName);
        assertThat(downloaded).isEqualTo(content);
    }
}

Pratik yapmaya başla!

Mülakat simülatörleri ve teknik testlerle bilgini test et.

En iyi uygulamalar ve performans optimizasyonu

Testcontainers'ın verimli yürütülmesi, derleme sürelerini azaltmak için bazı optimizasyonlar gerektirir.

AbstractIntegrationTest.javajava
@SpringBootTest
@Testcontainers
@ActiveProfiles("test")
public abstract class AbstractIntegrationTest {

    // Shared container across all inheriting classes
    @Container
    @ServiceConnection
    protected static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
        DockerImageName.parse("postgres:16-alpine")
    )
        .withReuse(true);

    @Autowired
    protected JdbcTemplate jdbcTemplate;

    @BeforeEach
    void cleanDatabase() {
        // Clean tables in order to respect FK constraints
        jdbcTemplate.execute("TRUNCATE TABLE order_items CASCADE");
        jdbcTemplate.execute("TRUNCATE TABLE orders CASCADE");
        jdbcTemplate.execute("TRUNCATE TABLE users CASCADE");
    }
}
UserIntegrationTest.javajava
class UserIntegrationTest extends AbstractIntegrationTest {

    @Autowired
    private UserRepository userRepository;

    @Test
    void shouldCreateUser() {
        // PostgreSQL container already started via parent class
        User user = new User();
        user.setEmail("inherited@test.com");
        user.setName("Inherited Test");

        User saved = userRepository.save(user);

        assertThat(saved.getId()).isNotNull();
    }
}

Soyut sınıf, konteyner yapılandırmasını ve veritabanı temizliğini merkezileştirerek kod yinelenmesini önler.

properties
# src/test/resources/application-test.properties
# Test-specific configuration
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=false

# Disable Flyway/Liquibase if using ddl-auto
spring.flyway.enabled=false

# Reduced connection pool for tests
spring.datasource.hikari.maximum-pool-size=5
spring.datasource.hikari.minimum-idle=2
Hafif Docker imajları

Daha hafif olan ve daha hızlı başlayan Alpine imajları (postgres:16-alpine, redis:7-alpine) tercih edilmelidir. Testlerde tam imajlardan işlevsel fark ihmal edilebilir düzeydedir.

Veritabanı geçişlerinin testleri

Testcontainers, gerçek bir veritabanına karşı Flyway veya Liquibase geçişlerini test etmede başarılıdır.

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

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>(
        DockerImageName.parse("postgres:16-alpine")
    );

    @Autowired
    private Flyway flyway;

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Test
    void shouldApplyAllMigrations() {
        // Given: migrations applied at startup

        // When: check state
        MigrationInfoService info = flyway.info();

        // Then: all migrations are applied
        assertThat(info.pending()).isEmpty();
        assertThat(info.applied()).isNotEmpty();
    }

    @Test
    void shouldCreateExpectedTables() {
        // Given: migrations applied

        // When: query system tables
        List<String> tables = jdbcTemplate.queryForList(
            "SELECT table_name FROM information_schema.tables " +
            "WHERE table_schema = 'public' AND table_type = 'BASE TABLE'",
            String.class
        );

        // Then: expected tables exist
        assertThat(tables).contains("users", "orders", "order_items", "products");
    }

    @Test
    void shouldHaveCorrectColumnTypes() {
        // Given: users table created

        // When: verify schema
        List<Map<String, Object>> columns = jdbcTemplate.queryForList(
            "SELECT column_name, data_type, is_nullable " +
            "FROM information_schema.columns " +
            "WHERE table_name = 'users'"
        );

        // Then: columns have correct types
        assertThat(columns)
            .extracting(c -> c.get("column_name"))
            .contains("id", "email", "name", "created_at");
    }
}

Bu testler, üretim dağıtımından önce SQL geçişlerinin doğru çalıştığını garanti eder.

Sonuç

Testcontainers, Spring Boot entegrasyon testlerini güvenilir, tekrarlanabilir ve yerel ortamdan bağımsız hale getirerek dönüştürür. Spring Boot 3.4'ün @ServiceConnection ile yerel desteği yapılandırmayı önemli ölçüde basitleştirirken konteyner yeniden kullanımı yürütme sürelerini optimize eder.

Testcontainers Spring Boot Kontrol Listesi:

  • ✅ Yerel otomatik yapılandırma için spring-boot-testcontainers kullanın
  • ✅ Mümkün olduğunda @DynamicPropertySource yerine @ServiceConnection tercih edin
  • ✅ Ardışık çalıştırmaları hızlandırmak için withReuse(true) etkinleştirin
  • ✅ Yapılandırmayı soyut bir sınıfta veya @TestConfiguration'da merkezileştirin
  • ✅ Testler arasındaki verileri @BeforeEach veya @Sql ile temizleyin
  • ✅ Daha hızlı başlatmalar için Alpine imajlarını kullanın
  • ✅ Flyway/Liquibase geçişlerini gerçek PostgreSQL'e karşı test edin
  • ✅ Çok hizmetli ortamlar için Docker Compose'dan yararlanın

Etiketler

#testcontainers
#spring boot
#integration testing
#docker
#postgresql

Paylaş

İlgili makaleler