Test-Driven Development Eğitim Serisi — Bölüm 19 —@DataJpaTest ile Repository Testleri
Faz 5: Spring Boot Ekosisteminde TDD

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ğlayanDataSourcebean'iHibernateJpaAutoConfiguration— Hibernate JPA implementationDataSourceTransactionManagerAutoConfiguration—PlatformTransactionManagerJpaRepositoriesAutoConfiguration— 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
@AutowiredileEntityManager,JpaRepository'ler,DataSourceenjekte 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ı. StandartEntityManager'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
DataSourceona 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.persistAndFlushile birlikteTestEntityManager.clearkullanarak 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
LazyInitializationExceptionfı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
@Transactionalile ç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 propagationREQUIRES_NEWveyaNESTEDile ç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.persistAndFlushveyaentityManager.flushzorunludur.

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.clearile 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
LazyInitializationExceptionfı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-autoayarı testtecreate-dropolmazsa, 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