Ana içeriğe geç
Kesit Python
İleri · Üretime hazırlık

İleri test teknikleri: mock, test ikizleri, özellik ve mutasyon testi

Test piramidini kurar, unittest.mock'u side_effect, spec ve doğru patch hedefiyle ustalıkla kullanır, bağımlılık enjeksiyonuyla zamanı, rastgeleliği ve ağı izole eder; fabrikalar, özellik tabanlı ve mutasyon testiyle testlerinin gerçekten hata yakaladığını kanıtlarsın.

Uygulamada aç, 29 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

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üzeyNeyi sınar?HızÖrnek (Anadolu Market)
BirimTek fonksiyon/sınıf, dış bağımlılık yokMilisaniyeindirim_hesapla(sepet)
EntegrasyonBileşenlerin birlikte çalışması (gerçek SQLite, gerçek ORM)Saniye altıSipariş deposu + veritabanı
Uçtan ucaKullanıcının gördüğü akış, tüm sistem ayaktaSaniyeler-dakikalarSepete 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ürNe yapar?Ne zaman?
DummyYalnı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çeklemeBellek 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 kaydederMock(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:

ÖzellikGö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 = fonksiyonArgü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_countKayıtlı çağrıları incelemek için
Mock(spec=Sinif), create_autospecYalnı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")
spec kullanmayan mock yalan söyleyebilirGerçek sınıfta metot 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.

İyi testin özellikleri: FIRSTFast (hızlı), Isolated (birbirinden bağımsız, sıra önemsiz), Repeatable (her makinede aynı sonuç: sabit saat, sabit tohum), Self-validating (assert ile kendi karar verir, çıktıya bakmayı gerektirmez), Timely (kodla birlikte yazılır).
Gerçek işteBankacılık ve e-ticaret ekiplerinde birleştirme isteği (pull request) incelemesinde ilk bakılan şeylerden biri yeni kodun testidir: "Hata yolu test edilmiş mi? Saat sabitlenmiş mi? Mock'lar spec'li mi?" Rastgele kırılan (flaky) bir test bulunduğunda genellikle karantinaya alınır ve kök nedeni (çoğu zaman paylaşılan durum, gerçek saat ya da test sırası) düzeltilene kadar bir iş kaydı açık kalır. Kritik modüllerde (ödeme, faiz hesabı) mutasyon testi gece çalışan ayrı bir CI işinde koşturulabilir.
Derinleş

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şımNasıl?Araç
Şemaya karşı testGerçek yanıtları OpenAPI şemasıyla doğrula; şemadan rastgele istekler üretip sunucuyu zorlaschemathesis, 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 edilirPact
Kırıcı değişiklik denetimiYeni ş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.

Gerçek işteBackend ekiplerinde tipik test dağılımı: çok sayıda hızlı birim testi, her depo ve sorgu için rollback fixture'lı entegrasyon testleri, CI'da sözleşme ve kırıcı değişiklik denetimi, büyük sürümlerden önce staging'de yük testi. Mülakatta "Entegrasyon testlerinde veritabanını nasıl izole ediyorsun?" sorusuna transaction rollback cevabı beklenir.
Uzmanlaş

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.

Alıştırmalara geç (29)