pytest ile Mock ve Patch Kullanarak Bağımlılıkları Yönetme

Birim test yazarken en çok takıldığım nokta her zaman bağımlılıklar olmuştur. Veritabanı çağrısı yapan bir fonksiyonu test edeceksin, ama test ortamında gerçek bir veritabanı yok. Dış bir API’ye istek atan bir servisi doğrulamak istiyorsun, ama her testte gerçekten HTTP isteği atmak hem yavaş hem de güvenilmez. İşte bu noktada mock ve patch devreye giriyor. pytest ekosistemiyle birlikte kullandığında, bağımlılıklarını izole ederek testlerini hem hızlı hem de deterministik hale getirebiliyorsun.

Bu yazıda gerçek dünya senaryoları üzerinden gideceğim. Teorik anlatım yerine “bu durumda ne yaparsın?” sorusuna odaklanacağım.

Mock Nedir, Ne Zaman Kullanırsın?

mock, gerçek bir nesnenin veya fonksiyonun yerine geçen sahte bir nesnedir. Temel amacı şu: test etmek istediğin kod parçasını dışarıya olan bağımlılıklarından koparıp izole etmek.

Şunu düşün: Bir kullanıcı kayıt fonksiyonun var. Bu fonksiyon hem veritabanına yazıyor hem de kayıt sonrası e-posta gönderiyor. Sen sadece “kayıt mantığı doğru mu çalışıyor?” sorusunu sormak istiyorsun. Veritabanını ve e-posta servisini mock’larsın, fonksiyonun core davranışını test edersin.

Python’da unittest.mock modülü standart kütüphaneyle birlikte geliyor. pytest ile kullanmak için ekstra bir şey kurman gerekmiyor. Ancak pytest-mock eklentisi işleri biraz daha temiz hale getiriyor:

pip install pytest pytest-mock

MagicMock ile Temel Kullanım

En basit haliyle bir mock nesnesi oluşturalım:

from unittest.mock import MagicMock

def send_notification(notifier, message):
    result = notifier.send(message)
    if result.status == "ok":
        return True
    return False

def test_send_notification_success():
    mock_notifier = MagicMock()
    mock_notifier.send.return_value.status = "ok"

    assert send_notification(mock_notifier, "Merhaba") is True
    mock_notifier.send.assert_called_once_with("Merhaba")

Burada dikkat et: mock_notifier.send.return_value.status = "ok" diyerek zincirli dönüş değerlerini de kontrol edebiliyorsun. Mock nesneleri, üzerlerine ne çağırırsan çağır şikayet etmez. Var olmayan bir attribute’a erişsen bile yeni bir MagicMock döner. Bu güçlü ama aynı zamanda tehlikeli bir özellik, çünkü yazım hataları seni yanıltabilir.

patch ile Modülleri Geçici Olarak Değiştirme

patch, bir modülün içindeki bir nesneyi test süresince başka bir şeyle değiştirmenin yoludur. Decorator veya context manager olarak kullanabilirsin.

Diyelim ki şöyle bir kod var:

# services/weather.py
import requests

def get_temperature(city):
    response = requests.get(f"https://api.weather.com/v1/{city}")
    data = response.json()
    return data["temperature"]

Bu fonksiyonu test etmek istiyorsun ama gerçek bir HTTP isteği atmak istemiyorsun. İşte patch burada devreye giriyor:

# tests/test_weather.py
from unittest.mock import patch, MagicMock
from services.weather import get_temperature

def test_get_temperature():
    mock_response = MagicMock()
    mock_response.json.return_value = {"temperature": 28}

    with patch("services.weather.requests.get", return_value=mock_response):
        result = get_temperature("istanbul")

    assert result == 28

Kritik nokta: patch argümanına "requests.get" değil "services.weather.requests.get" yazdım. Neden? Çünkü mock’lamak istediğin şey, test edilen modülün import ettiği isim uzayındaki requests.get. Bu kavram başta kafa karıştırıyor ama bir kez anladığında hayatın kolaylaşıyor. Kural şu: mock’u import ettiği yerden değil, kullanıldığı yerden yak.

pytest-mock ile Daha Temiz Sözdizimi

pytest-mock eklentisi, mocker fixture’ı üzerinden patch işlemini daha okunabilir hale getiriyor:

# tests/test_weather.py
from services.weather import get_temperature

def test_get_temperature_with_mocker(mocker):
    mock_response = mocker.MagicMock()
    mock_response.json.return_value = {"temperature": 15}

    mocker.patch("services.weather.requests.get", return_value=mock_response)

    result = get_temperature("ankara")
    assert result == 15

mocker.patch kullandığında test bitince patch otomatik olarak kaldırılıyor. Context manager yazmana gerek kalmıyor. Özellikle birden fazla şeyi patch’lemek gerektiğinde kod çok daha temiz görünüyor.

Gerçek Dünya Senaryosu: Veritabanı Katmanını İzole Etme

Diyelim ki bir e-ticaret uygulamasında sipariş servisin var:

# services/order_service.py
from repositories.order_repo import OrderRepository
from services.payment_service import PaymentService
from services.email_service import EmailService

class OrderService:
    def __init__(self):
        self.repo = OrderRepository()
        self.payment = PaymentService()
        self.email = EmailService()

    def place_order(self, user_id, items):
        total = sum(item["price"] for item in items)

        payment_result = self.payment.charge(user_id, total)
        if not payment_result["success"]:
            raise Exception("Ödeme başarısız")

        order_id = self.repo.save(user_id, items, total)
        self.email.send_confirmation(user_id, order_id)

        return {"order_id": order_id, "total": total}

Bu sınıfı test etmek için üç bağımlılığı da mock’laman gerekiyor:

# tests/test_order_service.py
import pytest
from unittest.mock import MagicMock, patch
from services.order_service import OrderService

@pytest.fixture
def mock_dependencies(mocker):
    mocker.patch(
        "services.order_service.PaymentService"
    ).return_value.charge.return_value = {"success": True}

    mocker.patch(
        "services.order_service.OrderRepository"
    ).return_value.save.return_value = 42

    mocker.patch(
        "services.order_service.EmailService"
    ).return_value.send_confirmation.return_value = None

def test_place_order_success(mock_dependencies):
    service = OrderService()
    items = [
        {"name": "Laptop", "price": 15000},
        {"name": "Mouse", "price": 500}
    ]

    result = service.place_order(user_id=1, items=items)

    assert result["order_id"] == 42
    assert result["total"] == 15500

def test_place_order_payment_failure(mocker):
    mocker.patch(
        "services.order_service.PaymentService"
    ).return_value.charge.return_value = {"success": False}

    mocker.patch("services.order_service.OrderRepository")
    mocker.patch("services.order_service.EmailService")

    service = OrderService()

    with pytest.raises(Exception, match="Ödeme başarısız"):
        service.place_order(user_id=1, items=[{"name": "Klavye", "price": 800}])

Fixture kullanarak tekrar eden mock kurulumlarını tek yere toplamak iyi bir pratik. Ama dikkat et: bazen farklı testler için farklı davranışlar gerekiyor (ödeme başarısız senaryosu gibi), bu durumda fixture’ı her yerde kullanmak yerine spesifik testlerde inline patch yazmak daha net oluyor.

side_effect ile Dinamik Davranış ve Exception Simülasyonu

return_value her zaman aynı değeri döndürür. Ama bazı durumlarda mock’un farklı çağrılarda farklı şeyler yapmasını ya da exception fırlatmasını isteyebilirsin. Bunun için side_effect var:

from unittest.mock import patch, MagicMock
import pytest

def fetch_data_with_retry(fetcher, max_retries=3):
    for attempt in range(max_retries):
        try:
            return fetcher.get()
        except ConnectionError:
            if attempt == max_retries - 1:
                raise
    return None

def test_retry_on_failure(mocker):
    mock_fetcher = mocker.MagicMock()

    # İlk iki çağrıda hata fırlat, üçüncüde başarılı dön
    mock_fetcher.get.side_effect = [
        ConnectionError("Bağlantı hatası"),
        ConnectionError("Bağlantı hatası"),
        {"data": "başarılı"}
    ]

    result = fetch_data_with_retry(mock_fetcher)
    assert result == {"data": "başarılı"}
    assert mock_fetcher.get.call_count == 3

def test_all_retries_fail(mocker):
    mock_fetcher = mocker.MagicMock()
    mock_fetcher.get.side_effect = ConnectionError("Sürekli hata")

    with pytest.raises(ConnectionError):
        fetch_data_with_retry(mock_fetcher)

side_effect liste alırsa her çağrıda listedeki sıradaki elemanı kullanır. Exception class veya instance alırsa o exception’ı fırlatır. Callable alırsa her çağrıda o fonksiyonu çağırır. Bu esneklik özellikle retry mantıklarını ve intermittent hataları test ederken çok işe yarıyor.

patch.object ile Nesne Metotlarını Mock’lama

Bazen bir sınıfın tamamını değil, sadece belirli bir metodunu mock’lamak istiyorsun:

# utils/file_processor.py
import os
import hashlib

class FileProcessor:
    def read_file(self, path):
        with open(path, "rb") as f:
            return f.read()

    def compute_hash(self, path):
        content = self.read_file(path)
        return hashlib.md5(content).hexdigest()

compute_hash metodunu test ederken read_file‘ın gerçekten dosya okumasını istemiyoruz:

from unittest.mock import patch
from utils.file_processor import FileProcessor

def test_compute_hash():
    processor = FileProcessor()

    with patch.object(processor, "read_file", return_value=b"test content"):
        result = processor.compute_hash("/fake/path.txt")

    import hashlib
    expected = hashlib.md5(b"test content").hexdigest()
    assert result == expected

patch.object ile spesifik bir nesne instance’ının metodunu değiştirebiliyorsun. Bu özellikle inheritance hiyerarşisi karmaşık olduğunda veya mock’lamak istediğin metodun tam yolunu bilmek istemediğinde kullanışlı.

assert_called ile Çağrı Doğrulama

Mock’lar sadece sahte değerler döndürmekle kalmaz, aynı zamanda nasıl çağrıldıklarını da kaydederler. Bu özellik, “bu fonksiyon doğru parametrelerle çağrıldı mı?” sorusunu cevaplamak için kritik:

from unittest.mock import patch, call
from services.notification_service import NotificationService

def process_alerts(alerts, notifier):
    for alert in alerts:
        if alert["severity"] == "critical":
            notifier.send_urgent(alert["message"])
        else:
            notifier.send_normal(alert["message"])

def test_process_alerts_routing(mocker):
    mock_notifier = mocker.MagicMock()

    alerts = [
        {"severity": "critical", "message": "Sunucu çöktü"},
        {"severity": "low", "message": "Disk dolmak üzere"},
        {"severity": "critical", "message": "Veritabanı bağlantısı kesildi"},
    ]

    process_alerts(alerts, mock_notifier)

    # Acil gönderim 2 kez çağrılmış olmalı
    assert mock_notifier.send_urgent.call_count == 2

    # Normal gönderim 1 kez çağrılmış olmalı
    mock_notifier.send_normal.assert_called_once_with("Disk dolmak üzere")

    # Acil çağrıların sırası ve parametreleri
    mock_notifier.send_urgent.assert_has_calls([
        call("Sunucu çöktü"),
        call("Veritabanı bağlantısı kesildi")
    ])

Kullanabileceğin assertion metodları:

  • assert_called(): En az bir kez çağrılmış mı?
  • assert_called_once(): Tam olarak bir kez çağrılmış mı?
  • assert_called_with(args, kwargs)*: Son çağrı bu parametrelerle mi yapıldı?
  • assert_called_once_with(args, kwargs)*: Tam olarak bir kez, bu parametrelerle mi çağrıldı?
  • assert_has_calls(calls): Bu çağrılar gerçekleşti mi?
  • assert_not_called(): Hiç çağrılmadı mı?

Dikkat Edilmesi Gereken Yaygın Hatalar

Yanlış yerde patch yapmak: Bunu yeterince vurgulayamam. Eğer services/order_service.py dosyasında from datetime import datetime varsa, patch edeceğin yer services.order_service.datetime olmalı, datetime.datetime değil.

Mock’ları aşırı kullanmak: Her şeyi mock’layan testler bazen hiçbir şeyi test etmiyor haline gelir. Entegrasyon noktalarını değil, gerçek business mantığını mock’lamaya çalış. Eğer bir fonksiyonun içindeki her şeyi mock’ladıysan, aslında o fonksiyonun var olup olmadığını test ediyorsun demektir.

spec kullanmamak: MagicMock varsayılan olarak var olmayan metodlara bile izin verir. spec parametresiyle bunu kısıtlayabilirsin:

from unittest.mock import MagicMock
from services.payment_service import PaymentService

# spec ile mock, sadece gerçek sınıftaki metodları kabul eder
mock_payment = MagicMock(spec=PaymentService)

# Bu hata verir çünkü PaymentService'de böyle bir metod yok
# mock_payment.non_existent_method()  -> AttributeError

spec kullanmak yazım hatalarını erkenden yakalamanı sağlar. Production ortamında olmayan bir metodu mock’lamak testlerin yanıltıcı bir güven vermesine yol açar.

freeze_gun ile Zaman Bağımlılıklarını Yönetme

Zaman ile ilgili testler her sysadmin’in korkulu rüyasıdır. Cron job’lar, expiry kontrolleri, rate limiting… Bunlar için freezegun kütüphanesi hayat kurtarıyor:

pip install freezegun
from freezegun import freeze_time
from datetime import datetime
from utils.token_manager import is_token_valid

def is_token_valid(token_created_at, ttl_seconds=3600):
    now = datetime.utcnow()
    elapsed = (now - token_created_at).total_seconds()
    return elapsed < ttl_seconds

@freeze_time("2024-01-15 10:00:00")
def test_token_valid_within_ttl():
    from datetime import datetime
    created_at = datetime(2024, 1, 15, 9, 30, 0)  # 30 dakika önce
    assert is_token_valid(created_at) is True

@freeze_time("2024-01-15 12:00:00")
def test_token_expired():
    from datetime import datetime
    created_at = datetime(2024, 1, 15, 9, 30, 0)  # 2.5 saat önce
    assert is_token_valid(created_at) is False

freezegun hem decorator hem context manager olarak çalışıyor. Zamanı belirli bir noktaya sabitliyor ve datetime.utcnow(), datetime.now(), time.time() gibi fonksiyonları otomatik olarak intercept ediyor.

Sonuç

Mock ve patch kullanımı başta “gerçeği kandırıyorum” gibi hissettiriyor, ama aslında tam tersi. Testlerini deterministik yapıyorsun, sadece test etmek istediğin şeye odaklanıyorsun ve dış dünyanın tutarsızlıklarından bağımsız hale geliyorsun.

Birkaç pratik kural olarak şunları söyleyebilirim: patch yaparken her zaman doğru modül yolunu kullan, mock’larını spec ile kısıtla, side_effect‘i retry ve hata senaryoları için kullan, pytest-mock‘un mocker fixture’ını tercih et çünkü cleanup otomatik oluyor.

En önemlisi: mock’un amacı test etmek istediğin kod birimini izole etmek. Eğer mock’lar testlerinin büyük bölümünü kaplıyorsa ve test mantığından çok mock kurulumu yapıyorsan, kodun tasarımına bakman gerekebilir. Bağımlılık injection prensiplerini uygulayan, sınırları belirgin kod hem daha kolay test edilir hem de daha iyi anlaşılır.

Testler, production kodu kadar ciddi bir yatırım. Mock’ları doğru kullandığında bu yatırımın getirisi katlanarak artıyor.

Bir yanıt yazın

E-posta adresiniz yayınlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir