pytest Kurulumu ve İlk Test Yazımına Giriş

Prodüksiyonda bir şeyler patladığında, “Keşke test yazsaydım” diye düşünmüşsünüzdür muhakkak. Python ekosisteminde bu pişmanlığı en az yaşatan araç tartışmasız pytest’tir. Bugün sıfırdan başlayıp gerçek dünyada işinize yarayacak bir test altyapısı kuracağız.

pytest Neden Bu Kadar Popüler?

Açıkçası bunu anlamak için unittest ile pytest’i karşılaştırmak yeterli. Python’un standart kütüphanesinde gelen unittest, Java’nın JUnit’inden ilham almış ve bence Python’a tam oturamamış bir yapıya sahip. Her test için bir sınıf tanımlamanız, self parametresini her yere yazmanız, assertEqual, assertRaises gibi özel metotları ezberlemek zorunda kalmanız gerekiyor.

pytest ise bambaşka bir felsefeden geliyor. Sade Python assert ifadelerini kullanıyorsunuz, fonksiyon bazlı testler yazabiliyorsunuz, hata mesajları son derece anlaşılır biçimde ekrana geldiği için neyin nerede bozulduğunu anlık görüyorsunuz. Üstüne bir de zengin plugin ekosistemi var: coverage raporları, paralel test çalıştırma, mock objeler, parametrize testler…

Bir de şu var: pytest, unittest ile yazdığınız eski testleri de çalıştırıyor. Yani mevcut projenize pytest’i dahil ettiğinizde geriye dönük uyumluluk sorunum olmaz diye endişelenmenize gerek yok.

Kurulum

Önce temiz bir ortam oluşturalım. Sistem Python’una doğrudan bir şey kurmak yerine sanal ortam kullanmak her zaman daha sağlıklı.

# Python 3.8+ varsayıyorum, zaten altını kullanmayın
python3 -m venv test_env
source test_env/bin/activate  # Linux/macOS
# Windows için: test_envScriptsactivate

pip install pytest
pytest --version

Çıktı olarak şuna benzer bir şey görmeniz lazım:

pytest 7.4.3

Birkaç ek paket daha kuralım, bunlar neredeyse her projede lazım olacak:

pip install pytest-cov pytest-mock pytest-xdist
  • pytest-cov: Kod kapsama (coverage) raporları üretiyor
  • pytest-mock: Mock objeler için temiz bir arayüz sunuyor
  • pytest-xdist: Testleri paralel çalıştırmak için kullanılıyor

Proje Yapısı

Test yazmaya başlamadan önce dizin yapısına karar vermek önemli. İki yaygın yaklaşım var:

Birinci yaklaşım – src layout:

myproject/
├── src/
│   └── myproject/
│       ├── __init__.py
│       └── calculator.py
├── tests/
│   ├── __init__.py
│   └── test_calculator.py
├── pytest.ini
└── requirements.txt

İkinci yaklaşım – flat layout:

myproject/
├── calculator.py
├── tests/
│   └── test_calculator.py
└── pytest.ini

Küçük projelerde flat layout gayet kullanışlı. Büyüyen ve paketlenecek projelerde src layout daha sağlıklı. Biz burada ikinci yaklaşımla devam edeceğiz, konuyu anlaşılır tutmak için.

İlk Test Dosyamız

Klasik örneklerden kaçınmak istiyorum ama “hello world” yerine gerçekte kullanabileceğiniz bir şey yapalım. Diyelim ki bir yapılandırma dosyası ayrıştırıcısı yazıyorsunuz; değerleri tiplerine göre dönüştürmesi lazım.

Önce config_parser.py dosyamızı yazalım:

# config_parser.py

def parse_value(value: str):
    """
    String değeri uygun Python tipine çevirir.
    Önce int, sonra float, sonra bool dener.
    Hiçbiri değilse string döner.
    """
    if not isinstance(value, str):
        raise TypeError(f"Beklenen str, gelen: {type(value).__name__}")
    
    # Boolean kontrolü (case-insensitive)
    if value.lower() in ("true", "yes", "1"):
        return True
    if value.lower() in ("false", "no", "0"):
        return False
    
    # Integer kontrolü
    try:
        return int(value)
    except ValueError:
        pass
    
    # Float kontrolü
    try:
        return float(value)
    except ValueError:
        pass
    
    # Boş string
    if value.strip() == "":
        return None
    
    return value


def parse_config_line(line: str) -> dict:
    """
    'key=value' formatındaki satırı ayrıştırır.
    """
    line = line.strip()
    
    if not line or line.startswith("#"):
        return {}
    
    if "=" not in line:
        raise ValueError(f"Geçersiz config satırı: {line!r}")
    
    key, _, value = line.partition("=")
    return {key.strip(): parse_value(value.strip())}

Şimdi testlerimizi yazalım. Dosyanın adı test_ ile başlamalı, pytest bu konvansiyona göre test dosyalarını buluyor:

# tests/test_config_parser.py

import pytest
from config_parser import parse_value, parse_config_line


class TestParseValue:
    
    def test_integer_donusumu(self):
        assert parse_value("42") == 42
        assert isinstance(parse_value("42"), int)
    
    def test_negatif_integer(self):
        assert parse_value("-10") == -10
    
    def test_float_donusumu(self):
        assert parse_value("3.14") == 3.14
    
    def test_true_degerleri(self):
        assert parse_value("true") is True
        assert parse_value("True") is True
        assert parse_value("yes") is True
        assert parse_value("YES") is True
    
    def test_false_degerleri(self):
        assert parse_value("false") is False
        assert parse_value("no") is False
    
    def test_string_kalir(self):
        assert parse_value("localhost") == "localhost"
    
    def test_bos_string_none_dondurmeli(self):
        assert parse_value("  ") is None
    
    def test_yanlis_tip_exception(self):
        with pytest.raises(TypeError) as exc_info:
            parse_value(123)
        assert "int" in str(exc_info.value)


class TestParseConfigLine:
    
    def test_basit_satir(self):
        result = parse_config_line("host=localhost")
        assert result == {"host": "localhost"}
    
    def test_bosluklar_temizlenir(self):
        result = parse_config_line("  port = 5432  ")
        assert result == {"port": 5432}
    
    def test_yorum_satiri_bos_dict(self):
        assert parse_config_line("# bu bir yorum") == {}
    
    def test_bos_satir_bos_dict(self):
        assert parse_config_line("") == {}
    
    def test_esittir_olmadan_exception(self):
        with pytest.raises(ValueError):
            parse_config_line("bu gecersiz bir satir")
    
    def test_deger_icinde_esittir(self):
        # connection string gibi değerlerde = işareti olabilir
        result = parse_config_line("dsn=postgresql://user:pass@host/db")
        assert result == {"dsn": "postgresql://user:pass@host/db"}

Testleri Çalıştırmak

# Tüm testleri çalıştır
pytest

# Detaylı çıktı (-v: verbose)
pytest -v

# Belirli bir dosyayı çalıştır
pytest tests/test_config_parser.py

# Belirli bir sınıfı çalıştır
pytest tests/test_config_parser.py::TestParseValue

# Belirli bir testi çalıştır
pytest tests/test_config_parser.py::TestParseValue::test_integer_donusumu

# İlk hata çıkınca dur
pytest -x

# Son başarısız testleri tekrar çalıştır
pytest --lf

-v flag’i ile çalıştırdığınızda her testin adını, sonucunu ve toplam süreyi görürsünüz. Yeşil noktalara bakmak, özellikle uzun bir süredir çalışmakta olan bir sunucunun log dosyalarına bakmaktan çok daha tatmin edici bir his veriyor bana.

pytest.ini ile Yapılandırma

Projenin kökünde bir pytest.ini dosyası oluşturarak pytest’in davranışını şekillendirebilirsiniz:

# pytest.ini
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts = -v --tb=short
filterwarnings =
    error
    ignore::DeprecationWarning
  • testpaths: pytest’in hangi dizinlere bakacağını belirtir
  • python_files: Hangi dosyaların test dosyası sayılacağını tanımlar
  • addopts: Her çalıştırmada otomatik eklenen argümanlar
  • –tb=short: Traceback çıktısını kısa tutar, üretim loglarına bakmaya alışkın gözler için daha okunabilir

Fixture Kavramı: pytest’in Güçlü Silahı

Fixture’lar, testleriniz için hazırlık ve temizlik işlemlerini yönettiğiniz yapılar. Şöyle düşünün: Her test için aynı veritabanı bağlantısını ya da aynı test verisini tekrar tekrar yazmak zorunda kalmamak için fixture kullanıyorsunuz.

Gerçek bir senaryo üzerinden gidelim. Diyelim ki bir kullanıcı yönetim modülü test ediyorsunuz:

# tests/conftest.py

import pytest
import tempfile
import os


@pytest.fixture
def temp_config_file():
    """Geçici bir config dosyası oluşturur, test biter bitmez siler."""
    content = """
host=db.internal
port=5432
debug=false
max_connections=100
app_name=myapp
    """.strip()
    
    # tempfile ile güvenli geçici dosya oluştur
    fd, path = tempfile.mkstemp(suffix=".conf")
    try:
        with os.fdopen(fd, 'w') as f:
            f.write(content)
        yield path  # test bu noktada çalışır
    finally:
        os.unlink(path)  # test bittikten sonra dosyayı sil


@pytest.fixture
def sample_config_data():
    """Test verisini döndürür, herhangi bir I/O işlemi yok."""
    return {
        "host": "localhost",
        "port": 5432,
        "debug": False,
        "app_name": "testapp"
    }

conftest.py özel bir dosya, pytest bunu otomatik olarak buluyor ve içindeki fixture’ları tüm test dosyalarında kullanılabilir hale getiriyor.

Şimdi bu fixture’ları kullanalım:

# tests/test_config_file_reader.py
# Önce config_reader.py'yi yazdığınızı varsayıyoruz

import pytest


def test_config_dosyasi_okunur(temp_config_file):
    # Fixture'dan gelen geçici dosya yolu
    with open(temp_config_file) as f:
        content = f.read()
    
    assert "host=db.internal" in content
    assert "port=5432" in content


def test_sample_data_tip_kontrolu(sample_config_data):
    assert isinstance(sample_config_data["port"], int)
    assert isinstance(sample_config_data["debug"], bool)
    assert sample_config_data["debug"] is False

Fixture’ların scope parametresi var ve bu çok önemli:

  • function (varsayılan): Her test fonksiyonu için yeniden çalışır
  • class: Aynı sınıftaki testler için bir kere çalışır
  • module: Aynı dosyadaki testler için bir kere çalışır
  • session: Tüm test oturumu boyunca bir kere çalışır

Veritabanı bağlantısı gibi pahalı işlemler için session scope kullanmak test süresini ciddi ölçüde düşürüyor.

Parametrize Testler

Aynı testin farklı girdilerle çalışmasını istiyorsanız, aynı testi beş kere kopyalamak yerine @pytest.mark.parametrize kullanın:

# tests/test_parametrize_ornek.py

import pytest
from config_parser import parse_value


@pytest.mark.parametrize("input_val, expected", [
    ("42", 42),
    ("-7", -7),
    ("0", False),   # "0" string'i False'a dönüşmeli
    ("3.14", 3.14),
    ("localhost", "localhost"),
    ("true", True),
    ("FALSE", False),
    ("yes", True),
])
def test_parse_value_parametrize(input_val, expected):
    assert parse_value(input_val) == expected


@pytest.mark.parametrize("gecersiz_satir", [
    "bu gecersiz",
    "bosluksuzesit",
    "tek kelime",
])
def test_gecersiz_satirlar_exception_firlatmali(gecersiz_satir):
    with pytest.raises(ValueError):
        from config_parser import parse_config_line
        parse_config_line(gecersiz_satir)

Bu yaklaşımın güzelliği şu: Her parametre kombinasyonu ayrı bir test olarak raporlanıyor. Hangisi geçti, hangisi kaldı, tek bakışta anlıyorsunuz.

Coverage Raporu Almak

Test yazdınız ama hangi kod satırlarının teste girip girmediğini bilmiyorsanız, testlerinizin ne kadar güvenli bir ağ oluşturduğunu bilemezsiniz.

# Coverage ile çalıştır
pytest --cov=. --cov-report=term-missing

# HTML rapor oluştur (daha okunabilir)
pytest --cov=. --cov-report=html

# Belirli bir modül için
pytest --cov=config_parser --cov-report=term-missing tests/

--cov-report=term-missing size hangi satırların test edilmediğini terminalde gösteriyor. HTML rapor oluşturduğunuzda htmlcov/index.html dosyasını tarayıcıda açarak satır satır inceleyebiliyorsunuz.

Makul bir hedef olarak %80 coverage’ı hedefleyin. %100 hedeflemek çoğu zaman anlamlı değil, getter/setter gibi trivial kodlar için test yazmak zaman kaybı. Kritik iş mantığını kapsıyor musunuz, buna odaklanın.

Testleri İşaretlemek (Marks)

Bazı testler yavaştır, bazıları dış sisteme bağımlıdır, bazıları sadece belirli ortamlarda çalışır. Bunları işaretleyerek kontrollü şekilde çalıştırabilirsiniz:

# tests/test_marks_ornek.py

import pytest


@pytest.mark.slow
def test_buyuk_dosya_isleme():
    # Bu test 30 saniye sürüyor diyelim
    pass


@pytest.mark.integration
def test_veritabani_baglantisi():
    # Gerçek DB bağlantısı gerektiriyor
    pass


@pytest.mark.skipif(
    condition=True,  # Normalde buraya os.environ.get("CI") gibi bir koşul koyarsınız
    reason="CI ortamında atlanıyor"
)
def test_sadece_local():
    pass

pytest.ini dosyasına özel marker’larınızı kaydedin ki uyarı almasın:

# pytest.ini içine ekleyin
[pytest]
markers =
    slow: Yavaş çalışan testler
    integration: Dış sistem gerektiren testler
    unit: Birim testler

Çalıştırırken seçici olabilirsiniz:

# Sadece unit testleri çalıştır
pytest -m unit

# Yavaş testleri atla
pytest -m "not slow"

# Integration ve unit testleri çalıştır
pytest -m "integration or unit"

Gerçek Dünya İpuçları

Prodüksiyondaki bir projeye test yazmaya başladığınızda karşılaşacağınız birkaç pratik durum:

Test izolasyonu kritik. Bir test başka bir testin sonucuna bağımlı olmamalı. pytest-randomly paketini kurup testleri rastgele sırada çalıştırarak bu bağımlılıkları erkenden keşfedebilirsiniz.

Hızlı geri bildirim döngüsü kurun. pytest-watch paketi ile dosyalarınızı kaydettiğinizde testler otomatik çalışır. Geliştirme sürecinde bu alışkanlık kod kalitesini inanılmaz ölçüde artırıyor.

CI/CD entegrasyonu. GitHub Actions veya GitLab CI’da test adımını şöyle yapılandırabilirsiniz:

# .github/workflows/test.yml'den ilgili adım
- name: Run tests
  run: |
    pip install -r requirements.txt
    pytest --cov=. --cov-report=xml -v
    
- name: Upload coverage
  uses: codecov/codecov-action@v3

Başarısız testleri debug etmek. --pdb flag’i ile pytest, hata çıktığı noktada Python debugger’ı açıyor:

pytest --pdb tests/test_config_parser.py

Bu özellikle “neden böyle bir değer geliyor ki?” dediğiniz durumlarda muazzam zaman kazandırıyor.

Sonuç

pytest ile test yazmak, bir süre sonra kodun ayrılmaz parçası haline geliyor. Başta “ekstra iş” gibi hissettiren şey, zamanla “bu kodu nasıl test ederim?” sorusunu sormak yerine “bu testi nasıl yazarım?” sorusunu sormaya dönüşüyor. Fark büyük.

Bugün anlattıklarımı özetlemek gerekirse: Sanal ortamda kurulum yapın, pytest.ini ile projenizi yapılandırın, fixture’larla tekrar eden kurulum kodundan kurtulun, parametrize testlerle edge case’leri kapatın, coverage raporlarıyla kör noktalarınızı bulun.

Bir sonraki adım olarak pytest-mock ile dış bağımlılıkları nasıl izole edeceğimize, conftest.py ile karmaşık fixture hiyerarşileri kurmaya ve property-based testing için hypothesis kütüphanesine bakabiliriz. Ama onlar başka yazıların konusu.

Şimdi gidip o “keşke test yazsaydım” pişmanlığı yaratacak kodu yazın, sonra testini yazın.

Bir yanıt yazın

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