Birim Test Yazımında En İyi Pratikler ve Yaygın Hatalar

Yıllar önce bir production ortamında yaşanan küçük bir değişiklik, tam anlamıyla kıyameti kopardı. Birisi veritabanı bağlantı havuzunu refactor etti, testler yeşil gösterdi, deploy edildi ve sabah 3’te uyandık. Sorun neydi? Yazılan “testler” aslında hiçbir şeyi test etmiyordu. Sadece metodun var olduğunu doğruluyorlardı. Bu yazı, o geceyi bir daha yaşamamak için birim test yazmanın gerçekten nasıl yapılması gerektiği üzerine.

Birim Test Neden Bu Kadar Yanlış Anlaşılıyor?

Birim test yazmak, test yazmak değildir. Bu cümle kulağa çelişkili geliyor ama inanılmaz sayıda ekip, test coverage rakamını yükseltmek için test yazıyor. %80 coverage hedefi var, ona ulaşmak için ne gerekiyorsa yapılıyor. Sonuç: Çok sayıda test var ama hiçbiri gerçek anlamda bir güvence sağlamıyor.

Birim testin amacı şudur: Kodun belirli bir davranışını izole edilmiş şekilde doğrulamak. Sadece “bu metod hata vermeden çalışıyor mu?” sorusunu sormak değil, “bu metod doğru girdiyle doğru çıktıyı üretiyor mu, yanlış girdiyle uygun şekilde hata mı veriyor, edge case’lerde ne yapıyor?” sorularının hepsini sormak.

Hadi somut konuşalım.

Test Anatomisi: Bir Birim Testin Sahip Olması Gerekenler

İyi bir birim test üç bölümden oluşur. Buna Arrange-Act-Assert ya da kısaca AAA deniyor. Sektörde bunun alternatifi olarak Given-When-Then de kullanılıyor ama özünde aynı şey.

def test_kullanici_yasini_hesapla():
    # Arrange
    dogum_tarihi = date(1990, 5, 15)
    bugun = date(2024, 5, 16)
    
    # Act
    yas = kullanici_yasini_hesapla(dogum_tarihi, bugun)
    
    # Assert
    assert yas == 34

Bu yapı basit görünüyor ama pek çok test bu üç bölümü birbirine karıştırıyor. Arrange içinde assertion yapılan, Act içinde yeni mock’lar kurulan testler görüyorsunuz ve bir hafta sonra kimse o testin ne yaptığını anlamıyor.

Test İsimlendirmesi: En Ucuz Dokümantasyon

Test isminin test_1, test_metod_calisir ya da test_ok gibi şeyler olması, testin ne yaptığını anlamayı imkansız kılar. İyi bir test ismi üç soruyu yanıtlamalı: Hangi metod test ediliyor? Hangi koşulda? Beklenen sonuç ne?

# Kötü
def test_hesapla():
    pass

# Kötü
def test_siparis_toplami_hesaplanir():
    pass

# İyi
def test_siparis_toplami_hesapla_indirim_uygulandiginda_dogru_tutari_dondurur():
    pass

# İyi (daha kısa ama yeterince açık)
def test_siparis_toplami__indirimli__dogru_fiyat():
    pass

Bu şekilde yazdığınızda test paketi adeta bir dokümantasyon haline geliyor. Yeni bir geliştirici koda bakmadan önce testlere bakarak sistemin nasıl davrandığını anlayabiliyor.

Yaygın Hata 1: Her Şeyi Tek Bir Testte Test Etmek

Bu hatayı çok sık görüyorum. Özellikle deneyimsiz geliştiricilerde değil, aksine “verimli olmaya” çalışan deneyimli geliştiricilerde.

# YANLIŞ: Tek test, çok sorumluluk
def test_siparis_isleme():
    siparis = Siparis(urun_id=1, miktar=5)
    
    # Stok kontrolü test ediliyor
    assert stok_var_mi(siparis) == True
    
    # Fiyat hesaplama test ediliyor
    fiyat = fiyat_hesapla(siparis)
    assert fiyat == 250.0
    
    # İndirim uygulaması test ediliyor
    indirimli_fiyat = indirim_uygula(siparis, 0.1)
    assert indirimli_fiyat == 225.0
    
    # Sipariş oluşturma test ediliyor
    sonuc = siparis_olustur(siparis)
    assert sonuc.id is not None

Bu test başarısız olduğunda ne başarısız oldu? Hiçbir fikriniz yok. Stok mu yoktu, fiyat mı yanlış hesaplandı, indirim mi hatalıydı? Hepsini debug etmeniz gerekiyor.

# DOĞRU: Her test tek bir davranışı doğrular
def test_stok_kontrolu__urun_mevcut__true_dondurur():
    siparis = Siparis(urun_id=1, miktar=5)
    assert stok_var_mi(siparis) == True

def test_fiyat_hesapla__standart_miktar__dogru_birim_fiyat():
    siparis = Siparis(urun_id=1, miktar=5)
    fiyat = fiyat_hesapla(siparis)
    assert fiyat == 250.0

def test_indirim_uygula__yuzde_on_indirim__dogru_tutar():
    indirimli_fiyat = indirim_uygula(base_fiyat=250.0, oran=0.1)
    assert indirimli_fiyat == 225.0

Bir test bir şeyi test eder. Bu kural.

Yaygın Hata 2: Mock Kullanımını Abartmak

Mock’lar hayat kurtarır ama aşırı mock kullanımı testlerin gerçeklikten kopmasına neden olur. Şöyle bir sahneyle karşılaştım: Bir servisin tüm bağımlılıkları mock’lanmış, method çağrıları sahte verilerle doldurulmuş, test geçiyor. Ama asıl iş mantığı tamamen bozuk. Çünkü test, “şu metod çağrıldı mı?” diye soruyordu, “doğru sonuç çıktı mı?” diye değil.

# YANLIŞ: Her şeyi mock'lamak
from unittest.mock import Mock, patch

def test_kullanici_kaydet__kotu_ornek():
    mock_db = Mock()
    mock_validator = Mock()
    mock_event_bus = Mock()
    mock_logger = Mock()
    
    # Bu test aslında hiçbir iş mantığını test etmiyor
    mock_validator.dogrula.return_value = True
    mock_db.kaydet.return_value = {"id": 1}
    
    servis = KullaniciServisi(mock_db, mock_validator, mock_event_bus, mock_logger)
    sonuc = servis.kaydet({"isim": "Ahmet", "email": "[email protected]"})
    
    # Bu assertion gerçekten ne doğruluyor?
    mock_db.kaydet.assert_called_once()
# DOĞRU: Sadece dış bağımlılıkları mock'la, iş mantığını gerçekten test et
def test_kullanici_kaydet__gecersiz_email__hata_firlatir():
    mock_db = Mock()
    
    servis = KullaniciServisi(db=mock_db)
    
    with pytest.raises(ValidationError) as exc_info:
        servis.kaydet({"isim": "Ahmet", "email": "gecersiz-email"})
    
    assert "geçerli bir email" in str(exc_info.value).lower()
    mock_db.kaydet.assert_not_called()  # DB'ye hiç gitilmemeli

Mock’ları şu kural çerçevesinde kullanın: Sisteminizin sınırlarındaki şeyleri mock’layın. Veritabanı, harici API, dosya sistemi, saat. İç iş mantığını mock’lamaya başladığınızda bir şeyler ters gidiyor demektir.

Yaygın Hata 3: Determinizm Eksikliği

Test çalıştırdığınızda bazen geçiyor bazen geçmiyor mu? Bu “flaky test” deniyor ve production’dan daha sinir bozucu. Genellikle iki kaynaktan geliyor: zamana bağımlılık ve global state.

# YANLIŞ: Zamana bağımlı test
def test_token_suresi_dolmamis():
    token = Token(olusturulma_zamani=datetime.now())
    assert token.gecerli_mi() == True  # 1 saat sonra bu test başarısız olabilir!
# DOĞRU: Zamanı enjekte et, kontrol altına al
def test_token_suresi_dolmamis__olusturulduktan_sonra__gecerli():
    simdi = datetime(2024, 1, 15, 12, 0, 0)
    token = Token(
        olusturulma_zamani=simdi,
        saat_siniri=1
    )
    
    kontrol_zamani = simdi + timedelta(minutes=30)
    assert token.gecerli_mi(kontrol_zamani) == True

def test_token_suresi_dolmus__bir_saat_sonra__gecersiz():
    simdi = datetime(2024, 1, 15, 12, 0, 0)
    token = Token(
        olusturulma_zamani=simdi,
        saat_siniri=1
    )
    
    kontrol_zamani = simdi + timedelta(hours=2)
    assert token.gecerli_mi(kontrol_zamani) == False

Global state meselesine gelince, testler birbirini etkilememelidir. Her test kendi ortamını kurar, işini yapar ve temizler.

# pytest fixture ile temiz state yönetimi
import pytest

@pytest.fixture
def temiz_veritabani():
    db = TestVeritabani()
    db.olustur()
    yield db
    db.temizle()  # Test bittikten sonra temizle

def test_kullanici_ekle(temiz_veritabani):
    kullanici = {"isim": "Mehmet", "email": "[email protected]"}
    temiz_veritabani.ekle(kullanici)
    assert temiz_veritabani.say() == 1

def test_kullanici_sil(temiz_veritabani):
    # Bu test, bir öncekinin veritabanını görmüyor
    kullanici = {"isim": "Ayşe", "email": "[email protected]"}
    temiz_veritabani.ekle(kullanici)
    temiz_veritabani.sil(email="[email protected]")
    assert temiz_veritabani.say() == 0

Parameterize Testler: Code Smell’e Karşı Güçlü Silah

Aynı fonksiyonu farklı girdilerle test etmeniz gerektiğinde kopyala-yapıştır yapmak yerine parameterize kullanın.

# YANLIŞ: Tekrarlayan testler
def test_gecerli_email_format_1():
    assert email_gecerli_mi("[email protected]") == True

def test_gecerli_email_format_2():
    assert email_gecerli_mi("[email protected]") == True

def test_gecersiz_email_format_1():
    assert email_gecerli_mi("@nodomain.com") == False

# DOĞRU: Parameterize ile temiz ve kapsamlı test
import pytest

@pytest.mark.parametrize("email,beklenen", [
    ("[email protected]", True),
    ("[email protected]", True),
    ("[email protected]", True),
    ("@nodomain.com", False),
    ("boşluk @domain.com", False),
    ("cift@@domain.com", False),
    ("", False),
    (None, False),
])
def test_email_gecerlilik(email, beklenen):
    assert email_gecerli_mi(email) == beklenen

Bu yaklaşımın güzelliği şu: Yeni bir edge case bulduğunuzda sadece listeye bir satır ekliyorsunuz.

Test Piramidi ve Birim Testin Yeri

Birim test konuşurken bütünü görmemek büyük hata. Test piramidi diye bir kavram var ve birim testler bu piramidin tabanında. Çok sayıda, hızlı çalışan, ucuz birim testleri. Ortada entegrasyon testleri. Tepede ise az sayıda, yavaş ama gerçekçi end-to-end testler.

Bu piramidin tersine dönmesi, yani az birim test çok e2e test, bir felakete davetiye çıkarmak demektir. E2e testler yavaş, kırılgan ve debug edilmesi zordur. Bir CI pipeline’ında 45 dakika süren testler varsa kimse testi ciddiye almaz hale gelir.

Birim testleriniz şu kriterleri karşılamalı:

  • Hız: Tüm birim test paketi 30 saniyenin altında çalışmalı
  • Bağımsızlık: Herhangi bir sırayla çalıştırılabilmeli
  • Tekrarlanabilirlik: Aynı ortamda her zaman aynı sonucu vermeli
  • Okunabilirlik: Testi okumak, kodu okumaktan daha kolay olmalı

Refactoring Yaparken Testleri Korumak

Testlerin en değerli olduğu an, refactoring sırasıdır. Ama burada kritik bir ayrım var: Testler implementasyonu değil, davranışı test etmelidir.

# YANLIŞ: Implementasyona bağlı test (brittle test)
def test_indirim_hesapla__ozel_algoritmaya_bagli():
    servis = IndirimServisi()
    
    # İç metodun çağrılıp çağrılmadığını test ediyor
    # Refactor edilince bu test patlayacak ama davranış değişmedi
    with patch.object(servis, '_hesapla_basamakli_indirim') as mock_hesapla:
        mock_hesapla.return_value = 50.0
        sonuc = servis.uygula(fiyat=100.0, musteri_tipi="premium")
    
    mock_hesapla.assert_called_with(100.0)

# DOĞRU: Davranışa bağlı test (refactoring-friendly)
def test_indirim_hesapla__premium_musteri__yuzde_elli_indirim():
    servis = IndirimServisi()
    
    # İç implementasyon ne olursa olsun, sonuç doğru mu?
    sonuc = servis.uygula(fiyat=100.0, musteri_tipi="premium")
    
    assert sonuc == 50.0

İkinci test, _hesapla_basamakli_indirim metodunu tamamen kaldırıp yerine farklı bir algoritma koysanız bile çalışmaya devam eder. Birinci test ise refactoring’e direnç gösterir, yani tam tersine yapmamanız gereken şeyi yapar.

Gerçek Dünya Senaryosu: Ödeme Sistemi Testi

Teoriden pratiğe geçelim. Bir ödeme sistemi entegrasyonu yazıyorsunuz. Dış servis var, para var, hata yönetimi kritik.

import pytest
from unittest.mock import Mock, patch
from decimal import Decimal

class OdemeSistemiHatasi(Exception):
    pass

class OdemeServisi:
    def __init__(self, odeme_gateway, bildirim_servisi):
        self.gateway = odeme_gateway
        self.bildirim = bildirim_servisi
    
    def odeme_yap(self, tutar, kart_bilgisi, kullanici_id):
        if tutar <= 0:
            raise ValueError("Tutar sıfırdan büyük olmalı")
        
        try:
            sonuc = self.gateway.islem_yap(tutar, kart_bilgisi)
            self.bildirim.gonder(kullanici_id, "Ödeme başarılı", tutar)
            return {"durum": "basarili", "islem_id": sonuc.islem_id}
        except Exception as e:
            self.bildirim.gonder(kullanici_id, "Ödeme başarısız", tutar)
            raise OdemeSistemiHatasi(f"Ödeme işlemi başarısız: {str(e)}")


@pytest.fixture
def mock_gateway():
    return Mock()

@pytest.fixture
def mock_bildirim():
    return Mock()

@pytest.fixture
def odeme_servisi(mock_gateway, mock_bildirim):
    return OdemeServisi(mock_gateway, mock_bildirim)


def test_odeme_yap__gecersiz_tutar__value_error_firlatir(odeme_servisi):
    with pytest.raises(ValueError):
        odeme_servisi.odeme_yap(
            tutar=Decimal("-10.00"),
            kart_bilgisi={"no": "4111111111111111"},
            kullanici_id="user_123"
        )

def test_odeme_yap__basarili__dogru_yapit_doner(odeme_servisi, mock_gateway):
    mock_gateway.islem_yap.return_value = Mock(islem_id="TXN_456")
    
    sonuc = odeme_servisi.odeme_yap(
        tutar=Decimal("150.00"),
        kart_bilgisi={"no": "4111111111111111"},
        kullanici_id="user_123"
    )
    
    assert sonuc["durum"] == "basarili"
    assert sonuc["islem_id"] == "TXN_456"

def test_odeme_yap__basarili__kullaniciya_bildirim_gider(odeme_servisi, mock_gateway, mock_bildirim):
    mock_gateway.islem_yap.return_value = Mock(islem_id="TXN_456")
    
    odeme_servisi.odeme_yap(
        tutar=Decimal("150.00"),
        kart_bilgisi={"no": "4111111111111111"},
        kullanici_id="user_123"
    )
    
    mock_bildirim.gonder.assert_called_once_with("user_123", "Ödeme başarılı", Decimal("150.00"))

def test_odeme_yap__gateway_hatasi__sistem_hatasi_firlatir(odeme_servisi, mock_gateway, mock_bildirim):
    mock_gateway.islem_yap.side_effect = ConnectionError("Gateway bağlantı hatası")
    
    with pytest.raises(OdemeSistemiHatasi):
        odeme_servisi.odeme_yap(
            tutar=Decimal("150.00"),
            kart_bilgisi={"no": "4111111111111111"},
            kullanici_id="user_123"
        )
    
    # Hata durumunda da bildirim gönderilmeli
    mock_bildirim.gonder.assert_called_once_with("user_123", "Ödeme başarısız", Decimal("150.00"))

Bu örnekte her test tek bir davranışı test ediyor. Başarı senaryosu, hata senaryosu, bildirim davranışı hepsi ayrı ayrı ele alınmış. Gateway mock’lanmış çünkü dış servis, ama OdemeServisi’nin kendi mantığı gerçekten çalışıyor.

Coverage Metriğini Doğru Yorumlamak

Son olarak coverage meselesine gelmek istiyorum çünkü bu konu çok yanlış anlaşılıyor. %100 coverage, %100 doğruluk anlamına gelmez.

# Coverage raporu almak
pytest --cov=uygulama --cov-report=html --cov-report=term-missing

# Branch coverage için (daha değerli)
pytest --cov=uygulama --cov-branch --cov-report=term-missing

Branch coverage, sadece satırların çalıştırılıp çalıştırılmadığını değil, her koşulun her dalının test edilip edilmediğini ölçer. Bu çok daha anlamlı bir metrik.

Ama asıl mesele şu: Coverage rakamını takip edin ama bağımlı olmayın. %85 coverage ile mükemmel test paketi mümkün. %100 coverage ile işe yaramaz test paketi de mümkün. Kritik iş mantığının test edilip edilmediğini anlayan bir insan gözü, herhangi bir araçtan daha değerli.

Mutation testing konusuna da kısaca değinmek gerekiyor. mutmut gibi araçlar kodunuzda küçük değişiklikler yaparak testlerinizin bu değişiklikleri yakalayıp yakalayamadığını ölçüyor. Bu, testlerinizin gerçekten ne kadar etkili olduğunu anlamanın en iyi yollarından biri.

# Python için mutmut kurulumu ve çalıştırılması
pip install mutmut
mutmut run
mutmut results

Sonuç

Birim test yazmak bir disiplin meselesi. Tek seferlik öğrenilip uygulanan bir şey değil, sürekli gelişen bir pratik. En önemli prensipleri özetlemek gerekirse:

  • Bir test, bir davranış. Asla daha fazlası.
  • İsimlendirme dokümantasyondur. Test adları konuşsun.
  • Mock’ları sınırda kullan. İç mantığı mock’lamak testleri anlamsızlaştırır.
  • Determinizm şart. Flaky test, test değildir.
  • Davranışı test et, implementasyonu değil. Refactoring’e dayanıklı testler yaz.
  • Coverage araç, amaç değil. Rakama değil anlama odaklan.

Sabah 3’te telefon çalmadan, paniksiz bir deploy süreci istiyorsanız, testlere gerçekten yatırım yapın. Kod reviewlarında test kalitesini kod kalitesi kadar ciddiye alın. Çünkü iyi yazılmış bir test paketi, en iyi güvenlik ağınızdır.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir