pytest ile Asenkron Kod Testi: asyncio Entegrasyonu
Asenkron kod yazmak bir süre sonra alışkanlık haline geliyor. Ama o kodu test etmek? İşte orada çoğu kişi tökezliyor. “await şunu yaptım, test geçti” diyorsunuz ama aslında test hiçbir şey yapmamış. Bu yazıda pytest ile asyncio entegrasyonunu, gerçek senaryolar üzerinden, acı çekerek öğrendiğim yollarla anlatacağım.
Neden Asenkron Test Zordur
Senkron kodda pytest’in işi basittir: fonksiyonu çağır, sonucu al, assert et. Ama asenkron fonksiyonlar bir coroutine objesi döndürür, doğrudan çalıştırılmazlar. Şöyle bir şey yazarsanız:
def test_veri_cek():
sonuc = veri_cek_async() # Bu bir coroutine objesi döndürür
assert sonuc is not None # Bu her zaman geçer, yanlış bir test!
Buradaki tehlike şu: test gerçekten “geçiyor” çünkü sonuc bir coroutine objesi ve None değil. Ama asenkron kod hiç çalışmadı. Production’da hata alırsınız, testte görmezsiniz. Bu tam anlamıyla “yanlış güven” senaryosu.
pytest-asyncio Kurulumu ve Temel Yapı
İlk adım pytest-asyncio paketini kurmak:
pip install pytest-asyncio
# veya pyproject.toml / requirements-dev.txt'e ekleyin
pip install pytest>=7.0 pytest-asyncio>=0.21
Kurulumdan sonra pytest.ini veya pyproject.toml dosyanıza asyncio modunu tanımlamanız gerekiyor. Bu adımı atlarsanız her test için ayrı ayrı dekoratör kullanmak zorunda kalırsınız:
# pytest.ini
[pytest]
asyncio_mode = auto
# ya da pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_mode = auto ayarı, async def ile tanımlanmış tüm test fonksiyonlarını otomatik olarak asyncio event loop’u içinde çalıştırır. Bu, özellikle büyük projelerde hayat kurtarır.
Eğer auto modunu kullanmak istemiyorsanız, her test fonksiyonuna @pytest.mark.asyncio dekoratörü eklemeniz gerekir:
import pytest
@pytest.mark.asyncio
async def test_basit_asenkron():
import asyncio
await asyncio.sleep(0.1)
assert True
Gerçek Dünya Senaryosu: HTTP İstemcisi Testi
Bir izleme servisi yazıyorsunuz diyelim. Dış API’lara istek atıyor ve yanıtları veritabanına kaydediyor. Bu servisi test etmek için dış API’ya gerçekten istek atmak istemiyorsunuz: hem yavaş, hem güvenilmez, hem de rate limit problemi yaratır.
Önce servis koduna bakalım:
# servis.py
import httpx
import asyncio
from typing import Optional
class IzlemeServisi:
def __init__(self, base_url: str, timeout: int = 30):
self.base_url = base_url
self.timeout = timeout
self._client: Optional[httpx.AsyncClient] = None
async def __aenter__(self):
self._client = httpx.AsyncClient(timeout=self.timeout)
return self
async def __aexit__(self, *args):
if self._client:
await self._client.aclose()
async def sunucu_durumu_kontrol(self, endpoint: str) -> dict:
yanit = await self._client.get(f"{self.base_url}/{endpoint}")
yanit.raise_for_status()
return {
"durum": yanit.status_code,
"yanit_suresi": yanit.elapsed.total_seconds(),
"veri": yanit.json()
}
async def toplu_kontrol(self, endpointler: list) -> list:
gorevler = [self.sunucu_durumu_kontrol(ep) for ep in endpointler]
return await asyncio.gather(*gorevler, return_exceptions=True)
Şimdi bunu test edelim. httpx‘in kendi AsyncMock desteğini ve pytest-httpx gibi araçları kullanabilirsiniz, ama burada unittest.mock ile nasıl yapılacağını gösterelim:
# test_servis.py
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from servis import IzlemeServisi
@pytest.fixture
async def izleme_servisi():
async with IzlemeServisi("https://api.example.com") as servis:
yield servis
@pytest.mark.asyncio
async def test_sunucu_durumu_kontrol_basarili():
mock_yanit = MagicMock()
mock_yanit.status_code = 200
mock_yanit.elapsed.total_seconds.return_value = 0.123
mock_yanit.json.return_value = {"durum": "aktif", "versiyon": "1.0"}
with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get:
mock_get.return_value = mock_yanit
async with IzlemeServisi("https://api.example.com") as servis:
sonuc = await servis.sunucu_durumu_kontrol("health")
assert sonuc["durum"] == 200
assert sonuc["yanit_suresi"] == 0.123
assert sonuc["veri"]["durum"] == "aktif"
AsyncMock ile Dikkat Edilmesi Gerekenler
unittest.mock.AsyncMock Python 3.8 ile geldi. Ondan önce farklı yaklaşımlar kullanılıyordu. Eğer hala 3.7 kullanan bir projeniz varsa (ki 2025’te varsa ciddi bir sorun bu), ayrı bir async mock tanımlamanız gerekir.
AsyncMock’un senkron Mock’tan farkı şu: çağrıldığında bir coroutine döndürür. Yani await mock_func() doğru çalışır. Eğer normal MagicMock kullansaydınız await beklediğiniz gibi çalışmaz ve garip hatalar alırdınız.
import pytest
from unittest.mock import AsyncMock, patch
async def veritabani_kaydet(veri: dict) -> bool:
# Normalde gerçek DB işlemi
pass
async def veri_isle_ve_kaydet(ham_veri: str) -> bool:
islenmis = {"deger": ham_veri.upper(), "uzunluk": len(ham_veri)}
return await veritabani_kaydet(islenmis)
@pytest.mark.asyncio
async def test_veri_isle_ve_kaydet():
with patch("__main__.veritabani_kaydet", new_callable=AsyncMock) as mock_db:
mock_db.return_value = True
sonuc = await veri_isle_ve_kaydet("merhaba")
assert sonuc is True
mock_db.assert_called_once_with({"deger": "MERHABA", "uzunluk": 7})
Fixture’larda Async Kullanımı
Async fixture’lar özellikle veritabanı bağlantıları, Redis bağlantıları veya test ortamı kurulumu için kritik. Şöyle düşünün: her test öncesinde test veritabanını temizlemek ve sonrasında bağlantıyı düzgünce kapatmak istiyorsunuz.
import pytest
import asyncpg
@pytest.fixture(scope="session")
async def veritabani_havuzu():
"""Session boyunca tek bir connection pool kullan"""
havuz = await asyncpg.create_pool(
host="localhost",
database="test_db",
user="test_user",
password="test_pass",
min_size=2,
max_size=10
)
yield havuz
await havuz.close()
@pytest.fixture(autouse=True)
async def temiz_veritabani(veritabani_havuzu):
"""Her test öncesinde tabloları temizle"""
async with veritabani_havuzu.acquire() as baglanti:
await baglanti.execute("TRUNCATE TABLE kullanicilar CASCADE")
await baglanti.execute("TRUNCATE TABLE loglar CASCADE")
yield
# Test sonrası ek temizlik gerekiyorsa buraya ekle
@pytest.fixture
async def ornek_kullanici(veritabani_havuzu):
async with veritabani_havuzu.acquire() as baglanti:
kullanici_id = await baglanti.fetchval(
"INSERT INTO kullanicilar (ad, email) VALUES ($1, $2) RETURNING id",
"Test Kullanici",
"[email protected]"
)
return {"id": kullanici_id, "ad": "Test Kullanici", "email": "[email protected]"}
Burada scope="session" kullanımına dikkat edin. Bağlantı havuzu pahalı bir kaynak, her test için yeniden oluşturmak testlerinizi gereksiz yere yavaşlatır. Ama tabloları temizleyen fixture her test için çalışmalı, autouse=True bunu sağlıyor.
Eş Zamanlılık Testleri: Race Condition’ları Yakalamak
Bu kısım çoğu test kılavuzunda eksik kalıyor. Asenkron kodun en kritik test senaryosu, eş zamanlı erişim durumlarıdır. Bir cache mekanizması yazdığınızı düşünün:
import asyncio
import pytest
from typing import Dict, Optional
class AkilliCache:
def __init__(self):
self._depo: Dict[str, str] = {}
self._yukleniyor: Dict[str, asyncio.Event] = {}
async def getir(self, anahtar: str, yukleyici) -> str:
if anahtar in self._depo:
return self._depo[anahtar]
if anahtar in self._yukleniyor:
# Başka bir coroutine zaten yüklüyor, bekle
await self._yukleniyor[anahtar].wait()
return self._depo[anahtar]
event = asyncio.Event()
self._yukleniyor[anahtar] = event
try:
deger = await yukleyici(anahtar)
self._depo[anahtar] = deger
return deger
finally:
event.set()
del self._yukleniyor[anahtar]
@pytest.mark.asyncio
async def test_ayni_anda_coklu_istek():
"""Aynı anahtara eş zamanlı istekler geldiğinde yükleyici sadece bir kez çağrılmalı"""
cache = AkilliCache()
cagri_sayisi = 0
async def pahali_yukleyici(anahtar: str) -> str:
nonlocal cagri_sayisi
cagri_sayisi += 1
await asyncio.sleep(0.05) # Pahalı işlem simülasyonu
return f"deger_{anahtar}"
# 10 eş zamanlı istek gönder
gorevler = [cache.getir("test_anahtari", pahali_yukleyici) for _ in range(10)]
sonuclar = await asyncio.gather(*gorevler)
assert all(s == "deger_test_anahtari" for s in sonuclar)
assert cagri_sayisi == 1, f"Yükleyici {cagri_sayisi} kez çağrıldı, 1 kez çağrılmalıydı"
Bu test, cache mekanizmanızın “thundering herd” problemini çözüp çözmediğini doğrular. Senkron testle bunu yakalamak neredeyse imkansızdı.
Timeout Testleri
Asenkron kodda timeout yönetimi kritik. Bir istek sonsuza kadar bekleyebilir, bunu test etmeniz gerekiyor:
import asyncio
import pytest
async def zaman_asimi_olan_islem(sure: float) -> str:
try:
async with asyncio.timeout(2.0): # Python 3.11+
await asyncio.sleep(sure)
return "tamamlandi"
except asyncio.TimeoutError:
return "zaman_asimi"
@pytest.mark.asyncio
async def test_normal_islem_tamamlanir():
sonuc = await zaman_asimi_olan_islem(0.5)
assert sonuc == "tamamlandi"
@pytest.mark.asyncio
async def test_uzun_islem_zaman_asimina_ugrarar():
sonuc = await zaman_asimi_olan_islem(3.0)
assert sonuc == "zaman_asimi"
@pytest.mark.asyncio
async def test_islem_gercekten_iptal_edildi():
"""asyncio.CancelledError düzgün handle ediliyor mu?"""
iptal_edildi = False
async def iptal_izleme():
nonlocal iptal_edildi
try:
await asyncio.sleep(10)
except asyncio.CancelledError:
iptal_edildi = True
raise
gorev = asyncio.create_task(iptal_izleme())
await asyncio.sleep(0.1)
gorev.cancel()
try:
await gorev
except asyncio.CancelledError:
pass
assert iptal_edildi is True
Event Loop Scope Yönetimi
pytest-asyncio 0.21 sonrasında event loop scope’u daha açık biçimde kontrol edebilirsiniz. Bu önemli çünkü bazı durumlarda session-scoped fixture’ların function-scoped event loop ile çakışması sorun yaratır:
# conftest.py
import pytest
import asyncio
# Tüm session için tek event loop kullan
@pytest.fixture(scope="session")
def event_loop():
"""Session boyunca tek event loop"""
loop = asyncio.new_event_loop()
yield loop
loop.close()
Ya da pyproject.toml‘da:
[tool.pytest.ini_options]
asyncio_mode = "auto"
# pytest-asyncio 0.23+ için
asyncio_default_fixture_loop_scope = "session"
Bu ayar olmadan, session-scoped async fixture kullandığınızda şöyle bir uyarı görürsünüz: PytestUnraisableExceptionWarning: Exception ignored in... Bu uyarıyı görmezden gelmek yerine, event loop scope’unu doğru ayarlayın.
Test Performansı: Paralel Async Testler
Binlerce async testiniz varsa ve her biri gerçek I/O bekliyorsa (mock kullanmadan entegrasyon testi yapıyorsanız), testleriniz yavaşlayabilir. pytest-xdist ile paralel çalıştırabilirsiniz ama dikkatli olun: her worker kendi event loop’unu kullanır, paylaşılan kaynaklar (veritabanı, Redis) için dikkatli izolasyon gerekir.
Daha pratik bir yaklaşım, async testlerin içinde asyncio.gather kullanmak:
import pytest
import asyncio
import httpx
@pytest.mark.asyncio
async def test_coklu_endpoint_kontrol():
"""Tüm endpointleri paralel test et"""
async def endpoint_kontrol(client: httpx.AsyncClient, url: str) -> tuple:
yanit = await client.get(url)
return url, yanit.status_code
test_urller = [
"https://httpbin.org/get",
"https://httpbin.org/ip",
"https://httpbin.org/user-agent"
]
async with httpx.AsyncClient(timeout=10.0) as client:
gorevler = [endpoint_kontrol(client, url) for url in test_urller]
sonuclar = await asyncio.gather(*gorevler)
for url, durum in sonuclar:
assert durum == 200, f"{url} beklenen 200, alınan {durum}"
Bu şekilde üç ayrı HTTP isteği paralel gider ve toplam süre en yavaş isteğin süresi kadar olur, üç katı değil.
Hata Senaryoları ve Exception Testleri
Async kodda exception’ların doğru fırlatıldığını test etmek de biraz farklı:
import pytest
import asyncio
class VeriCekmeHatasi(Exception):
def __init__(self, url: str, durum_kodu: int):
self.url = url
self.durum_kodu = durum_kodu
super().__init__(f"{url} adresinden veri çekilemedi: HTTP {durum_kodu}")
async def guvenli_veri_cek(url: str) -> dict:
# Basitleştirilmiş örnek
if "hata" in url:
raise VeriCekmeHatasi(url, 500)
return {"veri": "ok"}
@pytest.mark.asyncio
async def test_hata_durumu_dogru_exception():
with pytest.raises(VeriCekmeHatasi) as exc_info:
await guvenli_veri_cek("https://hata.example.com/api")
assert exc_info.value.durum_kodu == 500
assert "hata.example.com" in exc_info.value.url
@pytest.mark.asyncio
async def test_gather_hatalari_toplar():
"""return_exceptions=True ile hatalar exception yerine sonuç olarak döner"""
async def basarili(): return "ok"
async def basarisiz(): raise ValueError("bir hata")
sonuclar = await asyncio.gather(
basarili(),
basarisiz(),
basarili(),
return_exceptions=True
)
assert sonuclar[0] == "ok"
assert isinstance(sonuclar[1], ValueError)
assert str(sonuclar[1]) == "bir hata"
assert sonuclar[2] == "ok"
Pratik Öneriler ve Sık Yapılan Hatalar
Yıllar içinde gördüğüm yaygın hatalar ve çözümleri:
- Event loop’u elle kapatmayın: pytest-asyncio bunu yönetir.
loop.close()çağırmak testlerin geri kalanını bozar.
asyncio.run()test içinde kullanmayın: Test zaten bir event loop içinde çalışıyor, iç içe event loop çalışmaz.
- Mock’ları coroutine döndürür yapın:
MagicMock()yerineAsyncMock()kullanın. Bu hata saatlerce hata ayıklamaya yol açabilir.
async forveasync withmock’lamak:__aiter__,__anext__,__aenter__,__aexit__metodlarını da mock’lamanız gerekir.AsyncMockbunu otomatik halleder.
- Fixture scope’larını tutarlı tutun: Session-scoped async fixture, function-scoped event loop ile çalışmaz.
- Gerçek zamanlama testlerinden kaçının:
asyncio.sleep(0.1)ve sonra0.05‘ten fazla sürdü mü diye kontrol etmeyin. CI sunucuları yavaş olabilir.
anyioalternatifini değerlendirin:trioveya farklı async backend’ler kullanıyorsanız,anyiovepytest-anyiodaha esnek bir çözüm sunabilir.
Sonuç
Async kod testi başlangıçta karmaşık görünebilir ama temelde birkaç ilkeye dayanıyor: event loop’u pytest-asyncio’ya bırakın, AsyncMock’u doğru kullanın, fixture scope’larına dikkat edin ve eş zamanlılık senaryolarını ihmal etmeyin.
En büyük kazanım şu: async testleri yazmaya başladıktan sonra, senkron kodda fark etmediğiniz pek çok race condition ve hata yönetimi sorununu önceden yakalarsınız. Bir servisin production’da “zaman zaman çöküyor” şikayetini almak yerine, o senaryoyu test ortamında yeniden üretip düzeltebilirsiniz.
Kendi projelerimde şu yapıyı standart olarak kullanıyorum: asyncio_mode = auto, session-scoped event loop, veritabanı bağlantıları için session-scoped havuz, her test için temizlik fixture’ı. Bu yapı kurulduktan sonra async test yazmak senkron test yazmaktan daha zor değil.
Son bir not: pytest-asyncio sürekli gelişen bir kütüphane. 0.21 ile gelen değişiklikler, 0.23 ile gelenler önemli. CHANGELOG‘u takip edin ve sürüm kısıtlamalarınızı requirements-dev.txt‘te net belirtin.
