Python’da Hypothesis ile Property Based Testing

Yıllar önce bir ödeme sistemi projesinde çalışırken, birim testlerimizin hepsini geçen bir bug production’a sızdı. Para transferi fonksiyonu, negatif miktarları kabul ediyordu. Testlerimizde hiç aklımıza gelmemişti çünkü hep “makul” değerler kullanmıştık: 100 TL, 500 TL, 1000 TL. Kimse -1 TL göndermek isteyeceğini düşünmemişti. İşte tam bu noktada property-based testing kavramıyla tanıştım ve o günden beri bakış açım köklü biçimde değişti.

Geleneksel Testlerin Kör Noktaları

Çoğumuz şöyle test yazarız: bir fonksiyon var, birkaç örnek input giriyoruz, output’un beklediğimiz gibi olduğunu doğruluyoruz. Bu yaklaşıma example-based testing deniyor. Mantıklı, anlaşılması kolay, hızlı yazılıyor. Ama temel bir sorunu var: siz ne düşünürseniz onu test ediyorsunuz. Aklınıza gelmeyen edge case’i test edemiyorsunuz.

Property-based testing farklı bir soru soruyor: “Bu fonksiyon hangi özelliğe (property) sahip olmalı ve bu özellik her zaman geçerli mi?” Sonra framework, bu özelliği kırmaya çalışmak için yüzlerce, binlerce otomatik input üretiyor.

Python’daki en olgun property-based testing kütüphanesi Hypothesis. 2013’te David MacIver tarafından başlatılan proje, bugün production kalitesinde, aktif olarak geliştirilen bir araç haline geldi.

Hypothesis Kurulumu ve İlk Temas

Kurulum son derece basit:

pip install hypothesis
pip install hypothesis[pytest]  # pytest entegrasyonu için

Hemen basit bir örnekle başlayalım. Elimizde bir string ters çevirme fonksiyonu var:

# test_basics.py

from hypothesis import given
import hypothesis.strategies as st

def reverse_string(s):
    return s[::-1]

@given(st.text())
def test_reverse_twice_is_identity(s):
    """Bir stringi iki kez ters çevirince orijinaline dönmeli."""
    assert reverse_string(reverse_string(s)) == s

@given(st.text())
def test_reverse_length_preserved(s):
    """Ters çevirme uzunluğu değiştirmemeli."""
    assert len(reverse_string(s)) == len(s)

Bu testi çalıştırdığınızda Hypothesis arka planda yüzlerce farklı string üretiyor: boş string, Unicode karakterler, çok uzun stringler, sadece boşluklardan oluşan stringler… Hepsini otomatik olarak.

Strategies: Hypothesis’in Kalbi

Hypothesis’in gücü strategies sisteminden geliyor. Bir strategy, belirli türde test verisi üretmeyi bilen bir nesne. hypothesis.strategies modülü (genellikle st olarak import edilir) çok zengin bir koleksiyon sunuyor.

# strategies_demo.py

from hypothesis import given, settings
import hypothesis.strategies as st

# Temel strategies
@given(st.integers(min_value=0, max_value=1000))
def test_sqrt_squared(n):
    import math
    result = math.isqrt(n)
    assert result * result <= n

# Liste strategies
@given(st.lists(st.integers(), min_size=1))
def test_max_is_in_list(lst):
    assert max(lst) in lst

# Dictionary strategies  
@given(st.dictionaries(
    keys=st.text(min_size=1),
    values=st.floats(allow_nan=False, allow_infinity=False)
))
def test_dict_keys_preserved(d):
    keys_before = set(d.keys())
    # Dict'i JSON'a çevirip geri okusak keyler korunmalı
    import json
    restored = json.loads(json.dumps(d))
    assert set(restored.keys()) == keys_before

En çok kullandığım strategy’ler şunlar:

  • st.integers(min_value, max_value): Sınırlı ya da sınırsız tam sayılar
  • st.floats(allow_nan, allow_infinity): Float değerler, NaN ve sonsuz kontrolüyle
  • st.text(alphabet, min_size, max_size): Unicode string’ler
  • st.lists(elements, min_size, max_size): Liste üretimi
  • st.one_of(*strategies): Birden fazla strategy arasından seçim
  • st.builds(cls, kwargs)**: Obje oluşturma
  • st.from_regex(pattern): Regex pattern’e uyan string’ler

Gerçek Dünya Senaryosu: Para Transferi Validasyonu

Başta bahsettiğim o acı deneyime dönelim. İşte Hypothesis ile nasıl yazardım:

# test_payment.py

from hypothesis import given, assume
import hypothesis.strategies as st
from decimal import Decimal, ROUND_HALF_UP

class PaymentValidator:
    MAX_TRANSFER = Decimal('50000.00')
    
    def validate_amount(self, amount):
        if not isinstance(amount, (int, float, Decimal)):
            raise TypeError("Amount must be numeric")
        amount = Decimal(str(amount))
        if amount <= 0:
            raise ValueError("Amount must be positive")
        if amount > self.MAX_TRANSFER:
            raise ValueError(f"Amount exceeds maximum: {self.MAX_TRANSFER}")
        return amount.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
    
    def calculate_fee(self, amount):
        validated = self.validate_amount(amount)
        # Yüzde 0.5 komisyon, minimum 1 TL
        fee = (validated * Decimal('0.005')).quantize(
            Decimal('0.01'), rounding=ROUND_HALF_UP
        )
        return max(fee, Decimal('1.00'))

validator = PaymentValidator()

@given(st.decimals(
    min_value=Decimal('0.01'),
    max_value=Decimal('50000.00'),
    allow_nan=False,
    allow_infinity=False
))
def test_valid_amounts_always_accepted(amount):
    """Geçerli aralıktaki her miktar kabul edilmeli."""
    result = validator.validate_amount(amount)
    assert result > 0
    assert result <= Decimal('50000.00')

@given(st.one_of(
    st.decimals(max_value=Decimal('0'), allow_nan=False, allow_infinity=False),
    st.just(Decimal('-0.01')),
    st.just(Decimal('0'))
))
def test_non_positive_amounts_rejected(amount):
    """Sıfır veya negatif miktarlar reddedilmeli."""
    try:
        validator.validate_amount(amount)
        assert False, "ValueError bekleniyor"
    except ValueError:
        pass  # Beklenen davranış

@given(st.decimals(
    min_value=Decimal('0.01'),
    max_value=Decimal('50000.00'),
    allow_nan=False,
    allow_infinity=False
))
def test_fee_never_exceeds_amount(amount):
    """Komisyon hiçbir zaman transfer miktarını geçmemeli."""
    fee = validator.calculate_fee(amount)
    validated_amount = validator.validate_amount(amount)
    assert fee <= validated_amount

assume() fonksiyonunu burada kasıtlı kullanmadım çünkü doğrudan min/max sınırlamaları daha temiz. Ama assume() ne zaman kullanılır, ona da gelelim.

assume() ile Koşullu Testler

Bazen üretilen datanın belirli bir koşulu sağlamasını istiyoruz ama bunu direkt strategy ile ifade edemiyoruz. assume() burada devreye giriyor:

# test_assume.py

from hypothesis import given, assume
import hypothesis.strategies as st

def safe_divide(a, b):
    if b == 0:
        raise ZeroDivisionError("Cannot divide by zero")
    return a / b

@given(st.integers(), st.integers())
def test_division_inverse(a, b):
    """a/b * b yaklaşık olarak a'ya eşit olmalı (b != 0 için)."""
    assume(b != 0)  # Sıfır olan b değerlerini atla
    result = safe_divide(a, b)
    # Float hassasiyeti nedeniyle yaklaşık eşitlik
    assert abs(result * b - a) < 1e-10 or b == 0

@given(st.lists(st.integers(min_value=1), min_size=2))
def test_median_between_min_max(lst):
    """Medyan her zaman min ile max arasında olmalı."""
    assume(len(set(lst)) > 1)  # Tüm elemanlar aynıysa medyan trivial
    
    sorted_lst = sorted(lst)
    n = len(sorted_lst)
    median = sorted_lst[n // 2]
    
    assert min(lst) <= median <= max(lst)

assume() kullanırken dikkatli olmak lazım. Çok kısıtlayıcı assume() koşulları Hypothesis’in geçerli örnek bulmasını zorlaştırır ve “unsatisfied assumption” uyarısına yol açabilir. Bu durumu settings ile yönetebiliyoruz.

settings ile Test Davranışını Kontrol Etmek

# test_settings.py

from hypothesis import given, settings, HealthCheck
import hypothesis.strategies as st

@settings(
    max_examples=500,           # Varsayılan 100, daha kapsamlı test için artır
    deadline=None,              # Zaman aşımı kontrolünü devre dışı bırak
    suppress_health_check=[HealthCheck.too_slow]  # Yavaş üretimi tolere et
)
@given(st.lists(st.integers(), max_size=1000))
def test_sort_idempotent(lst):
    """Sıralamayı iki kez yapmak bir kez yapmakla aynı sonucu vermeli."""
    once = sorted(lst)
    twice = sorted(sorted(lst))
    assert once == twice

@settings(max_examples=50)  # Hızlı CI pipeline için az örnek
@given(st.text(max_size=100))
def test_strip_idempotent(s):
    """strip() iki kez uygulamak bir kez uygulamakla aynı."""
    assert s.strip().strip() == s.strip()

CI/CD pipeline’ınızda ortama göre max_examples ayarlaması yapmak iyi bir pratik. Lokal geliştirmede 100 yeterli, nightly build’lerde 1000+ kullanabilirsiniz.

Stateful Testing: Daha Karmaşık Senaryolar

Hypothesis’in çok az bilinen ama inanılmaz güçlü bir özelliği stateful testing. Bir sistem birden fazla operasyonun ardısıra uygulanmasına karşı nasıl davranıyor? Bunu test etmek için RuleBasedStateMachine kullanıyoruz:

# test_stateful.py

from hypothesis.stateful import RuleBasedStateMachine, rule, initialize, invariant
from hypothesis import given, settings
import hypothesis.strategies as st

class BankAccount:
    def __init__(self, initial_balance=0):
        self.balance = initial_balance
        self.transaction_count = 0
    
    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("Deposit must be positive")
        self.balance += amount
        self.transaction_count += 1
    
    def withdraw(self, amount):
        if amount <= 0:
            raise ValueError("Withdrawal must be positive")
        if amount > self.balance:
            raise ValueError("Insufficient funds")
        self.balance -= amount
        self.transaction_count += 1
    
    def get_balance(self):
        return self.balance

class BankAccountStateMachine(RuleBasedStateMachine):
    
    @initialize()
    def create_account(self):
        self.account = BankAccount(initial_balance=100)
        self.expected_balance = 100
        self.expected_transactions = 0
    
    @rule(amount=st.integers(min_value=1, max_value=10000))
    def deposit(self, amount):
        self.account.deposit(amount)
        self.expected_balance += amount
        self.expected_transactions += 1
    
    @rule(amount=st.integers(min_value=1, max_value=10000))
    def withdraw(self, amount):
        if amount <= self.expected_balance:
            self.account.withdraw(amount)
            self.expected_balance -= amount
            self.expected_transactions += 1
    
    @invariant()
    def balance_never_negative(self):
        """Bakiye hiçbir zaman negatif olmamalı."""
        assert self.account.get_balance() >= 0
    
    @invariant()
    def balance_matches_expected(self):
        """Hesaplanan bakiye beklenenle eşleşmeli."""
        assert self.account.get_balance() == self.expected_balance
    
    @invariant()
    def transaction_count_non_negative(self):
        assert self.account.transaction_count >= 0

TestBankAccount = BankAccountStateMachine.TestCase

Bu test, deposit ve withdraw operasyonlarının rastgele kombinasyonlarını uygulayarak invariant’ların her adımda geçerli olup olmadığını kontrol ediyor. Pek çok race condition ve state mutation bug’ını bu yöntemle bulmak mümkün.

Database Sorguları ile Hypothesis

API katmanı testlerinde Hypothesis’i SQLAlchemy ile birlikte kullandığım bir senaryo:

# test_user_repository.py

from hypothesis import given, settings
import hypothesis.strategies as st
from hypothesis.extra.django import TestCase  # Django için
# SQLAlchemy için kendi fixture'larınızı kullanın

# Kullanıcı adı için geçerli strategy
valid_username = st.text(
    alphabet=st.characters(whitelist_categories=('Lu', 'Ll', 'Nd')),
    min_size=3,
    max_size=50
)

valid_email = st.from_regex(
    r'[a-z]{3,10}@[a-z]{3,8}.(com|net|org)',
    fullmatch=True
)

def normalize_username(username):
    """Kullanıcı adını normalize et: küçük harf, trim."""
    return username.strip().lower()

def is_valid_username(username):
    """Kullanıcı adı validasyonu."""
    normalized = normalize_username(username)
    return (
        3 <= len(normalized) <= 50 and
        normalized.isalnum()
    )

@given(valid_username)
def test_normalization_idempotent(username):
    """Normalizasyonu iki kez uygulamak aynı sonucu vermeli."""
    once = normalize_username(username)
    twice = normalize_username(normalize_username(username))
    assert once == twice

@given(st.text(min_size=1))
def test_empty_username_rejected(username):
    """Sadece boşluktan oluşan username'ler reddedilmeli."""
    if not username.strip():
        assert not is_valid_username(username)

@given(valid_username, valid_username)
def test_username_comparison_consistent(u1, u2):
    """normalize(u1) == normalize(u2) ise is_valid sonuçları tutarsız olamaz."""
    n1 = normalize_username(u1)
    n2 = normalize_username(u2)
    if n1 == n2:
        assert is_valid_username(u1) == is_valid_username(u2)

Hata Bulduğunda: Shrinking

Hypothesis bir hata bulduğunda sihirli bir şey yapıyor: shrinking. Bulduğu karmaşık hatayı, aynı hatayı tetikleyen en minimal örneğe indirgiyor. Bu developer deneyimi açısından muazzam bir fark yaratıyor.

Örneğin bir bug 47 elemanlı karmaşık bir liste ile tetiklendiyse, Hypothesis bunu 2 elemanlı minimal bir listeye küçülterek gösteriyor. Debug süreci ciddi ölçüde kısalıyor.

Hypothesis aynı zamanda bulduğu örnekleri .hypothesis/examples/ dizinine kaydediyor. Böylece regression testi otomatik oluşuyor. Bu dizini Git’e commit etmek iyi bir pratik.

@composite ile Özel Strategy’ler

Karmaşık domain objeleri için kendi strategy’lerinizi yazabilirsiniz:

# custom_strategies.py

from hypothesis import given
from hypothesis.strategies import composite
import hypothesis.strategies as st
from dataclasses import dataclass
from datetime import date

@dataclass
class DateRange:
    start: date
    end: date
    label: str

@composite
def date_range_strategy(draw):
    """Geçerli bir tarih aralığı üret: start <= end."""
    start = draw(st.dates(
        min_value=date(2020, 1, 1),
        max_value=date(2025, 12, 31)
    ))
    # End date, start'tan en az 1 gün sonra
    end = draw(st.dates(
        min_value=start,
        max_value=date(2025, 12, 31)
    ))
    label = draw(st.text(min_size=1, max_size=50))
    return DateRange(start=start, end=end, label=label)

def calculate_duration_days(date_range):
    return (date_range.end - date_range.start).days

@given(date_range_strategy())
def test_duration_non_negative(dr):
    """Tarih aralığı süresi her zaman negatif olmayan bir sayı olmalı."""
    assert calculate_duration_days(dr) >= 0

@given(date_range_strategy(), date_range_strategy())
def test_overlapping_ranges_detection(dr1, dr2):
    """İki aralığın overlap tespiti simetrik olmalı."""
    def overlaps(a, b):
        return a.start <= b.end and b.start <= a.end
    
    assert overlaps(dr1, dr2) == overlaps(dr2, dr1)

Pytest ile Entegrasyon

Hypothesis pytest ile sorunsuz çalışıyor. Özel bir yapı kurmanıza gerek yok, doğrudan pytest komutu yeterli:

# Standart çalıştırma
pytest test_payment.py -v

# Verbose hypothesis çıktısı için
pytest test_payment.py -v --hypothesis-show-statistics

# Sadece hypothesis testlerini çalıştır
pytest -m "hypothesis" -v

# Daha fazla örnek üret (environment variable ile)
HYPOTHESIS_MAX_EXAMPLES=500 pytest test_payment.py

# Kayıtlı hata örneklerini tekrar çalıştır
pytest --hypothesis-seed=0

conftest.py‘de global settings tanımlayabilirsiniz:

# conftest.py

from hypothesis import settings, HealthCheck

# CI ortamı için profil
settings.register_profile(
    "ci",
    max_examples=200,
    deadline=5000,
    suppress_health_check=[HealthCheck.too_slow]
)

# Lokal geliştirme için profil
settings.register_profile(
    "dev",
    max_examples=50,
    deadline=None
)

# Derin test için profil (nightly build)
settings.register_profile(
    "nightly",
    max_examples=2000,
    deadline=None
)

# Ortam değişkenine göre profil seç
import os
settings.load_profile(os.getenv("HYPOTHESIS_PROFILE", "dev"))

Yaygın Tuzaklar ve Pratik Öneriler

Birkaç yıllık deneyimden çıkardığım somut uyarılar:

  • Side effect’li testlerden kaçın: Database write, API call gibi operasyonlar Hypothesis’in yüzlerce çağrısıyla sorun çıkarır. Mock kullanın ya da transaction rollback yapın.
  • assert yerine property düşünün: “Bu input için output X olmalı” yerine “Output şu özelliği her zaman taşımalı” diye düşünün. Bu zihinsel kayış en önemli adım.
  • Çok kısıtlayıcı assume() kullanmayın: Hypothesis 200 geçerli örnek üretmek için 10.000 deneme yapıyorsa, strategy’nizi yeniden tasarlayın.
  • Flaky testlere dikkat: Hypothesis deterministik. Aynı seed ile aynı sonucu üretiyor. Test bazen geçiyor bazen geçmiyorsa, asıl fonksiyonunuzda non-determinizm var demektir.
  • Database bağımlı testleri izole edin: @settings(database=None) ile Hypothesis’in örnek kaydetmesini devre dışı bırakabilirsiniz. CI’da bazen daha temiz olabiliyor.
  • Hypothesis’i sadece complex logic için kullanın: CRUD operasyonlarını test etmek için Hypothesis gerekmez. İş kuralları, matematiksel hesaplamalar, parsing/serialization, sorting/filtering işlemleri için biçilmiş kaftan.

Sonuç

Property-based testing, yazılım geliştirme pratiğimi köklü biçimde değiştirdi. Yalnızca bug bulmak için değil, tasarım sürecinde de kullanıyorum artık. Bir fonksiyonun hangi invariant’ları sağlaması gerektiğini düşünmek, o fonksiyonun API’sini ve davranışını netleştiriyor.

Hypothesis, Python ekosistemindeki en underrated araçlardan biri. Production sistemlerde Hypothesis ile test edilen modüllerde false sense of security azaldı, gerçek edge case’ler erkenden yakalandı. Ödeme sistemindeki o acı dersi Hypothesis ile alsaydım, production’a o bug asla ulaşamazdı.

Başlamak için öneri: Mevcut projenizde en kritik, en karmaşık iş mantığı fonksiyonunu seçin. O fonksiyonun “her zaman doğru olması gereken” üç özelliğini yazın. Hypothesis ile ilk testinizi yazın. Muhtemelen ilk 10 dakikada bir şey kırılacak. Ve o an, bu aracı neden daha önce kullanmadığınıza şaşıracaksınız.

Bir yanıt yazın

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