← Back to list

Test-Driven Development Eğitim Serisi — Bölüm 19 —@DataJpaTest ile Repository Testleri

Faz 5: Spring Boot Ekosisteminde TDD

Sahin Yelkenci · 2026-07-13 20:38 · 0 claps · 16.2 min read
#test-driven-development #spring-boot-testing #data-jpa-test #testcontainer #hibernate-jpa
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud

Test-Driven Development Eğitim Serisi — Bölüm 19 —@DataJpaTest ile Repository Testleri

Faz 5: Spring Boot Ekosisteminde TDD

» İçindekiler » Bu Bölüm Neden Önemli » @DataJpaTest’in Anatomisi » Default In-Memory DB Problemi » TestContainers ile Gerçek PostgreSQL » Transaction Yönetimi: Default Rollback Davranışı » TestEntityManager API » Query Method Testleri » Custom Repository Implementation Testleri » Entity Listener’lar ve JPA Auditing » N+1 Problemini Yakalamak » @Sql ile Veri Setup » Yaygın Tuzaklar » Bölüm 20'ye Köprü » Üç Gözlem Sorusu » Felsefi Mühür » *https://gitlab.com/sahin.yelkenci2/tdd-pure-java » [https://gitlab.com/sahin.yelkenci2/tdd-spring](https://gitlab.com/sahin.yelkenci2/tdd-spring)*

» Bu Bölüm Neden Önemli

Bölüm 18'de Spring Boot test slice’larının genel haritasını çıkardık. Şimdi en sık kullanılan ve en kritik slice’ı — @DataJpaTest'i — pratik derinliğine ele alıyoruz. Çünkü modern bir Spring Boot uygulamasında, repository katmanı sistemin omurgasıdır; bütün verinin geçtiği yer, bütün domain nesnelerinin persistence ile karşılaştığı sınır. Bu sınırın sağlam test edilmesi, sistemin tutarlılığı açısından vazgeçilmezdir.

Türkiye’deki Spring Boot pratiğinde, repository testleri çoğu zaman ya eksik ya da yanlış yazılır. Eksik yazılması, “JPA ile yazdım, çalışıyor olmalı” varsayımından gelir; geliştirici metod imzasını yazar, Spring Data’nın query derivation’ına güvenir, gerçek bir test yazmaz. Yanlış yazılması ise iki tipik biçim alır: ya @SpringBootTest ile bütün uygulamayı yükleyerek (yavaş ve aşırı), ya da plain JUnit ile mock'layarak (gerçek JPA davranışını bypass ederek, anlamsız). @DataJpaTest, bu iki uç arasında doğru ortayı sunar; ama doğru kullanılması için inceliklerini bilmek gerekir.

Bu bölümün özgün katkısı, **@DataJpaTest'in inceliklerinin tam haritasını** çıkarmaktır. Default davranışlar (in-memory H2, transaction rollback), TestContainers ile gerçek PostgreSQL kullanımı, TestEntityManager API'sinin sade gücü, query method test'lerinin doğru yazımı, custom repository implementation'larının test edilmesi, JPA auditing'in nasıl sınanacağı, N+1 problem'inin nasıl yakalanacağı, ve yaygın tuzaklar — hepsi tek bir bölümde işlenir. Şahin'in DDD odaklı pratiğinde repository pattern merkezi bir yer tutar; bu bölüm o pratiğin test boyutunu somutlaştırır.

Bir başka önemli mesele, production-prod parity (üretim-test paritesi) prensibidir. The Twelve-Factor App (Adam Wiggins, 2011) bu prensibi açıkça dile getirir: “geliştirme ortamı ile üretim ortamı mümkün olduğunca benzer olmalı.” JPA testlerinde bu prensip, test ortamında üretim veritabanı tipini kullanmak olarak somutlaşır. Eğer üretimde PostgreSQL kullanıyorsanız, testte H2 kullanmak — Spring’in @DataJpaTest default'u — yanıltıcıdır. H2 ve PostgreSQL arasında nüanslı davranış farkları vardır; bazı query'ler H2'de geçer PostgreSQL'de geçmez. TestContainers, bu paritenin pratik çözümüdür. Bu bölüm, bu çözümü standart bir pratik olarak konumlandırır.

» @DataJpaTest’in Anatomisi

@DataJpaTest annotation'ı uygulandığında, Spring Boot arka planda birkaç şey birden yapar. Bunları bilmek, slice'ın doğru kullanımı için gereklidir.

Birinci iş: belirli auto-configuration’ları aktif etmek. Bu liste şunları içerir:

  • DataSourceAutoConfiguration — veritabanı bağlantısı sağlayan DataSource bean'i
  • HibernateJpaAutoConfiguration — Hibernate JPA implementation
  • DataSourceTransactionManagerAutoConfigurationPlatformTransactionManager
  • JpaRepositoriesAutoConfiguration — Spring Data JPA repository'leri otomatik tarama

Bu auto-configuration’lar, JPA ile çalışmak için gereken minimum altyapıyı sağlar. Test sınıfında @Autowired ile EntityManager, JpaRepository'ler, DataSource enjekte edilebilir.

İkinci iş: web katmanını ve diğer auto-configuration’ları devre dışı bırakmak. Web sunucusu (Tomcat), security filter chain, scheduling, mail, message converter’lar gibi yüzlerce bean yüklenmez. Bu, context’in küçük ve hızlı kalmasını sağlar.

Üçüncü iş: test desteği bean’lerini enjekte etmek. En önemlisi TestEntityManager'dır; JPA test'leri için optimize edilmiş bir yardımcı. Standart EntityManager'a göre daha sade bir API sunar; aşağıda detaylı işleyeceğiz.

Dördüncü iş: transaction içinde çalıştırmak. Default olarak, her test metodu bir transaction içinde çalışır ve test sonunda rollback edilir. Bu, test izolasyonunu garantiler; bir testin yarattığı veriler, sonraki teste sızmaz.

Beşinci iş: in-memory veritabanı kullanmak. Default olarak, classpath’te bulunan en uygun in-memory DB (H2, HSQLDB, Derby) seçilir ve DataSource ona göre konfigüre edilir. Bu, hiçbir kurulum yapmadan testlerin çalışmasını sağlar. Ama production-prod parity açısından sorunludur; bu konuya birazdan döneceğiz.

Bu beş davranış birden tezahür ettiğinde, @DataJpaTest testlerin hem hızlı, hem izole, hem güvenilir olmasını sağlar. Ama default'lar her zaman doğru değildir; aşağıda hangi default'un ne zaman değiştirilmesi gerektiğini detaylı işleyeceğiz.

» Default In-Memory DB Problemi

Spring Boot’un @DataJpaTest default'u H2'dir (eğer classpath'te varsa). Bu, hızlı ve kurulumsuz testler için pratik bir varsayılan; ama üretim ortamı farklı bir veritabanı kullanıyorsa, anlamlı bir risk taşır.

H2 ve PostgreSQL arasındaki bazı farkları görelim:

Farklı yazım kuralları. PostgreSQL INTERVAL syntax'ı, JSONB tipi, array tipleri H2'de tam desteklenmez. PostgreSQL-spesifik bir query (SELECT * FROM users WHERE preferences->>'theme' = 'dark') H2'de derlenmez.

Farklı isolation davranışları. Concurrent erişim, lock yönetimi, isolation level davranışları farklıdır. Race condition bug’ları H2'de gözükmeyip PostgreSQL’de patlayabilir.

Farklı index ve performance. Query planlayıcı algoritmaları farklı; H2'de hızlı çalışan bir query PostgreSQL’de yavaş olabilir, ya da tersi.

Farklı sıralama default’ları. ORDER BY clause olmadan dönen kayıt sırası, iki veritabanında farklı olabilir. Bu, ince hatalara yol açar.

Farklı UNIQUE constraint davranışları. Null değerleri içeren unique constraint'lerin davranışı, PostgreSQL ve H2'de farklıdır.

Bu farkların pratik sonucu şudur: H2 testleri yanıltıcı güven verir. Testler yeşildir, ama production’a deploy edildiğinde patlamalar olur. Bu yanılgıyı önlemenin tek doğru yolu, testte gerçek production veritabanı tipini kullanmaktır. Bu kararın bir maliyeti vardır (testler yavaşlar, kurulum karmaşıklaşır); ama production sürprizlerinden kurtarır.

Eski yöntem, lokal makinede gerçek PostgreSQL kurmak veya CI/CD ortamında PostgreSQL servisini yapılandırmaktı. Bu çözümlerin sorunları vardı: lokal makineler farklı sürümler kullanırdı, CI/CD ortamında setup karmaşıktı, lokal-CI farkı çıkardı. Modern çözüm: TestContainers.

» TestContainers ile Gerçek PostgreSQL

TestContainers (Atomas Kosenko ve Sergei Egorov liderliğindeki ekip tarafından 2015'te başlatıldı), Docker container’larını JUnit test’leri içinde otomatik yöneten bir kütüphanedir. Bir test’i çalıştırdığınızda, TestContainers gerekli Docker image’ı çeker, container’ı başlatır, JDBC URL’i sağlar; test bittikten sonra container’ı temizler.

Bu yaklaşımın somut faydaları şunlardır. Birincisi: gerçek production veritabanı kullanılır; H2-PostgreSQL arası davranış farkları ortadan kalkar. İkincisi: lokal makine ve CI/CD aynı container’ı kullanır; “benim makinede çalışıyor ama CI’da kırılıyor” sorunu yok. Üçüncüsü: test izolasyonu doğal; her test (veya test sınıfı) kendi temiz container’ını alabilir.

Spring Boot 3.1 öncesinde TestContainers ile entegrasyon biraz boilerplate gerektiriyordu:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class CartRepositoryTest {
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
    @DynamicPropertySource
    static void postgresProperties(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 CartJpaRepository repository;
    @Test
    void kaydedilen_cart_bulunur() {
        // ...
    }
}

Üç önemli detaya dikkat edin. Birincisi: @AutoConfigureTestDatabase(replace = NONE) — Bu olmazsa, Spring Boot default'u devreye girer ve H2'yi kullanır; TestContainers'tan gelen DataSource göz ardı edilir. İkincisi: @Container static — container test sınıfı seviyesinde, statik olmalı; her test için yeni container açmak çok yavaş olur. Üçüncüsü: @DynamicPropertySource — container başlatıldıktan sonra elde edilen URL/username/password'ü Spring property'lerine yansıtır.

Spring Boot 3.1+ ile bu boilerplate dramatik biçimde sadeleşti:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class CartRepositoryTest {
    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
    @Autowired
    private CartJpaRepository repository;
    @Test
    void kaydedilen_cart_bulunur() {
        // ...
    }
}

@ServiceConnection annotation'ı, Spring Boot'a "bu container'a otomatik bağlan" der; manuel property override gerekmez. Sekiz satır boilerplate, tek annotation'a düşer. Spring Boot 3.1+ kullanan projeler için bu yaklaşım standart hâline gelmiştir.

Birden çok test sınıfının aynı container’ı paylaşması için Spring Boot 3.1+’da bir başka pattern kullanılır:

@TestConfiguration(proxyBeanMethods = false)
public class ContainersConfiguration {
    @Bean
    @ServiceConnection
    public PostgreSQLContainer<?> postgresContainer() {
        return new PostgreSQLContainer<>("postgres:15-alpine");
    }
}
@DataJpaTest
@Import(ContainersConfiguration.class)
class CartRepositoryTest {
    // Container otomatik başlatılır, Spring bean'i olarak yönetilir
}

Bu pattern, container’ı Spring bean’i olarak yönetir; bütün test sınıfları aynı bean’i paylaşır, dolayısıyla aynı container’ı kullanır. CI/CD performansını dramatik biçimde artırır.

Çalışan örnek kodlara erişmek için: » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring

» Transaction Yönetimi: Default Rollback Davranışı

@DataJpaTest'in en kritik default'larından biri, her test transaction içinde çalışır ve sonunda rollback edilir davranışıdır. Bu, test izolasyonunu sağlar — bir testin yarattığı veriler sonraki teste sızmaz; her test temiz bir slate ile başlar.

Bu davranışı @Transactional annotation'ı sağlar; @DataJpaTest arka planda bunu uygular. Bir test sınıfında ek olarak @Transactional yazmaya gerek yok.

Buna karşılık, bu davranışın ince tuzakları vardır.

Birinci tuzak: detached entity sorunu. Bir test, persist edilmiş bir entity’yi sonradan kullanmak isteyebilir; ama session’ı kapatıp tekrar açtıktan sonra detached olabilir. TestEntityManager.persistAndFlush ile birlikte TestEntityManager.clear kullanarak bu davranışı taklit etmek gerekir; aksi takdirde test, prod'da olmayan bir davranışı sınar.

İkinci tuzak: lazy loading session dependency. Bir entity’nin lazy ilişkileri sadece transaction aktifken yüklenebilir. Test sonunda transaction rollback olduğunda, dışarıda lazy ilişkiye erişmek LazyInitializationException fırlatır. Bu, test sınırlarını anlamayı gerektirir.

Üçüncü tuzak: @Rollback(false) kullanımı. Bazen test verisi gerçekten kalsın istersiniz (örneğin manuel inspection için). @Rollback(false) ile bu sağlanır; ama bu, diğer testlere veri sızdırır. Sadece debug için, geçici olarak kullanılmalıdır; production test'lerinde olmamalıdır.

Dördüncü tuzak: @Transactional içindeki transaction’ları test ederken. Eğer test edilen kod kendi @Transactional ile çalışan bir servis ise, test transaction'ı ile servis transaction'ı arasında nüanslı interaction olabilir. Çoğu durumda @DataJpaTest'in default davranışı bunu doğru handle eder; ama propagation REQUIRES_NEW veya NESTED ile çalışan kod test edilirken dikkat gerekir.

Pratik kural: default davranışı bilmeden değiştirmeyin. @DataJpaTest'in transaction handling'i, çoğu repository test için doğru olandır. Sadece yukarıdaki dört tuzaktan birine takıldığınızda, alternatif düşünün.

» TestEntityManager API

@DataJpaTest slice'ı, TestEntityManager adında bir test yardımcısı sağlar. Standart EntityManager'a göre daha sade ve test-odaklı bir API'ye sahiptir.

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class CartRepositoryTest {
@Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
    @Autowired
    private TestEntityManager entityManager;
    @Autowired
    private CartJpaRepository repository;
    @Test
    void kaydedilen_cart_id_ile_bulunabilir() {
        // Arrange: persist + flush + clear
        CartEntity entity = new CartEntity();
        entity.setCustomerId("c-1");
        entity.setStatus(CartStatus.ACTIVE);
        entity = entityManager.persistAndFlush(entity);
        entityManager.clear();
        // Act
        Optional<CartEntity> found = repository.findById(entity.getId());
        // Assert
        assertThat(found).isPresent();
        assertThat(found.get().getCustomerId()).isEqualTo("c-1");
    }
}

Burada üç metoda dikkat edin. **persistAndFlush**, entity'yi save eder ve hemen DB'ye yansıtır (flush). Standart persist sadece persistence context'e ekler; flush olana kadar DB'ye yazılmaz. Testlerde her zaman persistAndFlush tercih edilir; çünkü "gerçekten DB'ye yazıldı mı" sorusunun cevabını net verir.

**entityManager.clear, persistence context'i temizler. Bu, first-level cache**'i etkisizleştirir. Repository'den bir entity getirildiğinde, Hibernate cache'ten döndürebilir; cache temiz değilse, "DB'den getiriliyor gibi yapıyor ama aslında cache'ten alıyor" durumu olur. clear ile bunu engellersiniz; test gerçek DB davranışını sınar.

**entityManager.find, entityManager.merge, entityManager.remove** — standart JPA metodlarının sade versiyonları. Test setup'ında kullanıcı.

TestEntityManager ile JpaRepository arasındaki farkı kavramak önemlidir. JpaRepository Spring Data tarafından sağlanan ve test edilen şeydir; TestEntityManager ise Spring Boot Test'in sağladığı test yardımcısıdır. İlki üretim kodu, ikincisi test infrastructure'ı. Test'i karıştırmamak gerekir; setup için TestEntityManager, asıl davranışı sınamak için JpaRepository.

Çalışan örnek kodlara erişmek için: » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring

» Query Method Testleri

Spring Data JPA’nın en güçlü özelliklerinden biri, method name derivation’dır. findByCustomerIdAndStatus(CustomerId, CartStatus) gibi bir metod adı, Spring Data tarafından otomatik olarak query'ye çevrilir. Bu özellik harika; ama derivation'ın doğru çalıştığını test etmek gerekir, çünkü compile-time olarak garanti edilmez.

public interface CartJpaRepository extends JpaRepository<CartEntity, String> {
    Optional<CartEntity> findByCustomerIdAndStatus(String customerId, CartStatus status);
    List<CartEntity> findByStatusOrderByCreatedAtDesc(CartStatus status);
    long countByCustomerId(String customerId);
    @Query("SELECT c FROM CartEntity c WHERE c.customerId = :customerId AND c.totalAmount > :minTotal")
    List<CartEntity> findHighValueCartsForCustomer(
        @Param("customerId") String customerId,
        @Param("minTotal") BigDecimal minTotal
    );
}

Bu repository’nin testleri:

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

@Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
    @Autowired
    private TestEntityManager entityManager;
    @Autowired
    private CartJpaRepository repository;
    @Test
    void findByCustomerIdAndStatus_uygun_kayıt_döner() {
        // Arrange
        CartEntity active = createCart("c-1", CartStatus.ACTIVE);
        CartEntity abandoned = createCart("c-1", CartStatus.ABANDONED);
        entityManager.persistAndFlush(active);
        entityManager.persistAndFlush(abandoned);
        entityManager.clear();
        // Act
        Optional<CartEntity> found = repository.findByCustomerIdAndStatus("c-1", CartStatus.ACTIVE);
        // Assert
        assertThat(found).isPresent();
        assertThat(found.get().getStatus()).isEqualTo(CartStatus.ACTIVE);
    }
    @Test
    void findByStatusOrderByCreatedAtDesc_doğru_sırada_döner() {
        // Arrange
        CartEntity old = createCart("c-1", CartStatus.ACTIVE, Instant.parse("2024-06-01T00:00:00Z"));
        CartEntity newer = createCart("c-2", CartStatus.ACTIVE, Instant.parse("2024-06-15T00:00:00Z"));
        CartEntity newest = createCart("c-3", CartStatus.ACTIVE, Instant.parse("2024-06-20T00:00:00Z"));
        entityManager.persistAndFlush(old);
        entityManager.persistAndFlush(newer);
        entityManager.persistAndFlush(newest);
        entityManager.clear();
        // Act
        List<CartEntity> results = repository.findByStatusOrderByCreatedAtDesc(CartStatus.ACTIVE);
        // Assert
        assertThat(results)
            .extracting(CartEntity::getCustomerId)
            .containsExactly("c-3", "c-2", "c-1");
    }
    @Test
    void findHighValueCartsForCustomer_uygun_kayıtları_döner() {
        // Arrange
        CartEntity low = createCart("c-1", new BigDecimal("50.00"));
        CartEntity high = createCart("c-1", new BigDecimal("250.00"));
        CartEntity higher = createCart("c-1", new BigDecimal("500.00"));
        CartEntity otherCustomer = createCart("c-2", new BigDecimal("1000.00"));
        entityManager.persistAndFlush(low);
        entityManager.persistAndFlush(high);
        entityManager.persistAndFlush(higher);
        entityManager.persistAndFlush(otherCustomer);
        entityManager.clear();
        // Act
        List<CartEntity> results = repository.findHighValueCartsForCustomer(
            "c-1", new BigDecimal("100.00")
        );
        // Assert
        assertThat(results)
            .hasSize(2)
            .extracting(CartEntity::getTotalAmount)
            .containsExactlyInAnyOrder(new BigDecimal("250.00"), new BigDecimal("500.00"));
    }
    private CartEntity createCart(String customerId, CartStatus status) {
        CartEntity cart = new CartEntity();
        cart.setCustomerId(customerId);
        cart.setStatus(status);
        cart.setCreatedAt(Instant.now());
        return cart;
    }
    // overloaded variants...
}

Bu testlerin kritik özellikleri var. Birincisi: gerçek PostgreSQL’e karşı çalışır; H2'de geçen bir test PostgreSQL’de farklı davranabilir, ama bu test gerçek davranışı sınar. İkincisi: setup TestEntityManager ile yapılır; asıl davranış repository üzerinden sınanır. Üçüncüsü: AssertJ'nin koleksiyon assertion'ları (extracting, containsExactly, containsExactlyInAnyOrder) iş kuralının sıralamasını net biçimde ifade eder.

@Query ile yazılmış custom query'ler özellikle test gerektirir. Çünkü method name derivation'dan farklı olarak, custom JPQL veya native SQL derleme zamanında doğrulanmaz; runtime'da query parser çalışınca patlar. Bir test yazılırsa, parser hatası test çalışınca yakalanır; yazılmazsa, production'da ilk çağrıda patlar.

Çalışan örnek kodlara erişmek için: » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring

» Custom Repository Implementation Testleri

Spring Data’nın derivation’ı her query için yeterli değildir. Karmaşık koşullar, dinamik filtreleme, batch operations için custom repository implementation’ları yazılır. Tipik yapı:

// 1. Public interface (Spring Data tarafından yönetilen)
public interface CartJpaRepository extends JpaRepository<CartEntity, String>, CartRepositoryCustom {
    // method derivation'lar
}
// 2. Custom fragment interface
public interface CartRepositoryCustom {
    List<CartEntity> findWithComplexCriteria(CartSearchCriteria criteria);
}
// 3. Custom implementation (naming convention: {Interface}Impl)
@Repository
public class CartRepositoryCustomImpl implements CartRepositoryCustom {
    @PersistenceContext
    private EntityManager entityManager;
    @Override
    public List<CartEntity> findWithComplexCriteria(CartSearchCriteria criteria) {
        CriteriaBuilder cb = entityManager.getCriteriaBuilder();
        CriteriaQuery<CartEntity> query = cb.createQuery(CartEntity.class);
        Root<CartEntity> root = query.from(CartEntity.class);
        List<Predicate> predicates = new ArrayList<>();
        if (criteria.customerId() != null) {
            predicates.add(cb.equal(root.get("customerId"), criteria.customerId()));
        }
        if (criteria.minTotal() != null) {
            predicates.add(cb.greaterThanOrEqualTo(root.get("totalAmount"), criteria.minTotal()));
        }
        if (criteria.statuses() != null && !criteria.statuses().isEmpty()) {
            predicates.add(root.get("status").in(criteria.statuses()));
        }
        query.where(predicates.toArray(new Predicate[0]));
        query.orderBy(cb.desc(root.get("createdAt")));
        return entityManager.createQuery(query).getResultList();
    }
}

Bu custom implementation’ın testi, @DataJpaTest ile yazılır:

@Test
void findWithComplexCriteria_müşteri_ve_minTotal_filtresi() {
    // Arrange
    entityManager.persistAndFlush(createCart("c-1", new BigDecimal("100"), CartStatus.ACTIVE));
    entityManager.persistAndFlush(createCart("c-1", new BigDecimal("250"), CartStatus.ACTIVE));
    entityManager.persistAndFlush(createCart("c-1", new BigDecimal("500"), CartStatus.ACTIVE));
    entityManager.persistAndFlush(createCart("c-2", new BigDecimal("1000"), CartStatus.ACTIVE));
    entityManager.clear();
    CartSearchCriteria criteria = CartSearchCriteria.builder()
        .customerId("c-1")
        .minTotal(new BigDecimal("200"))
        .build();
    // Act
    List<CartEntity> results = repository.findWithComplexCriteria(criteria);
    // Assert
    assertThat(results)
        .hasSize(2)
        .extracting(CartEntity::getTotalAmount)
        .containsExactlyInAnyOrder(new BigDecimal("250"), new BigDecimal("500"));
}
@Test
void findWithComplexCriteria_status_listesi_filtresi() {
    entityManager.persistAndFlush(createCart("c-1", new BigDecimal("100"), CartStatus.ACTIVE));
    entityManager.persistAndFlush(createCart("c-1", new BigDecimal("200"), CartStatus.ABANDONED));
    entityManager.persistAndFlush(createCart("c-1", new BigDecimal("300"), CartStatus.CHECKED_OUT));
    entityManager.clear();
    CartSearchCriteria criteria = CartSearchCriteria.builder()
        .statuses(List.of(CartStatus.ACTIVE, CartStatus.ABANDONED))
        .build();
    List<CartEntity> results = repository.findWithComplexCriteria(criteria);
    assertThat(results).hasSize(2);
}

Custom repository implementation’larının test edilmesi, özellikle değerlidir; çünkü karmaşık criteria query’leri elle yazılır ve hatalara açıktır. Bir testin baskısı, query’nin doğru çalıştığının somut kanıtıdır.

Çalışan örnek kodlara erişmek için: » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring

» Entity Listener’lar ve JPA Auditing

JPA’nın güçlü özelliklerinden biri, entity lifecycle event’leri ve auditing’tir. @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy annotation'ları, otomatik olarak entity oluşturma ve güncelleme bilgilerini takip eder. Bu özelliğin doğru çalıştığını test etmek önemlidir.

@Entity
@EntityListeners(AuditingEntityListener.class)
public class CartEntity {
@Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private String id;
    @Column(nullable = false)
    private String customerId;
    @Enumerated(EnumType.STRING)
    private CartStatus status;
    @CreatedDate
    private Instant createdAt;
    @LastModifiedDate
    private Instant updatedAt;
    @CreatedBy
    private String createdBy;
    @LastModifiedBy
    private String lastModifiedBy;
    // ...
}

JPA auditing’in aktif olması için, ana konfigürasyonda @EnableJpaAuditing annotation'ı gerekir. Test'te bu auditing'in çalıştığını sınamak için:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import(JpaAuditingConfig.class)   // @EnableJpaAuditing içeren config
@Testcontainers
class CartEntityAuditingTest {
@Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
    @Autowired
    private TestEntityManager entityManager;
    @Autowired
    private CartJpaRepository repository;
    @Test
    void cart_kaydedildiğinde_createdAt_doldurulur() {
        CartEntity cart = new CartEntity();
        cart.setCustomerId("c-1");
        cart.setStatus(CartStatus.ACTIVE);
        CartEntity persisted = repository.save(cart);
        entityManager.flush();
        assertThat(persisted.getCreatedAt()).isNotNull();
        assertThat(persisted.getCreatedAt())
            .isCloseTo(Instant.now(), within(5, ChronoUnit.SECONDS));
    }
    @Test
    void cart_güncellendiğinde_updatedAt_değişir() throws InterruptedException {
        CartEntity cart = new CartEntity();
        cart.setCustomerId("c-1");
        cart.setStatus(CartStatus.ACTIVE);
        CartEntity persisted = repository.save(cart);
        entityManager.flush();
        Instant originalUpdatedAt = persisted.getUpdatedAt();
        Thread.sleep(10);   // Zamanda fark yaratma
        persisted.setStatus(CartStatus.CHECKED_OUT);
        repository.save(persisted);
        entityManager.flush();
        assertThat(persisted.getUpdatedAt()).isAfter(originalUpdatedAt);
    }
}

@CreatedBy ve @LastModifiedBy için ise bir AuditorAware implementation'ı gerekir; bu, mevcut kullanıcıyı sağlar. Test ortamında bu genellikle sabit bir değer döndüren bir bean'dir:

@TestConfiguration
public class TestAuditingConfig {
@Bean
    public AuditorAware<String> testAuditorAware() {
        return () -> Optional.of("test-user");
    }
}

Çalışan örnek kodlara erişmek için: » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring

» N+1 Problemini Yakalamak

JPA’nın en yaygın performance sorunu, N+1 query problem’idir. Bir parent entity koleksiyonu çekildiğinde, her bir parent’ın child’ları için ayrı bir query çıkarsa, “1 query + N query” pattern’i oluşur. Bir liste 100 elementliyse, 1 + 100 = 101 query çalışır.

Bu sorunu test’te yakalamak, hibernate session statistics ile mümkündür:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
class CartLoadingPerformanceTest {
@Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
    @Autowired
    private TestEntityManager entityManager;
    @Autowired
    private CartJpaRepository repository;
    @Test
    void findAll_with_items_uses_single_query() {
        // Arrange: 10 cart, her birinde 3 item
        for (int i = 0; i < 10; i++) {
            CartEntity cart = new CartEntity();
            cart.setCustomerId("c-" + i);
            cart.setStatus(CartStatus.ACTIVE);
            for (int j = 0; j < 3; j++) {
                CartItemEntity item = new CartItemEntity();
                item.setProductId("p-" + j);
                item.setQuantity(1);
                cart.getItems().add(item);
            }
            entityManager.persistAndFlush(cart);
        }
        entityManager.clear();
        // Act
        Statistics stats = getStatistics();
        stats.clear();
        List<CartEntity> carts = repository.findAllWithItems();   // JOIN FETCH ile yazılmış
        carts.forEach(cart -> cart.getItems().size());   // items'a dokun
        // Assert: tek query çalışmalı (JOIN FETCH sayesinde)
        assertThat(stats.getQueryExecutionCount()).isEqualTo(1);
    }
    private Statistics getStatistics() {
        EntityManagerFactory emf = entityManager.getEntityManager()
            .getEntityManagerFactory();
        SessionFactory sf = emf.unwrap(SessionFactory.class);
        return sf.getStatistics();
    }
}

Hibernate Statistics’i aktif etmek için application-test.yml'de:

spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true

generate_statistics: true property'si Hibernate'in query sayım ve diğer metrikleri tutmasını sağlar. Bu, performans testlerini mümkün kılar.

Vlad Mihalcea’nın High-Performance Java Persistence (2016) kitabı, JPA performans test’leri için kanonik kaynaktır. N+1 yakalama, fetch strategy testleri, batch insert testleri, cache testleri — hepsi bu kitapta detaylı işlenir.

Çalışan örnek kodlara erişmek için: » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring

» @Sql ile Veri Setup

Karmaşık veri setup’ları için @Sql annotation'ı kullanılır. SQL script'leri ile veri kurulabilir:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Testcontainers
@Sql(scripts = "/sql/cart-test-data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_METHOD)
class CartReportingRepositoryTest {
    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15-alpine");
    @Autowired
    private CartJpaRepository repository;
    @Test
    void aktif_carts_doğru_sayıda_döner() {
        long count = repository.countByStatus(CartStatus.ACTIVE);
        // SQL script'in yarattığı 5 aktif cart bekleniyor
        assertThat(count).isEqualTo(5);
    }
}
-- /test/resources/sql/cart-test-data.sql
INSERT INTO cart (id, customer_id, status, total_amount, created_at) VALUES
    ('c-001', 'cust-1', 'ACTIVE', 100.00, '2024-06-01 10:00:00'),
    ('c-002', 'cust-2', 'ACTIVE', 250.00, '2024-06-02 11:00:00'),
    ('c-003', 'cust-3', 'ABANDONED', 75.00, '2024-06-01 14:00:00'),
    ('c-004', 'cust-1', 'ACTIVE', 500.00, '2024-06-03 09:00:00'),
    ('c-005', 'cust-2', 'CHECKED_OUT', 300.00, '2024-06-02 16:00:00'),
    ('c-006', 'cust-3', 'ACTIVE', 150.00, '2024-06-04 12:00:00'),
    ('c-007', 'cust-1', 'ACTIVE', 200.00, '2024-06-05 13:00:00');

@Sql ile setup, TestEntityManager kullanmaktan daha sade olabilir; özellikle çok sayıda kayıt gerektiğinde. Trade-off şudur: SQL script'leri veritabanı şemasına bağımlıdır (column isimleri, constraint'ler), refactor sırasında script'leri güncellemek gerekir. TestEntityManager ile setup ise entity'ler değişince otomatik uyum sağlar.

Pratik kural: basit setup için TestEntityManager, çok kayıt veya karmaşık state için @Sql. Hibrid kullanım da mümkündür.

Çalışan örnek kodlara erişmek için: » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring

» Yaygın Tuzaklar

@DataJpaTest ile karşılaşılan birkaç yaygın tuzağı listeleyelim. Bu tuzaklara takılan geliştiriciler genellikle "test çalışmıyor" diye saatler kaybeder; ama anlayınca çözümü hızlıdır.

Tuzak 1: H2 ile geçen test, PostgreSQL’de kırılıyor. Yukarıda detaylı işledik; çözüm TestContainers ile production parity.

Tuzak 2: persistAndFlush yapmadan repository’den okumak. Persist edilen ama flush edilmemiş entity, repository find’ında bulunmaz. entityManager.persistAndFlush veya entityManager.flush zorunludur.

Tuzak 3: clear yapmadan ikinci kez okumak. Persistence context cache’e takılan entity, repository find’ında DB’den gelmez; cache’ten döner. entityManager.clear ile gerçek DB davranışını sınayabilirsiniz.

Tuzak 4: Lazy ilişkilere transaction dışında erişmek. Test sonunda transaction kapanır; lazy ilişkilere o noktadan sonra erişmek LazyInitializationException fırlatır. Çözüm: assertion'ları transaction içinde yapın, veya @Transactional(propagation = NOT_SUPPORTED) ile manuel kontrol edin.

Tuzak 5: Veritabanı şeması güncel değil. Hibernate ddl-auto ayarı testte create-drop olmazsa, eski şema kullanılır. application-test.yml'de açıkça konfigüre edin.

Tuzak 6: @DataJpaTest in-memory DB’yi REPLACE ediyor. TestContainers ile birlikte kullanırken, @AutoConfigureTestDatabase(replace = NONE) annotation'ı unutulursa, Spring otomatik olarak default'a (H2) döner. TestContainers kullanıyorsanız bu annotation zorunlu.

Bu tuzaklar, deneyimle öğrenilen şeylerdir. Burada listelenmesi, takıma yeni katılan geliştiricilere bir referans olarak değerlidir.

» Bölüm 20'ye Köprü

Bu bölümde, @DataJpaTest slice'ının tam pratik haritasını çıkardık. Anatomi, TestContainers ile production parity, transaction yönetimi, TestEntityManager API, query method testleri, custom repository implementation'ları, JPA auditing testleri, N+1 performance testleri, @Sql setup, ve yaygın tuzakları sistemli işledik. Modern bir DDD/TDD pratisyeninin repository test pratiğinin omurgası, bu bölümde somutlaştı.

Bir sonraki bölümde, **@WebMvcTest** slice'ına geçiyoruz. Controller testleri, MockMvc API, JSON serialization sınama, validation sınama, exception handler testleri, security ile entegrasyon, REST documentation üretme — bunları detaylı işleyeceğiz. Bölüm 19 repository'lerin (persistence layer) test pratiğini verdi; Bölüm 20 controller'ların (web layer) test pratiğini verecek. İki layer arasındaki application layer ise Bölüm 21'de — @SpringBootTest ile tam slice ve entegrasyon test'leri — işlenecek.

» Üç Gözlem Sorusu

Birinci soru: Şu an üzerinde çalıştığınız projede, repository testleri H2 ile mi çalışıyor, gerçek PostgreSQL (veya kullandığınız DB) ile mi? Eğer H2 ise, hangi production-test parity ihlalleri olabilir? Bir PostgreSQL-spesifik query veya constraint düşünebiliyor musunuz, H2'de farklı davranan? Bu sorgulama, “yanıltıcı yeşil testler” tehlikesini somutlaştırır.

İkinci soru: Custom repository implementation’larınız (criteria API, QueryDSL, native query) için ayrı testler yazıyor musunuz? Eğer hayır, bu kodlar runtime’da test ediliyor demektir — yani production’da. Bir critical custom query için bir test eklemenin maliyeti nedir; karşılığında riskin azalması ne kadar değerli?

Üçüncü soru: N+1 query problem’i hiç production’da bir performans incident’i yarattı mı? Yarattıysa, ondan ders aldığınız test pratiği nedir? hibernate.generate_statistics ile performans test'leri yazmak, gelecekteki incident'leri önleyebilir mi?

» Felsefi Mühür

Bu bölümün kapanışı için, Vlad Mihalcea’nın High-Performance Java Persistence (2016) kitabından, JPA test felsefesini özetleyen bir cümleye dönelim:

“You cannot optimize what you cannot measure, and you cannot test what you cannot reproduce. The first principle of database testing is fidelity to production: the test must use the same database engine, the same isolation level, the same query patterns. Anything less is theater, not testing.”

**“Ölçemediğiniz şeyi optimize edemezsiniz, yeniden üretemediğiniz şeyi de test edemezsiniz. Veritabanı testinin ilk ilkesi, üretim ortamına sadakat: Test, aynı veritabanı motorunu, aynı yalıtım düzeyini ve aynı sorgu kalıplarını kullanmalıdır. Bunların eksik olduğu her şey gösteri olur, test değil.”*Vlad Mihalcea*, High-Performance Java Persistence (2016)

Bu cümlenin Türkçesi şudur: ölçemediğinizi optimize edemezsiniz, ve yeniden üretemediğinizi test edemezsiniz. Veritabanı testinin birinci ilkesi production’a sadakattir: test, aynı veritabanı engine’ini, aynı isolation level’ı, aynı query pattern’lerini kullanmalı. Daha azı tiyatrodur, test değil. Bu cümle, bu bölümün özüdür. H2 ile yapılan testler “tiyatro”dur — test gibi görünür ama gerçek davranışı sınamaz. TestContainers ile gerçek PostgreSQL kullanmak ise “test”tir; production’a sadık, davranışsal olarak gerçek. Bu fark, yeşil testlerin anlamlı yeşil mi yoksa yanıltıcı yeşil mi olduğunu belirler. Bir CI pipeline’ının “200 test yeşil” mesajının değeri, testlerin gerçekten ne sınadığına bağlıdır; tiyatro değil, gerçek olmalıdır.

Bir sonraki bölümde, sistemin diğer ucuna — web katmanına — geçiyoruz. @WebMvcTest ile controller'ları izole olarak nasıl sınayacağımızı, MockMvc'nin gücünü, JSON ve validation testlerini, exception handling test'lerini, Spring Security ile entegrasyonu detaylı işleyeceğiz. Bölüm 20, modern bir REST API pratisyeninin test pratiğinin somut kılavuzu olacak.

**İçindekiler… « Önceki [Bölüm 18 — Spring Boot Test Slice’ları: Genel Bakış] » Sonraki **[Bölüm 20 — @WebMvcTest ile Controller Testleri] » https://gitlab.com/sahin.yelkenci2/tdd-pure-java » https://gitlab.com/sahin.yelkenci2/tdd-spring


메타데이터
post_id
ed97098f62bf
slug
test-driven-development-eğitim-serisi-bölüm-19-datajpatest-ile-repository-testleri-ed97098f62bf
url
https://medium.com/@sahinyelkenci/test-driven-development-e%C4%9Fitim-serisi-b%C3%B6l%C3%BCm-19-datajpatest-ile-repository-testleri-ed97098f62bf
canonical_url
https://medium.com/@sahinyelkenci/test-driven-development-e%C4%9Fitim-serisi-b%C3%B6l%C3%BCm-19-datajpatest-ile-repository-testleri-ed97098f62bf
author_url
https://medium.com/@sahinyelkenci
status
ok
fetched_at
2026-07-21 13:21:53