Mocking Nedir: Test Yazımında Sahte Nesne Kullanımı

Üretim ortamında bir şeyi test etmek istiyorsunuz ama gerçek veritabanına, gerçek API’ye ya da gerçek e-posta servisine dokunmak istemiyorsunuz. İşte tam bu noktada mocking devreye giriyor. Yıllar önce ilk kez bu kavramla karşılaştığımda “sahte nesne” ifadesi kulağıma biraz tuhaf geldi, ama zamanla anladım ki test dünyasının belki de en pragmatik çözümü bu.

Mocking Nedir, Neden Önemlidir?

Mocking, yazılım testlerinde gerçek bağımlılıkların (veritabanı, harici API, dosya sistemi, mesaj kuyruğu gibi) yerine kontrollü davranış sergileyen sahte nesneler koyma pratiğidir. Bu sahte nesneler gerçek bileşenlerin arayüzünü taklit eder fakat arkada hiçbir gerçek işlem yapmaz. Siz onlara “bu çağrıda şu cevabı dön” diyebilirsiniz, “bu metot kaç kez çağrıldı” diye sorgulayabilirsiniz.

Sistem yöneticileri ve DevOps mühendisleri için bu belki biraz “geliştirici işi” gibi görünebilir. Ama şunu düşünün: Ansible playbook’larınızı test ediyorsunuz, Terraform modüllerinizi doğruluyorsunuz, ya da kendi yazdığınız otomasyon scriptlerinin doğru çalışıp çalışmadığını kontrol etmek istiyorsunuz. Bu noktada mocking kaçınılmaz hale geliyor.

Mocking’in temelinde üç kavram yatıyor:

  • Stub: Önceden belirlenmiş cevaplar döndüren basit sahte nesne. Sadece “şunu dön” der, başka bir şey yapmaz.
  • Mock: Hem belirlenmiş cevaplar döndüren hem de “bu metot kaç kez, hangi argümanlarla çağrıldı” gibi etkileşimleri doğrulayan nesne.
  • Fake: Gerçek implementasyonun basitleştirilmiş, çalışan bir versiyonu. Mesela bellekte çalışan bir veritabanı fake’e güzel bir örnektir.

Python ile unittest.mock: Temel Kullanım

Python’da mocking için standart kütüphane olan unittest.mock çoğu ihtiyacı karşılıyor. Basit bir örnekle başlayalım. Diyelim ki bir sistem izleme scripti yazıyorsunuz ve disk kullanımını kontrol eden bir fonksiyonunuz var:

# disk_monitor.py
import shutil

def get_disk_usage(path):
    total, used, free = shutil.disk_usage(path)
    usage_percent = (used / total) * 100
    return usage_percent

def check_disk_alert(path, threshold=80):
    usage = get_disk_usage(path)
    if usage > threshold:
        return f"ALERT: Disk kullanimi %{usage:.1f} - esik deger: %{threshold}"
    return f"OK: Disk kullanimi %{usage:.1f}"

Bu kodu test etmek için gerçek disk durumuna bağımlı olmak istemiyoruz. Test her çalıştığında farklı sonuç verebilir. İşte mock devreye giriyor:

# test_disk_monitor.py
import unittest
from unittest.mock import patch
from disk_monitor import check_disk_alert

class TestDiskMonitor(unittest.TestCase):

    @patch('disk_monitor.shutil.disk_usage')
    def test_alert_when_disk_full(self, mock_disk_usage):
        # 1TB toplam, 900GB kullanılmış, 100GB boş
        mock_disk_usage.return_value = (1_000_000_000_000, 900_000_000_000, 100_000_000_000)
        
        result = check_disk_alert('/')
        
        self.assertIn('ALERT', result)
        mock_disk_usage.assert_called_once_with('/')

    @patch('disk_monitor.shutil.disk_usage')
    def test_ok_when_disk_has_space(self, mock_disk_usage):
        # 1TB toplam, 400GB kullanılmış, 600GB boş
        mock_disk_usage.return_value = (1_000_000_000_000, 400_000_000_000, 600_000_000_000)
        
        result = check_disk_alert('/')
        
        self.assertIn('OK', result)

if __name__ == '__main__':
    unittest.main()

@patch dekoratörü, test süresince shutil.disk_usage‘ı mock nesneyle değiştiriyor. Test bittikten sonra otomatik olarak gerçek halini geri koyuyor. Bu pattern’i öğrenir öğrenmez testlerinizi yazmak çok daha temiz hale geliyor.

Harici API Çağrılarını Mock’lamak

Gerçek dünya senaryosuna geçelim. Bir Slack bildirimi gönderen fonksiyonunuz var ve bunu test etmek istiyorsunuz. Elbette her test çalışmasında gerçek Slack’e mesaj atmak istemiyorsunuz:

# notifier.py
import requests

def send_slack_notification(webhook_url, message, channel="#alerts"):
    payload = {
        "channel": channel,
        "text": message
    }
    response = requests.post(webhook_url, json=payload)
    
    if response.status_code == 200:
        return True
    else:
        raise Exception(f"Slack bildirimi gonderilemedi: HTTP {response.status_code}")

Test tarafında şunu yapabiliriz:

# test_notifier.py
import unittest
from unittest.mock import patch, MagicMock
from notifier import send_slack_notification

class TestSlackNotifier(unittest.TestCase):

    @patch('notifier.requests.post')
    def test_successful_notification(self, mock_post):
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_post.return_value = mock_response
        
        result = send_slack_notification(
            'https://hooks.slack.com/fake',
            'Sunucu CPU alarmi!'
        )
        
        self.assertTrue(result)
        mock_post.assert_called_once()
        
        # Gönderilen payload'ı doğrula
        call_args = mock_post.call_args
        self.assertEqual(call_args.kwargs['json']['text'], 'Sunucu CPU alarmi!')

    @patch('notifier.requests.post')
    def test_failed_notification_raises_exception(self, mock_post):
        mock_response = MagicMock()
        mock_response.status_code = 500
        mock_post.return_value = mock_response
        
        with self.assertRaises(Exception) as context:
            send_slack_notification(
                'https://hooks.slack.com/fake',
                'Test mesaji'
            )
        
        self.assertIn('500', str(context.exception))

if __name__ == '__main__':
    unittest.main()

MagicMock burada çok kullanışlı. İstediğiniz herhangi bir özelliği ona atayabiliyorsunuz, status_code, json() metodu, headers gibi ne isterseniz.

pytest-mock ile Daha Temiz Syntax

Prodüksiyonda ben genelde pytest ekibi ile pytest-mock kombinasyonunu kullanıyorum. Syntax daha temiz, fixture sistemiyle entegrasyonu mükemmel:

pip install pytest pytest-mock
# test_notifier_pytest.py
from notifier import send_slack_notification

def test_successful_notification(mocker):
    mock_post = mocker.patch('notifier.requests.post')
    mock_post.return_value.status_code = 200
    
    result = send_slack_notification(
        'https://hooks.slack.com/fake',
        'Disk dolmak uzere!'
    )
    
    assert result is True
    mock_post.assert_called_once()

def test_notification_payload_structure(mocker):
    mock_post = mocker.patch('notifier.requests.post')
    mock_post.return_value.status_code = 200
    
    send_slack_notification(
        'https://hooks.slack.com/fake',
        'Test',
        channel='#ops-alerts'
    )
    
    _, kwargs = mock_post.call_args
    assert kwargs['json']['channel'] == '#ops-alerts'

mocker fixture’ı otomatik cleanup yapıyor, test sonunda mock’ları temizlemenize gerek kalmıyor. Bu çok önemli bir detay, manuel temizlik unutulduğunda testler arasında state sızıntısı yaşanabiliyor.

Veritabanı Bağlantısını Mock’lamak

Otomasyon scriptlerinin büyük bir kısmı bir şekilde veritabanına dokunuyor. CMDB’nizi okuyorsunuz, log kayıtları yazıyorsunuz, konfigürasyon değerlerini çekiyorsunuz. Tüm bu durumlar için gerçek DB bağlantısına ihtiyaç duymadan test yazabilmek kritik:

# server_inventory.py
import psycopg2

def get_servers_by_status(conn_string, status):
    conn = psycopg2.connect(conn_string)
    cursor = conn.cursor()
    
    cursor.execute(
        "SELECT hostname, ip_address, os_type FROM servers WHERE status = %s",
        (status,)
    )
    
    servers = []
    for row in cursor.fetchall():
        servers.append({
            'hostname': row[0],
            'ip_address': row[1],
            'os_type': row[2]
        })
    
    cursor.close()
    conn.close()
    return servers
# test_server_inventory.py
from unittest.mock import patch, MagicMock
from server_inventory import get_servers_by_status

def test_get_active_servers(mocker):
    mock_conn = MagicMock()
    mock_cursor = MagicMock()
    
    mock_conn.cursor.return_value = mock_cursor
    mock_cursor.fetchall.return_value = [
        ('web-01', '192.168.1.10', 'Ubuntu 22.04'),
        ('web-02', '192.168.1.11', 'Ubuntu 22.04'),
        ('db-01', '192.168.1.20', 'CentOS 8'),
    ]
    
    mocker.patch('server_inventory.psycopg2.connect', return_value=mock_conn)
    
    servers = get_servers_by_status('postgresql://fake', 'active')
    
    assert len(servers) == 3
    assert servers[0]['hostname'] == 'web-01'
    assert servers[2]['os_type'] == 'CentOS 8'
    
    # SQL sorgusunun doğru çağrıldığını kontrol et
    mock_cursor.execute.assert_called_once()
    call_args = mock_cursor.execute.call_args
    assert 'WHERE status' in call_args[0][0]

Dikkat edin, burada hem bağlantıyı hem cursor’ı mock’ladık. Gerçek veritabanı olmadan fonksiyonun mantığını test edebildik. cursor.close() ve conn.close() çağrılarının yapıldığını da doğrulayabilirsiniz:

    mock_cursor.close.assert_called_once()
    mock_conn.close.assert_called_once()

Bu kaynak yönetimi açısından önemli, bağlantı sızıntılarını erken yakalamak için testlerde bu kontrolleri ihmal etmeyin.

Side Effect Kullanımı

Mock’ların güçlü özelliklerinden biri side_effect. Bununla bir fonksiyonun farklı çağrılarda farklı davranmasını sağlayabilirsiniz, ya da exception fırlattırabilirsiniz. Özellikle retry logic test ederken çok işe yarıyor:

# retry_client.py
import requests
import time

def fetch_with_retry(url, max_retries=3, delay=1):
    for attempt in range(max_retries):
        try:
            response = requests.get(url, timeout=5)
            response.raise_for_status()
            return response.json()
        except (requests.ConnectionError, requests.Timeout) as e:
            if attempt == max_retries - 1:
                raise
            time.sleep(delay)
    
def fetch_service_health(base_url):
    data = fetch_with_retry(f"{base_url}/health")
    return data.get('status', 'unknown')
# test_retry_client.py
import pytest
import requests
from unittest.mock import MagicMock, call
from retry_client import fetch_with_retry

def test_retry_on_connection_error(mocker):
    mock_get = mocker.patch('retry_client.requests.get')
    mocker.patch('retry_client.time.sleep')  # sleep'i de mock'la, testler yavaslamamali
    
    # Ilk iki cagri hata, ucuncu cagri basarili
    mock_success_response = MagicMock()
    mock_success_response.json.return_value = {'status': 'healthy'}
    mock_success_response.raise_for_status.return_value = None
    
    mock_get.side_effect = [
        requests.ConnectionError("Baglanti reddedildi"),
        requests.ConnectionError("Baglanti reddedildi"),
        mock_success_response
    ]
    
    result = fetch_with_retry('http://fake-service', max_retries=3, delay=1)
    
    assert result == {'status': 'healthy'}
    assert mock_get.call_count == 3

def test_raise_after_max_retries(mocker):
    mock_get = mocker.patch('retry_client.requests.get')
    mocker.patch('retry_client.time.sleep')
    
    mock_get.side_effect = requests.ConnectionError("Servis cevap vermiyor")
    
    with pytest.raises(requests.ConnectionError):
        fetch_with_retry('http://fake-service', max_retries=3, delay=1)
    
    assert mock_get.call_count == 3

time.sleep‘i de mock’lamak kritik bir detay. Test ortamında gerçek bekleme yapılmasını engelliyor. Yoksa 3 retry, 1 saniye delay ile 3 saniye süren testleriniz olur ve CI pipeline’ınız yavaşlar.

Context Manager Mock’lama

Dosya işlemleri, veritabanı transaction’ları gibi with bloklarını mock’lamak biraz daha özel bir yaklaşım gerektiriyor:

# log_processor.py
def count_error_lines(log_file_path):
    error_count = 0
    with open(log_file_path, 'r') as f:
        for line in f:
            if 'ERROR' in line or 'CRITICAL' in line:
                error_count += 1
    return error_count
# test_log_processor.py
from unittest.mock import mock_open, patch
from log_processor import count_error_lines

def test_count_errors_in_log():
    fake_log_content = """2024-01-15 10:00:01 INFO Server started
2024-01-15 10:00:05 ERROR Database connection failed
2024-01-15 10:00:10 INFO Retrying connection
2024-01-15 10:00:15 CRITICAL Disk space critical: 98% used
2024-01-15 10:00:20 INFO Connection restored
"""
    
    with patch('builtins.open', mock_open(read_data=fake_log_content)):
        result = count_error_lines('/var/log/app.log')
    
    assert result == 2

def test_empty_log_file():
    with patch('builtins.open', mock_open(read_data="")):
        result = count_error_lines('/var/log/app.log')
    
    assert result == 0

mock_open özellikle dosya okuma testleri için biçilmiş kaftan. Dosya sistemine hiç dokunmadan istediğiniz içeriği simüle edebiliyorsunuz.

Yaygın Hatalar ve Dikkat Edilmesi Gerekenler

Yıllar içinde gördüğüm en yaygın mock hatalarını paylaşayım:

Yanlış lokasyondan patch etmek: Mock’lar, nesnenin tanımlandığı yerde değil, kullanıldığı yerde patch edilmeli. notifier.py içinde import requests varsa, requests.post‘u değil notifier.requests.post‘u patch etmelisiniz. Bu detayı kaçırmak saatlerce neden çalışmadığını anlamaya çalışmanıza neden olabilir.

Aşırı mock kullanımı: Her şeyi mock’larsanız aslında hiçbir şeyi test etmiyorsunuz. Test sadece mock’ların doğru şekilde kurulduğunu doğrular hale gelir. Gerçek iş mantığının test edilip edilmediğini sorgulamayı bırakmayın.

Mock state’ini testler arasında sızdırmak: unittest.mock.patch dekoratör ya da context manager olarak kullanılırsa otomatik temizlenir. Manuel start() ve stop() kullanıyorsanız mutlaka cleanup yapın.

Integration testleri unutmak: Unit test seviyesinde mock’lar hayat kurtarır ama gerçek bileşenlerle de test etmeniz gerekiyor. CI pipeline’ınızda hem mock’lu unit testler hem de gerçek servislerle integration testler olmalı.

Return value yerine side effect karışıklığı: Çağrıldığında exception fırlatmasını istiyorsanız return_value değil side_effect kullanın. mock.side_effect = Exception("hata") ile mock.return_value = Exception("hata") çok farklı şeyler yapar. İkincisi sadece exception nesnesini döndürür, fırlatmaz.

Gerçek Bir Otomasyon Senaryosu

Son olarak birleştirici bir örnek. Bir sunucunun sistem metriklerini toplayıp izleme sistemine gönderen basit bir otomasyon scripti:

# metrics_collector.py
import psutil
import requests
from datetime import datetime

def collect_and_send_metrics(push_gateway_url, job_name):
    metrics = {
        'timestamp': datetime.utcnow().isoformat(),
        'cpu_percent': psutil.cpu_percent(interval=1),
        'memory_percent': psutil.virtual_memory().percent,
        'disk_percent': psutil.disk_usage('/').percent
    }
    
    response = requests.post(
        f"{push_gateway_url}/metrics/job/{job_name}",
        json=metrics,
        headers={'Content-Type': 'application/json'}
    )
    
    if response.status_code not in (200, 204):
        raise RuntimeError(f"Metrik gonderilemedi: {response.status_code}")
    
    return metrics

# test_metrics_collector.py
from unittest.mock import MagicMock, patch
import pytest
from metrics_collector import collect_and_send_metrics

def test_metrics_collected_and_sent(mocker):
    mocker.patch('metrics_collector.psutil.cpu_percent', return_value=45.2)
    
    mock_vmem = MagicMock()
    mock_vmem.percent = 67.8
    mocker.patch('metrics_collector.psutil.virtual_memory', return_value=mock_vmem)
    
    mock_disk = MagicMock()
    mock_disk.percent = 55.0
    mocker.patch('metrics_collector.psutil.disk_usage', return_value=mock_disk)
    
    mock_post = mocker.patch('metrics_collector.requests.post')
    mock_post.return_value.status_code = 204
    
    result = collect_and_send_metrics(
        'http://pushgateway:9091',
        'server_metrics'
    )
    
    assert result['cpu_percent'] == 45.2
    assert result['memory_percent'] == 67.8
    assert result['disk_percent'] == 55.0
    
    mock_post.assert_called_once()
    call_args = mock_post.call_args
    assert 'server_metrics' in call_args[0][0]
    assert call_args.kwargs['headers']['Content-Type'] == 'application/json'

Bu test hem sistem kaynaklarına hem de gerçek Prometheus push gateway’e dokunmadan tüm fonksiyonun mantığını doğruluyor.

Sonuç

Mocking, iyi yazılmış testlerin vazgeçilmez bir parçası. Sysadmin ve DevOps tarafında bunu genellikle “yazılımcı meselesi” diye kenara iteriz, ama kendi yazdığımız otomasyon scriptleri, deployment araçları, izleme çözümleri de yazılım ve bunların da düzgün test edilmesi gerekiyor.

Özellikle şu durumlar için mock kullanmayı alışkanlık edinmek gerekiyor: dış servis çağrıları, veritabanı işlemleri, dosya sistemi operasyonları, sistem çağrıları ve saate/tarihe bağımlı işlemler. Bunların hepsi testleri yavaşlatan, güvenilmez yapan ve ortama bağımlı hale getiren unsurlardır.

Test yazımına yeni başlıyorsanız şu sırayı takip etmenizi öneririm: önce unittest.mock.patch ile başlayın, sonra pytest-mock‘a geçin, ardından MagicMock ile karmaşık nesne davranışlarını simüle etmeyi öğrenin. Zamanla hangi durumda stub, hangi durumda full mock, hangi durumda gerçek implementasyona ihtiyaç duyduğunuzu sezgisel olarak anlayacaksınız.

Testlerini yazdığınız kod, hem daha güvenilir çalışır hem de sizi refactoring yaparken korur. Bu yatırımın karşılığını production ortamında önlenmiş bir outage ile aldığınızda mocking’e bakışınız kalıcı olarak değişecek.

Bir yanıt yazın

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