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.
