pytest Plugin Ekosistemi: En Kullanışlı Eklentiler

Bir noktada her Python geliştiricisi aynı durumla karşılaşır: testler büyümeye başlar, süre uzar, raporlar okunaksız hale gelir ve “bu testi neden yazdım ki” diye sormaya başlarsın. İşte tam bu noktada pytest’in plugin ekosistemi devreye giriyor. Vanilla pytest gayet yetenekli bir araç, ama asıl gücü üzerine inşa edilmiş bu eklenti katmanında yatıyor.

Bu yazıda, gerçek projelerde sürekli başvurduğum, “bir daha kurmadan geçemem” dedirtecek eklentilere bakacağız. Salt liste vermeyeceğim, her birini neden kullandığımı ve nasıl kullandığımı da aktaracağım.

pytest’in Plugin Mimarisi Hakkında Kısa Bir Not

pytest, hook sistemi üzerine kurulu. conftest.py dosyaları aslında yerel plugin’ler gibi davranıyor. Harici plugin’ler ise aynı mekanizmayı kullanarak pytest_configure, pytest_runtest_setup, pytest_collection_modifyitems gibi hook’lara bağlanıyor.

Bu mimariyi anlamak önemli çünkü bir eklentinin ne zaman devreye girdiğini, neden bazı eklentilerin çakıştığını ve kendi eklentini yazman gerektiğinde nereden başlayacağını anlıyorsun. Ama şimdilik bunu bir kenara bırakalım ve işe yarayan araçlara geçelim.

Kurulum genel olarak şu şekilde:

pip install pytest-xdist pytest-cov pytest-mock pytest-asyncio 
            pytest-parametrize-cases pytest-clarity pytest-randomly 
            pytest-timeout pytest-benchmark

Production ortamında bunları requirements-dev.txt veya pyproject.toml‘ın [project.optional-dependencies] bölümüne eklemeyi unutma.

pytest-cov: Kapsam Analizi Olmadan Test Yazmak Kör Uçmak Gibi

En temel eklentiden başlayalım. pytest-cov, coverage.py kütüphanesinin pytest entegrasyonu. Hangi satırların test edildiğini, hangilerinin atlandığını gösterir.

# Temel kullanım
pytest --cov=myapp --cov-report=term-missing tests/

# HTML raporu üretmek için
pytest --cov=myapp --cov-report=html:coverage_html tests/

# Minimum kapsam yüzdesi zorunlu kılmak için
pytest --cov=myapp --cov-fail-under=80 tests/

pyproject.toml içinde kalıcı yapılandırma:

# pyproject.toml
[tool.pytest.ini_options]
addopts = "--cov=myapp --cov-report=term-missing --cov-fail-under=75"

[tool.coverage.run]
omit = [
    "*/migrations/*",
    "*/settings/*",
    "tests/*",
    "*/venv/*"
]
branch = true

branch = true satırına dikkat edin. Branch coverage olmadan bir if bloğunun her iki kolunu test etmeden de %100 satır kapsamına ulaşabilirsiniz. Satır kapsıyor görünmesi, o satırdaki tüm mantıksal yolları test ettiğiniz anlamına gelmiyor.

CI pipeline’ında --cov-fail-under kullanmak, zamanla kapsamın erimesini önlüyor. Bir projede bu değeri 70’ten 85’e çıkardıktan sonra ekibin gizlenmiş on üç tane bug’ı keşfettiğine bizzat tanık oldum.

pytest-xdist: Test Süresini Paralel Çalıştırmayla Kırmak

Test sayısı birkaç yüzü geçince çalışma süresi sorun olmaya başlar. pytest-xdist, testleri birden fazla CPU çekirdeğinde veya hatta uzak makinelerde paralel çalıştırmanı sağlar.

# Mevcut CPU sayısı kadar worker kullan
pytest -n auto tests/

# Belirli sayıda worker
pytest -n 4 tests/

# Her worker için ayrı log dosyası
pytest -n auto --dist=loadfile tests/

--dist parametresi önemli:

  • loadfile: Aynı dosyadaki testleri aynı worker’a gönderir. Modül seviyesi fixture’lar için güvenli.
  • loadscope: Aynı scope’taki testleri bir arada tutar.
  • no: Varsayılan, dağıtım yok.
  • each: Her worker tüm testleri çalıştırır (nadiren kullanılır).

Dikkat edilmesi gereken bir nokta: paralel testler için fixture’larınızın thread-safe veya process-safe olması gerekiyor. Özellikle veritabanı işlemlerinde sorun çıkabiliyor. Django kullanıyorsanız pytest-django‘nun --reuse-db ve transaction rollback mekanizmalarıyla birlikte dikkatli yapılandırma gerekiyor.

# conftest.py içinde worker'a özgü geçici dizin kullanımı
import pytest
import tempfile
import os

@pytest.fixture(scope="session")
def tmp_work_dir(tmp_path_factory, worker_id):
    if worker_id == "master":
        return tmp_path_factory.mktemp("data")
    return tmp_path_factory.mktemp(f"data_{worker_id}")

Bir microservice projesinde 847 test vardı ve serial çalışmada 4 dakika 20 saniye sürüyordu. pytest -n auto ile bu süre 58 saniyeye indi. Tek değişiklik buydu.

pytest-mock: Mock Nesneleri Artık Fixture Gibi Kullanmak

unittest.mock zaten güçlü bir araç ama pytest-mock onu fixture sistemine entegre ederek çok daha ergonomik hale getiriyor.

pip install pytest-mock
# unittest.mock ile klasik yaklaşım
from unittest.mock import patch, MagicMock

def test_send_email_classic():
    with patch('myapp.email.smtp_client') as mock_smtp:
        mock_smtp.send.return_value = True
        result = send_welcome_email("[email protected]")
        assert result is True
        mock_smtp.send.assert_called_once()

# pytest-mock ile aynı test
def test_send_email_modern(mocker):
    mock_smtp = mocker.patch('myapp.email.smtp_client')
    mock_smtp.send.return_value = True
    result = send_welcome_email("[email protected]")
    assert result is True
    mock_smtp.send.assert_called_once()

mocker fixture’ı testin sonunda otomatik olarak tüm patch’leri geri alıyor, context manager yazmak gerekmiyor. Buna ek olarak mocker.spy kullanımı da çok değerli:

def test_calculate_discount_called(mocker):
    # Gerçek implementasyonu çalıştırır ama çağrıları izler
    spy = mocker.spy(pricing_service, 'calculate_discount')
    
    result = process_order(order_id=42, user_tier="premium")
    
    assert result.discount > 0
    spy.assert_called_once_with(user_tier="premium", base_price=mocker.ANY)

mocker.spy ile gerçek fonksiyon çalışmaya devam eder ama çağrı detaylarını izleyebilirsiniz. Entegrasyon testlerinde bir fonksiyonun kaç kez çağrıldığını doğrulamak için biçilmiş kaftan.

pytest-asyncio: Asenkron Kodları Test Etmek

Modern Python projelerinin büyük çoğunluğu async/await kullanıyor. FastAPI, aiohttp, SQLAlchemy async… Bu kodları test etmek için pytest-asyncio neredeyse zorunlu.

pip install pytest-asyncio
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"

asyncio_mode = "auto" ile her async def test_ fonksiyonu otomatik olarak asyncio event loop’unda çalışıyor, her teste @pytest.mark.asyncio eklemek gerekmiyor.

import pytest
import httpx
from myapp.api import app

@pytest.fixture
async def async_client():
    async with httpx.AsyncClient(app=app, base_url="http://test") as client:
        yield client

async def test_create_user(async_client):
    response = await async_client.post(
        "/users/",
        json={"username": "testuser", "email": "[email protected]"}
    )
    assert response.status_code == 201
    data = response.json()
    assert data["username"] == "testuser"

async def test_user_not_found(async_client):
    response = await async_client.get("/users/99999")
    assert response.status_code == 404

Dikkat edilmesi gereken bir nokta: farklı async kütüphaneleri (trio, asyncio) için loop yönetimi bazen karışıyor. Özellikle anyio kullanıyorsanız pytest-anyio da radar’ınıza girsin.

pytest-clarity ve pytest-icdiff: Assertion Hatalarını Okunabilir Yapmak

Bu iki eklenti hayat kalitesini ciddi ölçüde artırıyor. pytest-clarity, büyük veri yapılarının karşılaştırmalarını renk kodlamasıyla gösteriyor.

pip install pytest-clarity pytest-icdiff

Büyük bir JSON response’u karşılaştırdığınızda standart pytest çıktısı genellikle dev bir string farkı gösterir, gözle bulmak zordur. pytest-clarity ile hangi alanın ne olduğunu ve ne olması gerektiğini renkli diff ile görürsünüz.

def test_api_response_structure():
    response = get_user_profile(user_id=1)
    
    expected = {
        "id": 1,
        "username": "johndoe",
        "email": "[email protected]",
        "preferences": {
            "theme": "dark",
            "notifications": True,
            "language": "tr"
        }
    }
    
    assert response == expected
    # Hata durumunda pytest-clarity tam olarak hangi key'in
    # hangi değerden farklı olduğunu renk kodlamasıyla gösterir

Bu eklenti özellikle büyük API yanıtlarını test eden ekiplerde çok fazla zaman kazandırıyor. “Nerede yanlış?” sorusunu sormaya gerek kalmıyor.

pytest-randomly: Test Sırasını Karıştırarak Gizli Bağımlılıkları Bul

Bu eklenti fark edilmesi en zor bug türlerini yakalamak için çok etkili: testler arası gizli durum bağımlılıkları.

pip install pytest-randomly

Kurulumdan sonra hiçbir şey yapmanıza gerek yok. Testler her çalıştırmada farklı bir sırada çalışacak. Eğer testlerinizden biri sadece belirli bir test sonra gelince başarısız oluyorsa, bu eklenti bunu ortaya çıkaracak.

# Belirli bir seed ile tekrar edilebilir çalıştırma
pytest --randomly-seed=12345 tests/

# Son çalışmanın seed'ini kullanmak için
pytest --randomly-seed=last tests/

Çıktıda kullanılan seed değeri gösterilir. Bir CI koşumda intermittent failure görürseniz, o koşumun seed’ini alıp lokal’de aynı sırayı yeniden üretebilirsiniz. Bu debugging sürecini dramatik biçimde kısaltıyor.

Bir projede pytest-randomly kurduğumuzda testlerin yüzde sekizi ilk hafta başarısız olmaya başladı. Hepsi birbirini etkileyen global state sorunlarıydı, üretim ortamında da sporadik bug’lara yol açıyordu. Altı aydır fark edilemeyen sorunlar üç günde temizlendi.

pytest-timeout: Sonsuz Döngü ve Donmaları Engellemek

Özellikle I/O yoğun veya network bağımlı testlerde vazgeçilmez.

pip install pytest-timeout
# Global timeout ayarı
pytest --timeout=30 tests/

# pyproject.toml ile kalıcı ayar
[tool.pytest.ini_options]
timeout = 30
timeout_method = "thread"

Belirli testler için farklı timeout:

import pytest

@pytest.mark.timeout(5)
def test_quick_database_query():
    result = db.execute("SELECT COUNT(*) FROM users")
    assert result > 0

@pytest.mark.timeout(60)
def test_large_file_processing():
    result = process_large_csv("/data/big_file.csv")
    assert result.row_count > 10000

@pytest.mark.timeout(0)  # Bu test için timeout devre dışı
def test_migration_script():
    run_full_migration()

timeout_method seçenekleri:

  • thread: Ayrı bir thread’de çalışır, cross-platform destekli.
  • signal: UNIX’te sinyal kullanır, daha güvenilir ama sadece Unix.

CI’da timeout olmadan çalışan bir test suitesi, tek bir kilitlenmiş test yüzünden tüm pipeline’ı dondurabilir. Bunu production’da yaşamak eğlenceli değil.

pytest-benchmark: Performans Regresyonlarını Otomatik Yakalamak

Kod değişikliklerinin performansa etkisini ölçmek için kullanılıyor.

pip install pytest-benchmark
def parse_json_config(json_string):
    import json
    return json.loads(json_string)

def test_json_parsing_performance(benchmark):
    sample_config = '{"key": "value", "number": 42, "nested": {"a": 1}}'
    
    result = benchmark(parse_json_config, sample_config)
    
    assert result["key"] == "value"

# Daha karmaşık benchmark
def test_bulk_insert_performance(benchmark, db_connection):
    records = [{"name": f"user_{i}", "age": i % 100} for i in range(1000)]
    
    def do_bulk_insert():
        return db_connection.bulk_insert("users", records)
    
    stats = benchmark.pedantic(do_bulk_insert, rounds=10, warmup_rounds=2)
# Benchmark sonuçlarını JSON'a kaydet
pytest --benchmark-save=baseline tests/benchmarks/

# Baseline ile karşılaştır
pytest --benchmark-compare=baseline tests/benchmarks/

# Belirli bir yüzde yavaşlamayı hata say
pytest --benchmark-compare=baseline --benchmark-compare-fail=mean:10% tests/benchmarks/

Bu son komut özellikle kıymetli: önceki çalışmaya göre ortalama süre %10’dan fazla artarsa CI başarısız oluyor. Farkında olmadan performans regresyonu yaratmak böylece önleniyor.

pytest-parametrize-cases: Parametreli Testleri Okunabilir Yazmak

Standart @pytest.mark.parametrize çalışır ama çok sayıda parametre olduğunda okunması güçleşir.

pip install pytest-parametrize-cases
from pytest_parametrize_cases import Case, parametrize_cases

# Klasik yaklaşım - okunması zor
@pytest.mark.parametrize(
    "username,password,expected_status,expected_message",
    [
        ("", "pass123", 400, "Username required"),
        ("user", "", 400, "Password required"),
        ("user", "short", 400, "Password too short"),
        ("validuser", "ValidPass1!", 200, "Login successful"),
    ]
)
def test_login_classic(username, password, expected_status, expected_message):
    ...

# pytest-parametrize-cases ile - çok daha okunabilir
@parametrize_cases(
    Case("empty_username", username="", password="pass123",
         expected_status=400, expected_message="Username required"),
    Case("empty_password", username="user", password="",
         expected_status=400, expected_message="Password required"),
    Case("short_password", username="user", password="short",
         expected_status=400, expected_message="Password too short"),
    Case("valid_credentials", username="validuser", password="ValidPass1!",
         expected_status=200, expected_message="Login successful"),
)
def test_login(username, password, expected_status, expected_message):
    response = auth_service.login(username, password)
    assert response.status == expected_status
    assert response.message == expected_message

Her test case’e isim vermek, başarısız test çıktısında anında neyin bozulduğunu anlamamızı sağlıyor. test_login[valid_credentials] çok daha anlamlı test_login[validuser-ValidPass1!-200-Login successful]‘dan.

Factory Boy ve Faker ile Birlikte Kullanım Örüntüsü

Eklentiler tek başına değil, birlikte kullanıldığında güçleniyor. Gerçek bir senaryo:

pip install factory-boy faker pytest-mock pytest-asyncio
# tests/factories.py
import factory
from factory.faker import Faker
from myapp.models import User, Order

class UserFactory(factory.Factory):
    class Meta:
        model = User
    
    id = factory.Sequence(lambda n: n)
    username = Faker("user_name")
    email = Faker("email")
    is_active = True

class OrderFactory(factory.Factory):
    class Meta:
        model = Order
    
    id = factory.Sequence(lambda n: n)
    user = factory.SubFactory(UserFactory)
    total_amount = Faker("pydecimal", left_digits=4, right_digits=2, positive=True)
    status = "pending"

# tests/test_order_service.py
async def test_complete_order_sends_notification(mocker):
    user = UserFactory(email="[email protected]")
    order = OrderFactory(user=user, total_amount=150.00)
    
    mock_notify = mocker.patch("myapp.notifications.send_order_confirmation")
    mock_notify.return_value = True
    
    result = await order_service.complete_order(order.id)
    
    assert result.status == "completed"
    mock_notify.assert_called_once_with(
        email=user.email,
        order_id=order.id,
        amount=mocker.ANY
    )

conftest.py ile Plugin Konfigürasyonunu Merkezileştirmek

Tüm bu eklentileri proje genelinde tutarlı kullanmak için conftest.py kritik bir rol üstleniyor:

# conftest.py
import pytest
from unittest.mock import AsyncMock

# pytest-xdist worker ID'sine göre veritabanı izolasyonu
@pytest.fixture(scope="session")
def db_url(worker_id):
    if worker_id == "master":
        return "postgresql://localhost/testdb"
    return f"postgresql://localhost/testdb_{worker_id}"

# Tüm testlerde kullanılacak ortak mock'lar
@pytest.fixture(autouse=True)
def disable_external_calls(mocker):
    """Dış servis çağrılarını otomatik olarak devre dışı bırak"""
    mocker.patch("myapp.integrations.payment_gateway.charge", 
                 return_value={"status": "success", "transaction_id": "test_123"})
    mocker.patch("myapp.integrations.email_service.send",
                 return_value=True)

# Benchmark testleri için marker
def pytest_configure(config):
    config.addinivalue_line(
        "markers", "slow: Bu test yavaş çalışır, -m 'not slow' ile atlayın"
    )
    config.addinivalue_line(
        "markers", "integration: Harici servis gerektiren testler"
    )

Sonuç

pytest plugin ekosistemi, test yazmayı ciddi anlamda keyifli hale getirebilecek araçlarla dolu. Ama her projeye her eklentiyi atmak doğru yaklaşım değil.

Başlangıç noktası olarak şu sırayı öneriyorum: önce pytest-cov ile neyi test etmediğinizi görün, sonra pytest-mock ile harici bağımlılıklardan izole edin, ardından pytest-randomly ile gizli bağımlılıkları temizleyin. Suite’iniz büyüdükçe pytest-xdist ve pytest-timeout ekleyin.

Async kod yazıyorsanız pytest-asyncio günlük rutininizin parçası olmalı. Performans kritik bir şeyler üzerinde çalışıyorsanız pytest-benchmark CI’a entegre etmek uzun vadede pek çok baş ağrısını önlüyor.

Bu eklentilerin her birinin kendi trade-off’ları var: pytest-xdist paralel çalışma getiriyor ama fixture yönetimini karmaşıklaştırıyor. pytest-cov ile pytest-xdist‘i birlikte kullanmak için --cov-config ve --no-cov-on-fail gibi ek ayarlar gerekebiliyor. Ekip olarak hangi eklentileri kullandığınızı ve neden kullandığınızı bir CONTRIBUTING.md‘ye yazmak, yeni katılan mühendislerin sürprizle karşılaşmasını önlüyor.

Test altyapısı da kod gibi bakım gerektiriyor. Bunu erken kabullenmek, ilerleyen zamanlarda çok daha fazla zaman kazandırıyor.

Bir yanıt yazın

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