pytest ile Test Performansı Optimizasyonu: Paralel Çalıştırma ve Hız İyileştirme Teknikleri

Test suite’iniz büyüdükçe bir noktada fark edersiniz: pytest çalıştırıyorsunuz, kahvenizi alıyorsunuz, geri dönüyorsunuz ve hala çalışıyor. Bu, bir şeylerin yanlış gittiğinin işareti değil, ama daha iyisini yapabileceğinizin sinyali. Yüzlerce, binlerce test içeren projelerde test süresi ciddi bir darboğaz haline gelir. CI/CD pipeline’larınız yavaşlar, developer feedback loop uzar ve sonunda insanlar testleri atlamaya başlar. Bu yazıda pytest ile test performansını nasıl optimize edeceğinizi, paralel çalıştırmanın nasıl kurulacağını ve gerçek dünyada işe yarayan hız iyileştirme tekniklerini ele alacağız.

Neden Test Performansı Önemli?

Bir projedeki 500 testin her biri ortalama 0.5 saniye sürüyorsa, toplam süreniz 250 saniye yani 4 dakikayı aşıyor. Bu kulağa fazla gelmeyebilir, ancak her commit’te bu süreyi beklemek, günde onlarca kez CI/CD pipeline’ı tetikleyen bir ekipte ciddi zaman kaybına dönüşür. Daha da kötüsü, testler yavaşladıkça geliştiriciler TDD yapmaktan vazgeçer ve testleri “sonra çalıştırırım” moduna geçer.

Temel prensip şu: Testler hızlı çalışırsa çalıştırılır, yavaş çalışırsa atlanır.

Mevcut Durumu Analiz Etmek

Optimize etmeden önce darboğazları bulmak gerekir. pytest’in yerleşik profiling özellikleri bu konuda çok işe yarar.

# En yavaş 10 testi listele
pytest --durations=10

# Tüm test sürelerini göster
pytest --durations=0

# Detaylı verbose çıktı ile birlikte
pytest --durations=10 -v

# Belirli bir dizindeki testleri profille
pytest tests/ --durations=10 --tb=no -q

--durations çıktısı şöyle görünür:

======= slowest 10 durations =======
5.23s call     tests/test_database.py::test_large_query
3.11s call     tests/test_api.py::test_external_service
2.87s setup    tests/test_integration.py::test_full_workflow
1.44s call     tests/test_reports.py::test_pdf_generation

Bu çıktıya bakarak hangi testlerin ve hangi fazların (setup, call, teardown) yavaş olduğunu görürsünüz. Çoğu zaman sorun test mantığında değil, fixture setup’larında veya gereksiz I/O operasyonlarında olur.

pytest-xdist ile Paralel Test Çalıştırma

Paralel test çalıştırma, test süresini düşürmenin en etkili yollarından biridir. pytest-xdist bu iş için go-to araçtır.

# Kurulum
pip install pytest-xdist

# CPU sayısı kadar worker ile çalıştır
pytest -n auto

# Belirli sayıda worker ile çalıştır
pytest -n 4

# 2 worker ile sadece belirli modülü test et
pytest tests/test_api.py -n 2

Ancak paralel çalıştırma sihirli bir düğme değil. Testlerinizin birbirinden bağımsız olması gerekiyor. Eğer testler ortak state paylaşıyorsa, paralel çalıştırma flaky testlere yol açar.

Worker Dağılımını Kontrol Etmek

pytest-xdist’in test dağılımını nasıl yaptığını kontrol edebilirsiniz:

# Testleri load-balancing ile dağıt (varsayılan)
pytest -n 4 --dist=load

# Her worker'a sırayla test ata
pytest -n 4 --dist=each

# Aynı dosyadaki testleri aynı worker'a gönder
pytest -n 4 --dist=loadfile

# Aynı scope'taki testleri grupla
pytest -n 4 --dist=loadscope

--dist=loadfile özellikle database test’leri için değerlidir. Aynı dosyadaki testler aynı worker’a gider, dolayısıyla dosya bazlı fixture’lar güvenle paylaşılabilir.

Paralel Test İçin Fixture Tasarımı

Paralel çalıştırmayla test yazmak, dikkatli fixture tasarımı gerektirir. İşte gerçek dünyadan bir örnek:

# conftest.py
import pytest
import tempfile
import os
from pathlib import Path

@pytest.fixture(scope="function")
def temp_dir(tmp_path):
    """Her test için izole geçici dizin - paralel çalışmaya uygun"""
    return tmp_path

@pytest.fixture(scope="session")
def app_config():
    """Session scope - tüm worker'larda bir kez oluşturulur"""
    return {
        "debug": True,
        "database_url": "sqlite:///:memory:",
        "secret_key": "test-secret"
    }

@pytest.fixture(scope="function")
def db_session(worker_id):
    """
    worker_id: pytest-xdist'in sağladığı benzersiz worker kimliği
    Her worker kendi database'ini kullanır
    """
    if worker_id == "master":
        # Paralel çalışmıyorsa normal davran
        db_url = "sqlite:///test.db"
    else:
        # Her worker için ayrı DB dosyası
        db_url = f"sqlite:///test_{worker_id}.db"
    
    engine = create_engine(db_url)
    Base.metadata.create_all(engine)
    session = Session(engine)
    
    yield session
    
    session.close()
    Base.metadata.drop_all(engine)

worker_id fixture’ı pytest-xdist tarafından otomatik sağlanır. Bu sayede her worker kendi izole ortamında çalışır.

Fixture Optimizasyonu

Paralel çalıştırmadan önce fixture’larınızı optimize etmek çok daha büyük kazanç sağlar. Fixture scope yönetimi bu konunun kalbidir.

# KÖTÜ: Her test için database bağlantısı oluşturuluyor
@pytest.fixture(scope="function")
def db_connection():
    conn = create_expensive_connection()  # 2 saniye sürüyor
    yield conn
    conn.close()

# İYİ: Bağlantı bir kez açılıyor, transaction rollback ile izolasyon sağlanıyor
@pytest.fixture(scope="session")
def db_engine():
    engine = create_engine("postgresql://localhost/testdb")
    yield engine
    engine.dispose()

@pytest.fixture(scope="function")
def db_session(db_engine):
    connection = db_engine.connect()
    transaction = connection.begin()
    session = Session(bind=connection)
    
    yield session
    
    session.close()
    transaction.rollback()  # Test sonrası tüm değişiklikler geri alınır
    connection.close()

Bu pattern ile database bağlantısı session boyunca bir kez açılıyor, her test kendi transaction’ı içinde çalışıyor ve rollback ile temiz state’e dönüluyor. Gerçek bir projede bu yaklaşım test süresini yarıya indirebilir.

Lazy Fixture’lar ile Gereksiz Setup’tan Kaçınmak

import pytest
from unittest.mock import MagicMock, patch

# Sadece ihtiyaç duyulduğunda oluşturulan fixture
@pytest.fixture(scope="session")
def ml_model():
    """Büyük ML modeli - sadece ML testlerinde lazım"""
    # Bu fixture sadece kullanan testlerde çalışır
    import joblib
    return joblib.load("model/production_model.pkl")

# Hızlı mock alternatifi - çoğu test için yeterli
@pytest.fixture
def mock_model():
    model = MagicMock()
    model.predict.return_value = [0.85, 0.92, 0.78]
    return model

# Test sınıfında hangisinin kullanılacağını açıkça belirt
class TestPredictionService:
    def test_prediction_format(self, mock_model):
        """Mock yeterli - hızlı"""
        service = PredictionService(mock_model)
        result = service.predict([1, 2, 3])
        assert isinstance(result, list)
    
    @pytest.mark.slow
    def test_prediction_accuracy(self, ml_model):
        """Gerçek model gerekiyor - yavaş"""
        service = PredictionService(ml_model)
        result = service.predict(TEST_INPUT)
        assert result[0] > 0.8

Test Seçimi ve Filtreleme

Her seferinde tüm test suite’i çalıştırmak zorunda değilsiniz. Akıllı test seçimi geliştirme sürecini hızlandırır.

# Sadece belirli marker'lara sahip testleri çalıştır
pytest -m "not slow"
pytest -m "unit"
pytest -m "unit or integration"
pytest -m "not (slow or database)"

# Belirli keyword içeren testleri çalıştır
pytest -k "payment"
pytest -k "test_user and not test_admin"

# Son başarısız testleri önce çalıştır
pytest --ff

# Sadece başarısız testleri yeniden çalıştır
pytest --lf

# İlk başarısızlıkta dur
pytest -x

# İlk 3 başarısızlıktan sonra dur
pytest --maxfail=3

Marker Stratejisi

pytest.ini veya pyproject.toml dosyanızda marker’larınızı tanımlayın:

# pyproject.toml
[tool.pytest.ini_options]
markers = [
    "unit: Birim testleri, herhangi bir dış bağımlılık yok",
    "integration: Entegrasyon testleri, database veya servis gerektirir",
    "slow: Yavaş çalışan testler, 1 saniyeden uzun süren",
    "smoke: Smoke testler, deploy sonrası hızlı kontrol",
    "flaky: Ara sıra başarısız olan, dikkatli izlenmesi gereken testler"
]
addopts = "-ra --strict-markers"
# Testlerde marker kullanımı
import pytest

@pytest.mark.unit
def test_calculate_discount():
    assert calculate_discount(100, 10) == 90

@pytest.mark.integration
@pytest.mark.slow
def test_order_complete_workflow(db_session, email_service):
    """Gerçek database ve email servisi gerektirir"""
    order = create_order(db_session, items=[...])
    result = complete_order(order)
    assert result.status == "completed"
    assert email_service.sent_count == 1

Mock ve Stub Kullanımıyla Hız Kazanmak

Dış servis çağrıları test sürelerinin büyük kısmını oluşturur. Doğru mock stratejisi dramatik hız artışı sağlar.

import pytest
from unittest.mock import patch, MagicMock
import responses  # pip install responses
import httpretty  # pip install httpretty

# responses decorator ile HTTP mock
@responses.activate
def test_payment_api_success():
    responses.add(
        responses.POST,
        "https://api.payment.com/charge",
        json={"status": "success", "transaction_id": "txn_123"},
        status=200
    )
    
    result = charge_customer(amount=100, card_token="tok_visa")
    
    assert result.success is True
    assert result.transaction_id == "txn_123"
    assert len(responses.calls) == 1

# pytest fixture olarak mock servis
@pytest.fixture
def mock_payment_service():
    with patch("app.services.payment.PaymentGateway") as mock_gateway:
        instance = mock_gateway.return_value
        instance.charge.return_value = MagicMock(
            success=True,
            transaction_id="test_txn_001"
        )
        yield instance

# Cache'li fixture - aynı session'da tekrar kullanılır
@pytest.fixture(scope="session")
def mock_geocoding_service():
    """Geocoding API'yi mock'la - tüm session boyunca geçerli"""
    with patch("app.utils.geocoding.geocode") as mock_fn:
        mock_fn.return_value = {"lat": 41.0082, "lon": 28.9784}
        yield mock_fn

pytest-cache ve Incremental Testing

pytest-cache, test sonuçlarını saklar ve akıllı test sıralaması yapmanıza olanak tanır:

# Cache'i temizle
pytest --cache-clear

# Cache içeriğini göster
pytest --cache-show

# Son başarısız testleri önce çalıştır, sonra kalanları ekle
pytest --ff -x

# Sadece değişen dosyaları test et (pytest-testmon ile)
pip install pytest-testmon
pytest --testmon

pytest-testmon özellikle büyük projelerde değerlidir. Hangi testlerin hangi kaynak koduna bağlı olduğunu takip eder ve sadece değişen koda bağlı testleri çalıştırır.

# İlk çalıştırma - tüm testler
pytest --testmon

# Sonraki çalıştırmalar - sadece etkilenen testler
# app/payment.py değiştiyse sadece payment testleri çalışır
pytest --testmon

Kod Tabanlı Optimizasyonlar

Parametrize Testleri Verimli Kullanmak

import pytest

# KÖTÜ: Her kombinasyon için ayrı test
def test_validate_email_valid():
    assert validate_email("[email protected]") is True

def test_validate_email_invalid_format():
    assert validate_email("not-an-email") is False

def test_validate_email_missing_domain():
    assert validate_email("user@") is False

# İYİ: Parametrize ile tek tanım, birden fazla senaryo
@pytest.mark.parametrize("email,expected", [
    ("[email protected]", True),
    ("[email protected]", True),
    ("not-an-email", False),
    ("user@", False),
    ("@domain.com", False),
    ("", False),
    (None, False),
])
def test_validate_email(email, expected):
    assert validate_email(email) == expected

# Parametrize ile ID'ler - okunabilirlik için
@pytest.mark.parametrize("amount,discount,expected", [
    pytest.param(100, 10, 90, id="standard-discount"),
    pytest.param(200, 50, 100, id="half-price"),
    pytest.param(0, 10, 0, id="zero-amount"),
], ids=lambda x: str(x) if not hasattr(x, 'id') else x.id)
def test_apply_discount(amount, discount, expected):
    assert apply_discount(amount, discount) == expected

Ağır Setup’ları Paylaşmak

# conftest.py - Proje genelinde paylaşılan fixture'lar
import pytest
from pathlib import Path
import json

@pytest.fixture(scope="session")
def sample_data():
    """Büyük test veri seti - session boyunca bir kez yüklenir"""
    data_file = Path(__file__).parent / "fixtures" / "large_dataset.json"
    with open(data_file) as f:
        return json.load(f)

@pytest.fixture(scope="session")  
def compiled_regex_patterns():
    """Regex pattern'leri bir kez derle"""
    import re
    return {
        "email": re.compile(r'^[w.-]+@[w.-]+.w+$'),
        "phone": re.compile(r'^+?[ds-()]{10,}$'),
        "url": re.compile(r'https?://[w.-]+(?:/[w./-?=&]*)?')
    }

@pytest.fixture(scope="class")
def api_client(app_config):
    """Test sınıfı için tek client instance"""
    from app.testing import TestClient
    client = TestClient(app_config)
    client.authenticate(username="testuser", password="testpass")
    yield client
    client.logout()

CI/CD Pipeline’da Test Optimizasyonu

Gerçek dünyadaki CI/CD senaryosu için örnek bir GitHub Actions konfigürasyonu:

# .github/workflows/test.yml içeriği için pytest komutu örnekleri

# Stage 1: Hızlı unit testler (paralel)
pytest tests/unit/ -n auto --dist=loadfile -q --tb=short

# Stage 2: Integration testler (paralel, ayrı worker'lar)
pytest tests/integration/ -n 4 --dist=loadscope -q

# Stage 3: Slow testler (seri, gece çalışır)
pytest -m slow --tb=long -v

# Coverage ile birlikte çalıştırma
pytest tests/ -n auto --cov=app --cov-report=xml --cov-report=term-missing -q

# Sadece smoke testler (deploy sonrası kontrol)
pytest -m smoke --tb=short --timeout=30

pytest-timeout ile Sonsuz Döngüleri Engellemek

pip install pytest-timeout
import pytest

# Global timeout ayarı pytest.ini'de
# [pytest]
# timeout = 30

# Test bazlı timeout
@pytest.mark.timeout(5)
def test_fast_operation():
    result = quick_calculation(1000)
    assert result > 0

@pytest.mark.timeout(60)
@pytest.mark.slow
def test_data_migration():
    """Data migration testi - daha uzun sürebilir"""
    result = migrate_legacy_data(batch_size=1000)
    assert result.success_count > 0

Gerçek Dünya Sonuçları

Bir e-ticaret projesinde uyguladığımız optimizasyonların öncesi ve sonrası:

Öncesi:

  • 847 test
  • Toplam süre: 18 dakika 32 saniye
  • CI pipeline bloke süresi: 25 dakika (setup dahil)

Sonrası (uygulanan teknikler):

  • Fixture scope optimizasyonu: 8 dakika 15 saniyeye düştü
  • pytest-xdist ile -n 8: 2 dakika 10 saniyeye düştü
  • Mock optimizasyonu ve testmon: 1 dakika 45 saniyeye (sadece değişen testler)
  • CI pipeline süresi: 8 dakikaya düştü

Uygulanan adımlar sırasıyla şunlardı: önce --durations=10 ile darboğazlar tespit edildi, sonra session-scope fixture’lar düzenlendi, ardından dış servis çağrıları mock’landı ve son olarak paralel çalıştırma aktifleştirildi.

Sonuç

Test performans optimizasyonu, tek seferlik yapılan bir iş değil, sürekli dikkat gerektiren bir pratik. Şu adımları takip ederek başlayabilirsiniz:

  • İlk adım olarak --durations=10 ile gerçek darboğazları bulun, tahmin yürütmeyin.
  • Fixture scope’larını gözden geçirin. Çoğu projede function scope yerine session veya module scope kullanılabilecek onlarca fixture vardır.
  • Dış bağımlılıkları mock’layın. HTTP çağrıları, dosya sistemi operasyonları ve ağır hesaplamalar prime mock adaylarıdır.
  • pytest-xdist’i ekleyin, ancak testlerinizin izolasyonundan emin olduktan sonra. Flaky testlerle başa çıkmak zaman kaybıdır.
  • Marker stratejisi geliştirin. Unit, integration ve slow marker’ları CI/CD’de farklı stage’lerde çalıştırmanızı sağlar.
  • pytest-testmon’ı değerlendirin. Büyük monorepo’larda sadece etkilenen testlerin çalışması dev feedback loop’unu dakikalardan saniyelere indirebilir.

Test hızı, test kalitesinden taviz vermeden artırılabilir. Hızlı testler daha sık çalıştırılır, daha sık çalıştırılan testler daha erken bug yakalar. Bu, sysadmin perspektifinden bakıldığında pipeline maliyetini düşürür, developer mutluluğunu artırır ve production güvenilirliğini iyileştirir. Yatırım yapmaya değer.

Bir yanıt yazın

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