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.
