Neden ileri test teknikleri?
Test yazma dersinde unittest, düz assert, patch.object ve TDD döngüsünü gördün. Gerçek projede ise testlerin kendisi bir kod tabanına dönüşür: yüzlerce test, dış servislere bağlı kod, saatle değişen kurallar, rastgele üretilen kimlikler. Kötü tasarlanmış bir test paketi ya yavaştır, ya rastgele kırılır (flaky), ya da daha kötüsü her şey yeşilken hatayı kaçırır. Bu derste Ilgaz Bank ve Anadolu Market örnekleriyle testlerini hızlı, güvenilir ve gerçekten hata yakalayan hâle getirmeyi öğreneceksin.
Test piramidi
Test piramidi (test pyramid), farklı düzeydeki testlerden ne kadar yazman gerektiğini anlatan bir kılavuzdur: tabanda çok sayıda hızlı birim testi (unit test), ortada daha az entegrasyon testi (integration test), tepede birkaç uçtan uca test (end-to-end, E2E).
| Düzey | Neyi sınar? | Hız | Örnek (Anadolu Market) |
|---|---|---|---|
| Birim | Tek fonksiyon/sınıf, dış bağımlılık yok | Milisaniye | indirim_hesapla(sepet) |
| Entegrasyon | Bileşenlerin birlikte çalışması (gerçek SQLite, gerçek ORM) | Saniye altı | Sipariş deposu + veritabanı |
| Uçtan uca | Kullanıcının gördüğü akış, tüm sistem ayakta | Saniyeler-dakikalar | Sepete ekle → öde → e-posta gelsin |
Piramit ters dönerse (çok E2E, az birim testi) CI yavaşlar, hatanın yeri bulunamaz ve testler küçük arayüz değişikliklerinde topluca kırılır.
Test ikizleri: dummy, stub, fake, mock, spy
Gerçek bir bağımlılığın yerine geçen nesneye genel olarak test ikizi (test double) denir. Türleri karıştırılır ama farkları önemlidir:
| Tür | Ne yapar? | Ne zaman? |
|---|---|---|
| Dummy | Yalnızca parametre doldurur, kullanılmaz | İmza gerektiriyor ama test ilgilenmiyor |
| Saplama (stub) | Sabit cevap döndürür | "Kur servisi 40.0 desin" |
| Sahte (fake) | Çalışan ama basit bir gerçekleme | Bellek içi depo, sözlükle çalışan önbellek (cache) |
| Taklit (mock) | Çağrıları kaydeder, beklentiyi doğrular | "SMS tam bir kez bu numaraya gitti mi?" |
| Casus (spy) | Gerçeği çağırır, çağrıları da kaydeder | Mock(wraps=gercek) |
Kural: durumu (sonuç ne oldu?) doğrulamak için stub ve fake, etkileşimi (kim, kime, neyle çağrıldı?) doğrulamak için mock kullan. Her şeyi mock'larsan testler gerçekleştirmenin ayrıntısına kilitlenir ve her yeniden düzenlemede kırılır.
class SahteMusteriDepo:
"""Gerçek SQL deposuyla aynı arayüz, bellekte çalışır (fake)."""
def __init__(self):
self._kayitlar = {}
def ekle(self, musteri):
self._kayitlar[musteri["id"]] = dict(musteri)
def getir(self, mid):
return self._kayitlar.get(mid)
def sadakat_puani_ekle(depo, mid, tutar):
m = depo.getir(mid)
m["puan"] = m.get("puan", 0) + int(tutar // 10)
depo.ekle(m)
return m["puan"]
depo = SahteMusteriDepo()
depo.ekle({"id": 7, "ad": "Ayşe"})
print(sadakat_puani_ekle(depo, 7, 255))
print(depo.getir(7)) # durumu doğruluyoruz, etkileşimi değil
unittest.mock derinlemesine
Mock ve MagicMock erişilen her özniteliği anında yaratır ve her çağrıyı kaydeder. En sık kullanacağın ayarlar:
| Özellik | Görevi |
|---|---|
return_value | Çağrı hep bu değeri döndürür |
side_effect = İstisna | Çağrı istisna fırlatır (ağ hatası taklidi) |
side_effect = [a, b, c] | Her çağrıda sıradaki değer (listede istisna varsa fırlatılır) |
side_effect = fonksiyon | Argümanlara göre hesaplanan cevap |
assert_called_once_with(...) | Tam bir kez, bu argümanlarla çağrıldı mı? |
call_args, call_args_list, call_count | Kayıtlı çağrıları incelemek için |
Mock(spec=Sinif), create_autospec | Yalnızca gerçek sınıfta olan özniteliklere izin verir; autospec imzayı da denetler |
from unittest.mock import Mock, MagicMock, call
kur = Mock(side_effect=[38.5, ConnectionError("TCMB yanıt vermiyor"), 38.7])
print(kur("USD"))
try:
kur("USD")
except ConnectionError as e:
print("yakalandı:", e)
print(kur("USD"), "| çağrı sayısı:", kur.call_count)
sms = Mock()
sms.gonder("05321112233", "Siparişin yola çıktı")
sms.gonder.assert_called_once_with("05321112233", "Siparişin yola çıktı")
print(sms.gonder.call_args_list == [call("05321112233", "Siparişin yola çıktı")])
sepet = MagicMock() # sihirli metotları da destekler
sepet.__len__.return_value = 3
print(len(sepet), "ürün")
kaydet ama kodun depo.kayit_et() çağırıyorsa, düz Mock() bunu sessizce kabul eder ve test yeşil kalır; üretimde ise AttributeError alırsın. Mock(spec=Depo) ya da create_autospec(Depo) bu yalanı engeller. Ayrıca m.assert_called_once_wiht(...) gibi yazım hatalarını Python 3.12 AttributeError ile yakalar.from unittest.mock import Mock, create_autospec
class Depo:
def kaydet(self, siparis): ...
serbest = Mock()
serbest.kayit_et({"id": 1}) # hata yok: yalan söyleyen test
print("düz Mock sorunu fark etmedi")
kati = create_autospec(Depo, instance=True)
try:
kati.kayit_et({"id": 1})
except AttributeError as e:
print("autospec yakaladı:", type(e).__name__)
try:
kati.kaydet() # eksik argüman
except TypeError as e:
print("imza da denetlendi:", type(e).__name__)
patch hedefi: nerede kullanılıyorsa orası
patch("a.b.c") bir modüldeki adı geçici olarak değiştirir. rapor.py içinde from kur import kur_getir yazılmışsa, rapor modülü kendi kur_getir adına sahiptir; patch("kur.kur_getir") bunu etkilemez. Doğru hedef patch("rapor.kur_getir")'dir. Aşağıda iki modülü bellekte oluşturup farkı görüyoruz:
import sys, types
from unittest.mock import patch
kur = types.ModuleType("kur")
def kur_getir(doviz):
raise ConnectionError("ağ yok")
kur.kur_getir = kur_getir
sys.modules["kur"] = kur
rapor = types.ModuleType("rapor")
exec("from kur import kur_getir\ndef tl(tutar): return round(tutar * kur_getir('USD'), 2)", rapor.__dict__)
sys.modules["rapor"] = rapor
with patch("kur.kur_getir", return_value=40.0):
try:
rapor.tl(100)
except ConnectionError:
print("Yanlış hedef: rapor hâlâ gerçek fonksiyonu çağırdı")
with patch("rapor.kur_getir", return_value=40.0) as sahte:
print("Doğru hedef:", rapor.tl(100))
sahte.assert_called_once_with("USD")
Bağımlılık enjeksiyonu: test edilebilir tasarım
Patch'e çok ihtiyaç duyuyorsan bu genellikle tasarımın bağımlılıkları içeride sakladığını gösterir. Bağımlılık enjeksiyonu (dependency injection) ile saat, rastgele sayı üreteci (generator), HTTP istemcisi ve depo dışarıdan verilir; testte yerine sabit/sahte olanı koyarsın. Zaman (datetime.now), rastgelelik (random) ve ağ, testleri "bazen kırmızı" yapan üç büyük kaynaktır.
import random
from datetime import datetime
class KuponUretici:
def __init__(self, saat=datetime.now, rng=None):
self.saat = saat
self.rng = rng or random.Random()
def uret(self, musteri_id):
gun = self.saat().strftime("%y%m%d")
return f"AM{gun}-{musteri_id}-{self.rng.randint(100, 999)}"
sabit_saat = lambda: datetime(2026, 3, 13, 9, 30)
u1 = KuponUretici(saat=sabit_saat, rng=random.Random(42))
u2 = KuponUretici(saat=sabit_saat, rng=random.Random(42))
print(u1.uret(15), u2.uret(15)) # aynı tohum, aynı sonuç: deterministik test
Parametrize ve test verisi fabrikaları
Aynı mantığı onlarca girdiyle denemek için testi kopyalamazsın; tablo güdümlü (table-driven) yazarsın. pytest'te bu @pytest.mark.parametrize, unittest'te subTest'tir:
import pytest
@pytest.mark.parametrize("tutar, beklenen", [(100, 120.0), (0, 0.0), (19.99, 23.99)])
def test_kdv(tutar, beklenen):
assert kdv_ekle(tutar) == pytest.approx(beklenen)
Testlerde sık ihtiyaç duyulan nesneleri (müşteri, sipariş) her seferinde elle yazmak hem uzun hem kırılgandır. Fabrika (factory) fonksiyonu makul varsayılanlar üretir; test yalnızca kendisini ilgilendiren alanı değiştirir. Python'da factory_boy kütüphanesi bunu büyük ölçekte yapar.
import copy, itertools
_sayac = itertools.count(1)
VARSAYILAN = {"musteri": "Ayşe", "sehir": "İzmir", "kalemler": [{"urun": "Çay", "adet": 1, "fiyat": 50.0}]}
def siparis(**ozel):
s = copy.deepcopy(VARSAYILAN)
s["id"] = next(_sayac)
s.update(ozel)
return s
print(siparis())
print(siparis(sehir="Van", musteri="Mehmet"))
Özellik tabanlı test
Örnek tabanlı testte girdiyi sen seçersin; özellik tabanlı testte (property-based testing) her girdi için doğru olması gereken bir özellik yazarsın ve araç yüzlerce rastgele girdi üretir. Python'da standart araç hypothesis'tir; bulduğu hatalı girdiyi en küçük hâline küçültür (shrinking).
from hypothesis import given, strategies as st
@given(st.floats(min_value=0, max_value=1e6), st.integers(min_value=0, max_value=100))
def test_indirim_fiyati_asmaz(fiyat, oran):
sonuc = indirimli(fiyat, oran)
assert 0 <= sonuc <= fiyat
Kendi bilgisayarında dene: pip install hypothesis pytest. Tarayıcıda hypothesis yok; ama fikri random.Random(tohum) ile kendimiz kurabiliriz. Aşağıdaki indirim fonksiyonundaki hatayı elle yazdığın üç örnek muhtemelen yakalamazdı:
import random
def indirimli(fiyat, oran):
return round(fiyat - fiyat * oran / 10, 2) # hata: /100 olmalıydı
def ozellik(fiyat, oran):
s = indirimli(fiyat, oran)
return 0 <= s <= fiyat
rng = random.Random(0)
for deneme in range(1, 501):
fiyat, oran = round(rng.uniform(0, 1000), 2), rng.randint(0, 100)
if not ozellik(fiyat, oran):
print(f"{deneme}. denemede karşı örnek: fiyat={fiyat}, oran={oran} -> {indirimli(fiyat, oran)}")
break
else:
print("500 denemede özellik bozulmadı")
İyi özellik kalıpları: gidiş-dönüş (json.loads(json.dumps(x)) == x), değişmezler (sıralama eleman sayısını korur), eşgüç (idempotence: temizle(temizle(x)) == temizle(x)) ve referansla karşılaştırma (hızlı algoritma = yavaş ama kesin olan).
Kapsam, dal kapsamı ve mutasyon testi
Satır kapsamı (line coverage) hangi satırların çalıştığını ölçer; dal kapsamı (branch coverage) her if'in hem doğru hem yanlış yolunun denendiğini. if vip: oran += 5 satırı yalnızca VIP müşteriyle test edilirse satır kapsamı %100 olur ama "VIP değil" yolu hiç denenmemiştir. Komut: coverage run --branch -m pytest ve coverage report -m (Kalite ve CI dersinde kapsam eşiğini gördün).
Kapsam bir testin kodu çalıştırdığını söyler, doğruladığını söylemez. Mutasyon testi (mutation testing) bunu ölçer: araç koda küçük hatalar (mutant) sokar, ör. < yerine <=, ve testleri koşturur. Testler kırmızıya dönerse mutant öldürülmüştür; yeşil kalırsa hayatta kalmıştır ve testlerinde bir boşluk var demektir. Python'da mutmut ve cosmic-ray bu işi yapar.
import ast
kaynak = "def ucretsiz_kargo(tutar):\n return tutar >= 500\n"
class BuyukEsitToBuyuk(ast.NodeTransformer):
def visit_Compare(self, dugum):
dugum.ops = [ast.Gt() if isinstance(o, ast.GtE) else o for o in dugum.ops]
return dugum
mutant = ast.unparse(BuyukEsitToBuyuk().visit(ast.parse(kaynak)))
print("Mutant:", mutant.splitlines()[1].strip())
def testler(f):
assert f(1000) is True
assert f(100) is False
for ad, kod in [("orijinal", kaynak), ("mutant", mutant)]:
ns = {}
exec(kod, ns)
testler(ns["ucretsiz_kargo"])
print(ad, "testleri geçti")
print("Mutant hayatta kaldı: sınır değeri 500 hiç test edilmemiş!")
TDD ve güvenli yeniden düzenleme
Kırmızı-yeşil-düzenle döngüsünü biliyorsun. İleri seviyede asıl kazanç "düzenle" adımındadır: davranışı kilitleyen testler varken kodu gönül rahatlığıyla yeniden yapılandırırsın (refactor). Eski, testsiz bir koda dokunmadan önce karakterizasyon testi (characterization test) yaz: kodun bugün ne yaptığını (doğru ya da yanlış) kaydeden testler. Sonra küçük adımlarla değiştir, her adımda testleri çalıştır.
Mock ve fake'ler hızlıdır ama bir noktada gerçek parçalarla da sınaman gerekir: SQL sorgun PostgreSQL'de gerçekten çalışıyor mu, API'n yayımladığı sözleşmeye uyuyor mu, sistem yük altında ne kadar dayanıyor? Bu bölüm test piramidinin üst katlarını uygulamalı olarak açıyor: gerçek veritabanıyla entegrasyon testi, sözleşme testi ve yük testi.
Gerçek veritabanı, temiz durum: transaction rollback fixture
Entegrasyon testlerinin en büyük sorunu durumdur: bir testin eklediği satır diğerini bozar. Her testte veritabanını sıfırdan kurmak yavaştır. Yaygın çözüm, her testi geri alınacak bir transaction içinde çalıştırmaktır: test başında BEGIN, sonunda ROLLBACK. Test istediği kadar yazar, iz bırakmaz. Test edilen kod kendi içinde commit() çağırıyorsa, dış transaction'ı korumak için iç işlemleri bir savepoint'e (iç içe transaction) bağlarsın.
# conftest.py (SQLAlchemy 2.0 + pytest)
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
@pytest.fixture(scope="session")
def engine():
from testcontainers.postgres import PostgresContainer # pip install testcontainers
with PostgresContainer("postgres:16") as pg: # gerçek PostgreSQL, geçici konteyner
eng = create_engine(pg.get_connection_url())
Base.metadata.create_all(eng) # ya da: alembic upgrade head
yield eng
@pytest.fixture
def db(engine):
with engine.connect() as conn:
dis = conn.begin() # dış transaction
oturum = Session(bind=conn, join_transaction_mode="create_savepoint")
yield oturum # kodun commit'i yalnız savepoint'i kapatır
oturum.close()
dis.rollback() # her şey geri alınır
testcontainers, test oturumu başında Docker'da gerçek bir PostgreSQL (ya da Redis, Kafka) başlatır ve sonunda siler. SQLite ile test edip PostgreSQL'de çalıştırmak, ILIKE, JSONB, eşzamanlı kilitler ve tarih fonksiyonlarındaki farkları kaçırır. CI'da Docker varsa bu yaklaşım pratik ve güvenilirdir. Aynı fikrin sqlite3 ile çalışan küçük hâli:
import sqlite3
from contextlib import contextmanager
conn = sqlite3.connect(":memory:", isolation_level=None) # transaction'ları elle yönet
conn.execute("CREATE TABLE siparis (id INTEGER PRIMARY KEY, tutar REAL)")
conn.execute("INSERT INTO siparis (tutar) VALUES (100)")
@contextmanager
def test_islemi(c):
c.execute("BEGIN")
try:
yield c
finally:
c.execute("ROLLBACK")
def siparis_ekle(c, tutar): # test edilen kod: kendi savepoint'ini açar ve kapatır
c.execute("SAVEPOINT ekle")
c.execute("INSERT INTO siparis (tutar) VALUES (?)", (tutar,))
c.execute("RELEASE ekle")
with test_islemi(conn) as c:
siparis_ekle(c, 250)
print("test içinde:", c.execute("SELECT COUNT(*) FROM siparis").fetchone()[0])
print("test sonrası:", conn.execute("SELECT COUNT(*) FROM siparis").fetchone()[0])
Sözleşme testi: API'n söz verdiği gibi mi?
Bir API'nin sözleşmesi OpenAPI belgesidir: hangi uç nokta, hangi alanlar, hangi türler, hangileri zorunlu. FastAPI bu belgeyi koddan üretir; ama istemciler (mobil uygulama, başka ekip) belgeye güvenir. İki tür sözleşme testi vardır:
| Yaklaşım | Nasıl? | Araç |
|---|---|---|
| Şemaya karşı test | Gerçek yanıtları OpenAPI şemasıyla doğrula; şemadan rastgele istekler üretip sunucuyu zorla | schemathesis, openapi-core |
| Tüketici güdümlü (consumer-driven) | Her istemci beklediği alanları bir sözleşme dosyasına yazar; sağlayıcı CI'da bu dosyalara karşı test edilir | Pact |
| Kırıcı değişiklik denetimi | Yeni şemayı eskisiyle karşılaştır; alan silme, tür değiştirme, yeni zorunlu alan = kırıcı | oasdiff, openapi-diff |
schemathesis run http://localhost:8000/openapi.json --checks all # şemadan yüzlerce istek üretir
Yük testi: ne kadar dayanır?
Yük testi (load test), gerçekçi eşzamanlı kullanıcı trafiği altında yanıt sürelerini ve hata oranını ölçer. Python dünyasında en yaygın araç locust'tır: kullanıcı davranışını Python sınıfı olarak yazarsın.
# locustfile.py → locust -f locustfile.py --host http://localhost:8000 -u 200 -r 20 -t 5m --headless
from locust import HttpUser, task, between
class Musteri(HttpUser):
wait_time = between(1, 3) # kullanıcılar arası düşünme süresi
@task(5) # ağırlık: listeleme 5 kat sık
def urunler(self):
self.client.get("/urunler?sayfa=1")
@task(1)
def siparis(self):
self.client.post("/siparisler", json={"urun_id": 7, "adet": 1})
Ortalamaya değil yüzdeliklere bak: p95 = 480 ms, isteklerin %95'inin 480 ms'den kısa sürdüğünü söyler. Ortalama, birkaç çok yavaş isteği gizler; o yavaş istekler genellikle en çok sipariş veren müşterilerinkidir. Hedefleri SLO (hizmet düzeyi hedefi) olarak yaz: "p95 < 500 ms, hata oranı < %1, 200 eşzamanlı kullanıcıda". Yük testini prod'a değil, prod benzeri staging ortamına uygula.
Sözleşme testleri ve sahtenin doğruluğu
Fake kullanmanın riski, sahtenin gerçekten farklı davranmasıdır. Çözüm: aynı test paketini hem fake hem gerçek depo üzerinde çalıştırmak (sözleşme testi, contract test). pytest'te bunu parametrize edilmiş bir fixture ile yaparsın; gerçek PostgreSQL için testcontainers testte geçici bir konteyner açar.
Flaky testlerin anatomisi
Rastgele kırılan testlerin çoğu şu kaynaklardan gelir: gerçek saat (gece yarısı geçişi, saat dilimi), sabitlenmemiş rastgelelik, testler arasında paylaşılan modül düzeyi durum, sıraya bağımlılık (pytest-randomly sırayı karıştırarak bunu ortaya çıkarır), ağ ve time.sleep ile beklenen eşzamanlılık (concurrency). Her birinin ilacı aynıdır: bağımlılığı dışarıdan ver ve testte kontrol et.
Ne kadar mock?
"Sahip olmadığın şeyi mock'lama" sık duyulan bir ilkedir: üçüncü taraf bir kütüphaneyi doğrudan mock'lamak yerine onu kendi küçük adaptör sınıfının arkasına koy, testte adaptörü değiştir. Böylece kütüphane değiştiğinde yalnızca adaptör ve onun entegrasyon testi etkilenir.