pytest Fixture Kullanımı ve Kapsamlı Test Organizasyonu

Bir süredir farklı ekiplerin test altyapılarını inceleme fırsatım oluyor. Çoğunda aynı sorunu görüyorum: testler var, çalışıyor, ama her test fonksiyonu kendi başına bir ada gibi. Aynı veritabanı bağlantısı beş farklı yerde kurulup yıkılıyor, aynı kullanıcı objesi on farklı testte yeniden yaratılıyor. Sonunda test suite’i büyüdükçe bir kaos ortaya çıkıyor. pytest’in fixture sistemi tam da bu sorunu çözmek için var ve doğru kullanıldığında test kodunu uygulama kodundan bile daha temiz hale getirebiliyor.

Fixture Nedir, Neden Bu Kadar Güçlüdür?

pytest fixture’ları, testlerin ihtiyaç duyduğu bağlamı, veriyi ve kaynakları hazırlayan fonksiyonlardır. Ama bunu “setup/teardown” mekanizması olarak düşünmek yetersiz bir bakış açısı. Fixture’lar birer bağımlılık enjeksiyonu (dependency injection) mekanizmasıdır. Bir test fonksiyonu hangi fixture’lara ihtiyaç duyduğunu parametre adı olarak belirtir, pytest gerisi halleder.

# Basit bir fixture örneği
import pytest

@pytest.fixture
def sample_user():
    return {
        "id": 1,
        "username": "ahmet_yilmaz",
        "email": "[email protected]",
        "role": "admin"
    }

def test_user_has_email(sample_user):
    assert "@" in sample_user["email"]

def test_user_is_admin(sample_user):
    assert sample_user["role"] == "admin"

Görüldüğü gibi test_user_has_email ve test_user_is_admin fonksiyonları sample_user fixture’ını parametre olarak alıyor. pytest, test ismini görünce otomatik olarak ilgili fixture fonksiyonunu bulup çalıştırıyor ve dönen değeri teste enjekte ediyor.

Scope: Fixture’ların Yaşam Döngüsünü Kontrol Etmek

Bu noktada işler ilginçleşiyor. Her fixture için bir scope tanımlayabilirsiniz. Bu scope, fixture’ın ne zaman oluşturulup ne zaman yıkılacağını belirler.

  • function: Varsayılan değer. Her test fonksiyonu için ayrı ayrı çalışır.
  • class: Aynı sınıf içindeki tüm testler için bir kez çalışır.
  • module: Aynı dosyadaki tüm testler için bir kez çalışır.
  • session: Tüm test suite boyunca yalnızca bir kez çalışır.

Veritabanı bağlantısı kurmanın maliyetli olduğu bir senaryo düşünün. Her test için yeni bir bağlantı açıp kapamak hem yavaş hem de gereksiz:

import pytest
import psycopg2

@pytest.fixture(scope="session")
def db_connection():
    """Tüm test session'ı boyunca tek bir bağlantı kullanılır."""
    conn = psycopg2.connect(
        host="localhost",
        database="test_db",
        user="testuser",
        password="testpass"
    )
    yield conn
    conn.close()
    print("Veritabanı bağlantısı kapatıldı.")

@pytest.fixture(scope="function")
def db_cursor(db_connection):
    """Her test için temiz bir cursor, ama aynı bağlantı üzerinden."""
    cursor = db_connection.cursor()
    yield cursor
    db_connection.rollback()  # Her testten sonra değişiklikleri geri al
    cursor.close()

Bu yapı sayesinde bağlantı bir kez kurulur, her test kendi cursor’ını alır ve test bittiğinde rollback yapılarak temiz bir slate elde edilir. Bu pattern production sistemlerde testleri ciddi oranda hızlandırıyor.

yield ile Setup ve Teardown

Yukarıdaki örnekte yield kullandım. Bu pytest fixture’larının en güçlü özelliklerinden biri. yield‘den önceki kod setup, sonraki kod teardown işlevi görür.

import pytest
import tempfile
import os

@pytest.fixture
def temp_config_file():
    """Geçici bir konfigürasyon dosyası oluşturur ve test biter bitmez siler."""
    config_content = """
[database]
host = localhost
port = 5432
name = testdb

[cache]
backend = redis
timeout = 300
"""
    # Setup: Geçici dosya oluştur
    temp_file = tempfile.NamedTemporaryFile(
        mode='w',
        suffix='.ini',
        delete=False
    )
    temp_file.write(config_content)
    temp_file.close()
    
    yield temp_file.name  # Test bu yolu kullanır
    
    # Teardown: Dosyayı temizle
    if os.path.exists(temp_file.name):
        os.unlink(temp_file.name)

def test_config_file_readable(temp_config_file):
    assert os.path.exists(temp_config_file)
    with open(temp_config_file) as f:
        content = f.read()
    assert "database" in content
    assert "localhost" in content

Bu yaklaşımın güzelliği şu: test başarısız olsa bile teardown kodu çalışır. Eski try/finally bloklarıyla uğraşmak yerine temiz ve okunabilir bir yapı elde ediyorsunuz.

conftest.py: Fixture’ları Organize Etmenin Doğru Yolu

Projeniz büyüdükçe fixture’ları nereye koyacağınız önemli bir soru haline gelir. pytest’in conftest.py dosyası bu sorunu elegant bir şekilde çözer. Bu dosyaya koyduğunuz fixture’lar, o dizin ve altındaki tüm test dosyaları tarafından import etmeden kullanılabilir.

Tipik bir proje yapısı şöyle görünebilir:

proje/
├── src/
│   ├── api/
│   ├── services/
│   └── models/
├── tests/
│   ├── conftest.py          # Genel fixture'lar
│   ├── unit/
│   │   ├── conftest.py      # Unit test fixture'ları
│   │   ├── test_models.py
│   │   └── test_services.py
│   ├── integration/
│   │   ├── conftest.py      # Integration test fixture'ları
│   │   └── test_api.py
│   └── e2e/
│       ├── conftest.py      # E2E test fixture'ları
│       └── test_workflows.py

Bu hiyerarşik yapıda, tests/conftest.py içindeki fixture’lar tüm testlere erişilebilirken, tests/unit/conftest.py içindekiler yalnızca unit testlere açıktır.

# tests/conftest.py - Tüm testlerin erişebildiği fixture'lar

import pytest
from src.database import Database
from src.app import create_app

@pytest.fixture(scope="session")
def app():
    """Flask/FastAPI uygulamasını test modunda başlatır."""
    application = create_app(config="testing")
    application.config["TESTING"] = True
    application.config["DATABASE_URL"] = "sqlite:///:memory:"
    return application

@pytest.fixture(scope="session")
def test_client(app):
    """HTTP testleri için test client'ı sağlar."""
    return app.test_client()

@pytest.fixture(scope="function")
def clean_db(app):
    """Her test için temiz bir veritabanı durumu sağlar."""
    with app.app_context():
        Database.create_all()
        yield
        Database.drop_all()
# tests/unit/conftest.py - Sadece unit testlere özel

import pytest
from unittest.mock import MagicMock

@pytest.fixture
def mock_email_service():
    """Gerçek email göndermeden test etmek için mock servis."""
    mock = MagicMock()
    mock.send.return_value = {"status": "sent", "message_id": "test-123"}
    return mock

@pytest.fixture
def mock_payment_gateway():
    """Ödeme gateway'ini taklit eder."""
    mock = MagicMock()
    mock.charge.return_value = {
        "success": True,
        "transaction_id": "txn_test_001"
    }
    return mock

Fixture Parametrization: Aynı Testi Farklı Senaryolar İçin Çalıştırmak

Fixture parametrization, bir testin birden fazla senaryo için otomatik olarak tekrarlanmasını sağlar. Bu özelliği yeterince kullanan pek fazla ekip göremedim, ama production bug’larının büyük çoğunluğunu edge case’lerde yakalamayı sağlıyor.

import pytest

# Farklı kullanıcı rolleri için test verisi
@pytest.fixture(params=[
    {"role": "admin", "can_delete": True, "can_create": True},
    {"role": "editor", "can_delete": False, "can_create": True},
    {"role": "viewer", "can_delete": False, "can_create": False},
])
def user_with_role(request):
    """Her parametre için ayrı bir test çalışır."""
    return request.param

def test_user_permissions(user_with_role):
    """Bu test 3 kez çalışır: admin, editor ve viewer için."""
    user = user_with_role
    
    if user["role"] == "admin":
        assert user["can_delete"] is True
        assert user["can_create"] is True
    elif user["role"] == "editor":
        assert user["can_delete"] is False
        assert user["can_create"] is True
    else:
        assert user["can_delete"] is False
        assert user["can_create"] is False

Bunu daha gerçekçi bir senaryo için genişletelim. Bir API endpoint’i farklı HTTP metodları için test etmemiz gerektiğini düşünün:

import pytest
import requests

@pytest.fixture(params=[
    ("GET", "/api/users", 200),
    ("GET", "/api/users/999", 404),
    ("POST", "/api/users", 201),
    ("DELETE", "/api/users/1", 204),
])
def api_scenario(request):
    method, endpoint, expected_status = request.param
    return {
        "method": method,
        "endpoint": endpoint,
        "expected_status": expected_status
    }

def test_api_status_codes(test_client, api_scenario):
    """Her API senaryosu için doğru HTTP status kodu döndürülüyor mu?"""
    response = getattr(test_client, api_scenario["method"].lower())(
        api_scenario["endpoint"]
    )
    assert response.status_code == api_scenario["expected_status"], (
        f"{api_scenario['method']} {api_scenario['endpoint']} için "
        f"beklenen {api_scenario['expected_status']}, "
        f"gelen {response.status_code}"
    )

Factory Fixture Pattern: Esnek Test Verisi Üretimi

Gerçek dünya projelerinde sıkça karşılaşılan bir sorun: bazen fixture’dan dönen veri üzerinde küçük değişiklikler yapmak istiyorsunuz. Örneğin çoğunlukla standart bir kullanıcı objesi lazım, ama bazı testler için email alanının farklı olması gerekiyor. Factory pattern bu durumu zarif biçimde çözüyor:

import pytest
from dataclasses import dataclass, field
from typing import Optional

@dataclass
class User:
    id: int
    username: str
    email: str
    role: str = "viewer"
    is_active: bool = True
    department: Optional[str] = None

@pytest.fixture
def make_user():
    """Özelleştirilebilir kullanıcı objesi üreten factory."""
    created_users = []
    
    def _make_user(
        id=1,
        username="test_kullanici",
        email="[email protected]",
        role="viewer",
        is_active=True,
        department=None
    ):
        user = User(
            id=id,
            username=username,
            email=email,
            role=role,
            is_active=is_active,
            department=department
        )
        created_users.append(user)
        return user
    
    yield _make_user
    
    # Teardown: oluşturulan tüm kullanıcıları temizle
    # (gerçek DB senaryosunda burada silme işlemi yapılırdı)
    created_users.clear()

def test_admin_can_access_dashboard(make_user):
    admin = make_user(role="admin", department="IT")
    assert admin.role == "admin"
    assert admin.department == "IT"

def test_inactive_user_blocked(make_user):
    inactive_user = make_user(is_active=False)
    assert not inactive_user.is_active

def test_multiple_users_interaction(make_user):
    """Birden fazla kullanıcı gereken testlerde factory çok işe yarıyor."""
    sender = make_user(id=1, username="gonderen", role="admin")
    receiver = make_user(id=2, username="alici", role="viewer")
    
    assert sender.id != receiver.id
    assert sender.role != receiver.role

Fixture’lar Arası Bağımlılık ve Zincirleme

Fixture’lar birbirini parametre olarak alabilir. Bu sayede karmaşık test ortamlarını küçük, tekrar kullanılabilir parçalardan inşa edebilirsiniz:

import pytest
import redis
from src.services.cache import CacheService
from src.services.user_service import UserService

@pytest.fixture(scope="session")
def redis_client():
    """Test için Redis bağlantısı."""
    client = redis.Redis(
        host="localhost",
        port=6379,
        db=15,  # Production DB'yi kullanmamak için ayrı bir DB
        decode_responses=True
    )
    yield client
    client.flushdb()  # Test DB'yi temizle
    client.close()

@pytest.fixture
def cache_service(redis_client):
    """Redis üzerine inşa edilmiş cache servisi."""
    service = CacheService(redis_client)
    yield service
    # Her testten sonra cache'i temizle
    redis_client.flushdb()

@pytest.fixture
def user_service(cache_service, clean_db):
    """Cache ve DB bağımlılıkları olan UserService."""
    return UserService(
        cache=cache_service,
        db_session=clean_db
    )

def test_user_service_caches_result(user_service, redis_client):
    """Kullanıcı sorgulandıktan sonra cache'e alınıyor mu?"""
    # İlk sorgu - DB'den gelir
    user = user_service.get_user_by_id(1)
    
    # Cache'de kayıtlı olmalı
    cache_key = f"user:1"
    assert redis_client.exists(cache_key)
    
    # İkinci sorgu - cache'den gelmeli
    cached_user = user_service.get_user_by_id(1)
    assert user.id == cached_user.id

autouse ile Otomatik Fixture Uygulaması

Bazı fixture’ların her teste otomatik olarak uygulanmasını isteyebilirsiniz. Örneğin her testten önce log’ları temizlemek, ya da her test için ayrı bir transaction başlatmak:

import pytest
import logging

@pytest.fixture(autouse=True)
def reset_logging():
    """Her testten önce logging konfigürasyonunu sıfırlar."""
    # Setup
    for handler in logging.root.handlers[:]:
        logging.root.removeHandler(handler)
    
    logging.basicConfig(level=logging.DEBUG)
    
    yield
    
    # Teardown
    logging.shutdown()

@pytest.fixture(autouse=True, scope="function")
def test_isolation_marker(request):
    """Her testin başlangıç ve bitiş zamanını log'a yazar."""
    import time
    start_time = time.time()
    print(f"n>>> Test başladı: {request.node.name}")
    
    yield
    
    elapsed = time.time() - start_time
    print(f"<<< Test bitti: {request.node.name} ({elapsed:.3f}s)")

autouse=True kullanırken dikkatli olmak gerekiyor. Her yere uygulanan bir fixture, beklenmedik yan etkiler yaratabilir. Bu yüzden autouse fixture’larını genellikle scope ile birlikte ve modül ya da class bazında tutmayı tercih ediyorum.

Marker’lar ile Fixture Kombinasyonu

pytest marker’larını fixture’larla kombine etmek, test kategorileri oluşturmanın güçlü bir yoludur:

# conftest.py
import pytest

def pytest_configure(config):
    config.addinivalue_line("markers", "slow: yavaş çalışan testleri işaretler")
    config.addinivalue_line("markers", "requires_network: ağ bağlantısı gerektiren testler")
    config.addinivalue_line("markers", "db_required: veritabanı gerektiren testler")

@pytest.fixture(autouse=True)
def skip_slow_tests(request):
    """--no-slow flag'i verilmişse yavaş testleri atla."""
    if request.node.get_closest_marker("slow"):
        if request.config.getoption("--no-slow", default=False):
            pytest.skip("Yavaş testler atlandı (--no-slow)")

# test dosyasında kullanım
@pytest.mark.slow
@pytest.mark.db_required
def test_large_data_processing(clean_db, make_user):
    """Bu test hem yavaş hem de DB gerektiriyor."""
    users = [make_user(id=i, username=f"user_{i}") for i in range(1000)]
    # ... işlem
    assert len(users) == 1000

Bunu çalıştırırken:

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

# Sadece hızlı testleri çalıştır
pytest tests/ --no-slow

# Sadece belirli marker'lı testleri çalıştır
pytest tests/ -m "db_required and not slow"

# Fixture bağımlılıklarını görüntüle
pytest tests/ --fixtures

# Hangi fixture'ların hangi testlere uygulandığını gör
pytest tests/ --setup-show

Gerçek Dünya Senaryosu: Mikroservis Test Altyapısı

Tüm bu kavramları bir araya getiren gerçekçi bir örnek üzerinden gidelim. Bir e-ticaret mikroservisini test ettiğimizi varsayalım:

# tests/conftest.py
import pytest
import fakeredis
from unittest.mock import patch, MagicMock
from src.app import create_app
from src.database import db as _db

@pytest.fixture(scope="session")
def app():
    app = create_app("testing")
    ctx = app.app_context()
    ctx.push()
    yield app
    ctx.pop()

@pytest.fixture(scope="session")
def fake_redis():
    """Gerçek Redis yerine in-memory fake Redis kullan."""
    return fakeredis.FakeRedis(decode_responses=True)

@pytest.fixture(scope="function")
def db(app):
    _db.create_all()
    yield _db
    _db.session.remove()
    _db.drop_all()

@pytest.fixture
def client(app, db):
    return app.test_client()

@pytest.fixture
def auth_headers(client, make_user):
    """JWT token ile authenticated request headers üretir."""
    user = make_user(role="admin")
    response = client.post("/api/auth/login", json={
        "username": user.username,
        "password": "test_sifre_123"
    })
    token = response.json["access_token"]
    return {"Authorization": f"Bearer {token}"}

@pytest.fixture
def mock_payment():
    """Ödeme servisini mock'la, gerçek para gitmesin."""
    with patch("src.services.payment.PaymentGateway") as mock:
        instance = mock.return_value
        instance.process_payment.return_value = {
            "success": True,
            "transaction_id": "mock_txn_12345",
            "amount": None  # Testte doldurulacak
        }
        yield instance

# Test kullanımı
def test_complete_order_flow(client, auth_headers, mock_payment, make_user, db):
    """Sipariş oluşturma, ödeme ve onay akışını test eder."""
    # Sipariş oluştur
    order_response = client.post(
        "/api/orders",
        json={"product_id": 1, "quantity": 2},
        headers=auth_headers
    )
    assert order_response.status_code == 201
    order_id = order_response.json["order_id"]
    
    # Ödeme yap
    payment_response = client.post(
        f"/api/orders/{order_id}/pay",
        json={"card_token": "tok_test_visa"},
        headers=auth_headers
    )
    assert payment_response.status_code == 200
    assert mock_payment.process_payment.called
    
    # Siparişin durumunu kontrol et
    status_response = client.get(
        f"/api/orders/{order_id}",
        headers=auth_headers
    )
    assert status_response.json["status"] == "paid"

Sonuç

pytest fixture sistemi, test kodunu uygulama kodu gibi mimari düşünerek yazmanızı sağlar. Doğru scope seçimi test hızını dramatik biçimde etkiler, conftest.py hiyerarşisi fixture’ların nereden geldiğini netleştirir, factory pattern tekrara son verir, zincirleme bağımlılıklar karmaşık entegrasyon senaryolarını parçalara böler.

En kritik nokta şu: fixture’larınız ne kadar iyi organize edilirse, yeni test yazmak o kadar kolaylaşır. Bir ekip üyesi gelip auth_headers fixture’ını kullandığında kimlik doğrulama detaylarına bakmak zorunda kalmaz. mock_payment varken gerçek bir ödeme gateway’ine bağlanmaktan endişelenmez. Bu soyutlama katmanı, uzun vadede test suite’ini bakımı yapılabilir kılan şeydir.

Başlangıç için küçük bir proje alın, önce conftest.py dosyalarını düzenleyin, tekrarlayan setup kodlarını fixture’lara taşıyın. Kısa sürede farkı göreceksiniz.

Bir yanıt yazın

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