in

Testcontainers ile Spring Boot Test Stratejisi: 4 Katman

Testler var ama üretim hataları geçiyor; çünkü H2 PostgreSQL gibi davranmaz, mock'lar entegrasyonu doğrulamaz. Kurumsal Java projesi için dört katmanlı test stratejisini — birim, Testcontainers ile gerçek veritabanı, contract testleri ve az sayıda uçtan uca test — oranları, test verisi yaklaşımı ve CI süresi hedefleriyle anlatıyoruz.

Birim, entegrasyon, contract ve uçtan uca test katmanlarını gösteren test piramidi

"Testlerimiz var" cümlesi ile "üretimde sürpriz yaşamıyoruz" cümlesi arasında büyük bir mesafe var. Kurumsal Java projelerinde en sık gördüğümüz durum şudur: yüzlerce birim testi, %80 kapsama oranı ve yine de her sürümde veritabanı kısıtı, entegrasyon sözleşmesi ya da transaction davranışından kaynaklanan bir üretim hatası. Nedeni test sayısı değil, test stratejisinin olmamasıdır: H2 PostgreSQL gibi davranmaz, mock'lanmış bir ERP istemcisi gerçek ERP'nin ne döndürdüğünü bilmez, uçtan uca testler ise ya yoktur ya da kimse güvenmez.

Bu yazıda kurumsal Java projesi için dört katmanlı bir test stratejisi öneriyoruz: iş kurallarını birim testleriyle, veri erişimini Testcontainers üzerinde gerçek veritabanıyla, dış entegrasyonları contract testleriyle ve kritik akışları az sayıda uçtan uca testle doğrulamak. Oranları, test verisi yaklaşımını ve CI süresi hedeflerini kod örnekleriyle veriyoruz.

Dört katman ve oranları

Katman

Ne doğrular

Araç

Süre / test

Toplam içindeki pay

Birim

İş kuralları, hesaplamalar, durum geçişleri

JUnit 5, AssertJ; Spring context yok

ms

%60–70

Entegrasyon

Repository sorguları, transaction sınırları, migrasyonlar, mesaj dinleyicileri

Spring Boot test dilimleri + Testcontainers

100 ms – 2 s

%20–30

Contract

Dış sistemlerle (ERP, ödeme, e-fatura) istek/yanıt sözleşmesi

WireMock, Pact, Spring Cloud Contract

100 ms – 1 s

%5–10

Uçtan uca

Kritik iş akışları (sipariş → fatura), kimlik doğrulama

Gerçek ortam, Playwright / REST

sn – dk

%1–5

Oranların anlamı şudur: hızlı ve kararlı testler çoğunlukta olmalı, yavaş ve kırılgan testler az ama iyi seçilmiş. "Her şeyi uçtan uca test edelim" yaklaşımı iki ay sonra kimsenin koşturmadığı bir test paketiyle sonuçlanır.

Katman 1: Birim testleri — Spring olmadan

İş kuralı Spring'e ihtiyaç duymamalıdır; duyuyorsa tasarım sorunu vardır. Fiyatlandırma, kredi limiti, durum makinesi gibi kurallar saf Java sınıflarında yaşar ve milisaniyede test edilir:

class KrediLimitiKuraliTest {

    @Test
    void acikBakiyeVeSiparisToplamiLimitiAsarsaOnayaDuser() {
        var kural = new KrediLimitiKurali();
        var karar = kural.degerlendir(
            Para.of("120000"),        // kredi limiti
            Para.of("95000"),         // açık bakiye
            Para.of("30000"));        // yeni sipariş

        assertThat(karar).isEqualTo(Karar.ONAY_GEREKLI);
    }
}

Mock yalnızca dış sınırda kullanılır (saat, rastgelelik, dış servis arayüzü). Repository ve servisleri mock'layarak yazılan "birim" testleri, gerçekte hiçbir şeyi doğrulamayan en pahalı test türüdür.

Katman 2: Testcontainers ile gerçek veritabanı

H2 ile geçen bir sorgu PostgreSQL'de kırılabilir: JSONB, ON CONFLICT, SKIP LOCKED, pencere fonksiyonları, sıralama davranışı. Testcontainers, testin başında gerçek bir PostgreSQL (ya da Kafka, Redis) konteyneri ayağa kaldırır; Spring Boot 3.1+ ile bağlantı ayarı tek anotasyondur:

@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)    // gömülü H2'yi devreye sokma
@Testcontainers
class SiparisRepositoryTest {

    @Container
    @ServiceConnection                                 // url/kullanıcı/şifre otomatik
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired SiparisRepository repository;
    @Autowired TestEntityManager em;

    @Test
    void bekleyenSiparisleriKilitleyerekGetirir() {
        em.persist(SiparisFixture.bekleyen("S-1"));
        em.persist(SiparisFixture.tamamlanmis("S-2"));
        em.flush();

        List<Siparis> sonuc = repository.bekleyenleriKilitle(10);   // FOR UPDATE SKIP LOCKED

        assertThat(sonuc).extracting(Siparis::getNo).containsExactly("S-1");
    }
}

Flyway migrasyonları testte de koşar; böylece migrasyon hatası üretimden önce CI'da görünür. Konteyner başlatma süresi (birkaç saniye) test sınıfları arasında paylaşılan tek bir konteynerle amorti edilir: statik alan + soyut temel sınıf ya da withReuse(true).

public abstract class PostgresTestBase {
    static final PostgreSQLContainer<?> POSTGRES =
        new PostgreSQLContainer<>("postgres:16-alpine").withReuse(true);
    static { POSTGRES.start(); }                      // JVM ömrü boyunca tek konteyner

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry r) {
        r.add("spring.datasource.url", POSTGRES::getJdbcUrl);
        r.add("spring.datasource.username", POSTGRES::getUsername);
        r.add("spring.datasource.password", POSTGRES::getPassword);
    }
}

Aynı yaklaşım Kafka (KafkaContainer), Redis, RabbitMQ ve hatta SAP dışındaki çoğu bağımlılık için geçerlidir. Kural: üretimde ne varsa testte de o var; sürümü dahil.

Test dilimleri

Tüm uygulamayı ayağa kaldıran @SpringBootTest son çaredir. @DataJpaTest yalnızca JPA katmanını, @WebMvcTest yalnızca controller ve doğrulama katmanını, @JsonTest yalnızca serileştirmeyi yükler; süre onlarca kat kısalır ve hata konumu netleşir. Modüler monolitte @ApplicationModuleTest aynı fikri modül düzeyinde uygular.

Katman 3: Contract testleri — dış sistemin sözünü doğrulamak

ERP, ödeme sağlayıcı veya e-fatura entegratörüyle konuşan kodun testinde iki tuzak vardır: gerçek sistemi testte çağırmak (yavaş, kararsız, bazen imkânsız) ya da elle yazılmış bir mock'a güvenmek (gerçek yanıtla farkı kimse bilmez). Contract testi ikisinin ortasıdır: dış sistemin gerçek yanıt örnekleri stub olarak kaydedilir, uygulama bu stub'a karşı test edilir ve stub'ın hâlâ geçerli olduğu periyodik olarak gerçek sisteme karşı doğrulanır.

@SpringBootTest
@WireMockTest(httpPort = 8089)
class SapStokAdaptoruContractTest {

    @Test
    void stokYanitiniEsler() {
        stubFor(get(urlPathEqualTo("/API_MATERIAL_STOCK_SRV/A_MatlStkInAcctMod"))
            .withQueryParam("Material", equalTo("000000000000010001"))
            .willReturn(okJson(readResource("sap/stok-10001.json"))));   // gerçek SAP'den kaydedilmiş yanıt

        StokDurumu durum = adaptor.sorgula(UrunKodu.of("10001"), DepoKodu.of("1000")).orElseThrow();

        assertThat(durum.miktar()).isEqualByComparingTo("240");
        assertThat(durum.birim()).isEqualTo(Birim.ADET);
    }
}

Kaydedilmiş yanıt dosyaları (stok-10001.json) sözleşmenin kendisidir; sağlayıcı yanıt formatını değiştirdiğinde bunu ilk fark eden, gece koşan "stub doğrulama" işi olmalıdır. Kendi API'nizi tüketen mobil ya da web ekipleri için Pact veya Spring Cloud Contract ile tüketici odaklı sözleşmeler aynı işi tersten yapar: tüketici beklentisini yazar, sağlayıcı CI'ında doğrulanır.

Katman 4: Uçtan uca — az ve kritik

Uçtan uca testler yalnızca "bu kırılırsa şirket para kaybeder" akışları için yazılır: giriş, sipariş oluşturma, ödeme, fatura üretimi. Beş ile on senaryo tipiktir; test ortamında gerçek entegrasyon sandbox'larıyla koşar ve sonuçları sürüm kararının parçasıdır. Elli senaryoya çıkan uçtan uca paket, bakımı bırakılan ilk pakettir.

Test verisi ve temizlik

  • Builder / fixture sınıfları: SiparisFixture.bekleyen("S-1") gibi niyet anlatan yardımcılar; test içinde 30 satırlık entity kurulumu yok.

  • Paylaşılan değişebilir veri yok: Her test kendi verisini kurar; "test sırasına bağlı" hata sınıfı ortadan kalkar.

  • Temizlik stratejisi: Test sınıfına @Transactional koymak flush'ı ve lazy yükleme davranışını gizler; entegrasyon testlerinde tabloyu temizleyen bir @AfterEach ya da TRUNCATE tercih edin.

CI süresi: 10 dakika kuralı

Pull request'te koşan paket 10 dakikayı geçmemelidir; geçerse geliştiriciler beklemez, test atlanır. Süreyi tutmanın yolları: birim testlerinin çoğunlukta olması, konteyner paylaşımı, test sınıflarının paralel koşması (JUnit 5 junit.jupiter.execution.parallel.enabled), test dilimleri ve uçtan uca paketin ayrı bir aşamada (dağıtım öncesi) koşması. Kararsız (flaky) test için politika nettir: karantinaya alınır, bir hafta içinde düzeltilir ya da silinir.

Hangi hata hangi katmanda yakalanır?

Üretim hatası

Yakalayan katman

Yanlış KDV hesabı, hatalı durum geçişi

Birim

Unique kısıt ihlali, migrasyon hatası, N+1, yanlış sorgu

Entegrasyon (Testcontainers)

ERP yanıt formatı değişti, alan eşlemesi kırıldı

Contract

Kimlik doğrulama sonrası sipariş akışı çalışmıyor

Uçtan uca

Transaction sınırı yanlış, rollback olmuyor

Entegrasyon (gerçek DB, @Transactional'sız test)

Sık sorulan sorular

Kapsama oranı hedefi ne olmalı?

Hedef değil, gösterge: %70–80 sağlıklı bir işarettir ama %95, anlamsız testlerle de elde edilir. Testlerin gerçekten hata yakalayıp yakalamadığını görmek için PIT gibi mutasyon test araçlarını ara sıra çalıştırmak daha öğreticidir.

Testcontainers CI'da çalışır mı?

Docker erişimi olan her CI'da (GitHub Actions, GitLab CI, Jenkins ajanları) çalışır. Docker'ın olmadığı ortamlar için Testcontainers Cloud ya da CI aşamasında ayağa kaldırılan servis konteynerleri alternatiftir.

Eski, testsiz bir projeye nereden başlamalı?

Karakterizasyon testleriyle: mevcut davranışı olduğu gibi kaydeden entegrasyon testleri (gerçek DB, gerçek sorgular), sonra en sık değişen modülde birim testleri. Kapsamayı bir anda değil, dokunulan her kod parçasında artırın.

Sonuç

Güvenilir bir test paketi test sayısıyla değil katman dengesiyle kurulur: kuralları saf birim testleriyle, veri erişimini gerçek veritabanıyla, entegrasyonları kaydedilmiş sözleşmelerle, kritik akışları az sayıda uçtan uca testle. Bu denge, üretim hatalarının çoğunu PR aşamasında, 10 dakika içinde görünür kılar.

Mevcut projenizin test stratejisini kurmak veya testsiz bir kod tabanını güvenle değiştirilebilir hale getirmek için bakım ve destek ile yazılım tasarımı ve mimari hizmetlerimize göz atabilirsiniz.