pytest ile Snapshot Testing: UI ve API Çıktılarını Otomatik Doğrulama

Bir sysadmin olarak fark ettim ki en sinir bozucu bug turu, “dun calısıyordu, bugun neden bozuldu?” sorusuyla gelen tipten oluyor. API endpoint’inden donen JSON yanıtı hafifce degistiginde, bir UI component’inin render cıktısı beklenmedik sekilde farklılastıgında ya da bir konfigürasyon dosyası uretme scripti artık eski formatı vermediginde, bunları ancak production’da fark ediyorsunuz. Iste snapshot testing tam bu noktada devreye giriyor.

Snapshot Testing Nedir ve Neden Ihtiyac Duyarız

Snapshot testing, bir fonksiyonun ya da sistemin cıktısını ilk calistırdigınızda kaydeder ve sonraki her testde bu kaydedilmis cıktıyla karsilastirir. Eger bir fark varsa test baskısız bir sekilde size bunu bildirir. Klasik assert yaklasimiyla karsilastirildigında fark belirgindir: klasik yaklasimlarda her degisikligi manuel olarak güncellemeniz gerekir, snapshot testing ise bu yükü otomatize eder.

Özellikle su senaryolarda snapshot testing altın deger tasır:

  • API response dogrulama: Bir servis güncellendikten sonra JSON yapisının bozulmadıgını dogrulamak
  • CLI tool cıktısı: Bir script’in terminal cıktısının degismedigini kontrol etmek
  • Konfigürasyon dosyası üretimi: Jinja2 template’lerinden üretilen YAML/JSON dosyalarının regresyon testi
  • HTML render cıktısı: Server-side rendering yapan bir uygulamada sayfa cıktısının tutarliligini kontrol etmek

pytest ekosisteminde snapshot testing için en olgun cözüm syrupy kütüphanesidir. Daha eski bir alternatif olan snapshottest da kullanilabilir ama syrupy daha aktif gelisim içindedir ve pytest ile entegrasyonu cok daha temizdir.

Kurulum ve Ilk Adımlar

Önce bir sanal ortam kuralım ve gerekli bagimlılıkları yükleyelim:

python -m venv venv
source venv/bin/activate  # Windows: venvScriptsactivate
pip install pytest syrupy requests fastapi httpx
pip freeze > requirements.txt

Proje yapısını düzenleyelim:

mkdir -p myproject/{api,tests,snapshots}
touch myproject/api/__init__.py
touch myproject/tests/conftest.py
touch myproject/tests/test_api_snapshots.py
touch myproject/tests/test_cli_snapshots.py

syrupy pytest’e otomatik olarak entegre olur, conftest.py’a herhangi bir sey eklemeniz gerekmez. Test fonksiyonunuzun parametrelerine snapshot fixture’ını eklemek yeterlidir.

Basit Bir API ile Snapshot Testing

Önce test edecegimiz basit bir FastAPI uygulaması yazalım:

cat > myproject/api/main.py << 'EOF'
from fastapi import FastAPI
from datetime import datetime

app = FastAPI()

@app.get("/health")
def health_check():
    return {
        "status": "healthy",
        "service": "myproject-api",
        "version": "1.2.0",
        "features": ["auth", "logging", "rate-limiting"]
    }

@app.get("/users/{user_id}")
def get_user(user_id: int):
    # Gercek hayatta bir DB sorgusu olurdu
    users = {
        1: {"id": 1, "username": "admin", "role": "superuser", "active": True},
        2: {"id": 2, "username": "johndoe", "role": "viewer", "active": False},
    }
    return users.get(user_id, {"error": "not found"})

@app.get("/config")
def get_config():
    return {
        "database": {
            "pool_size": 10,
            "timeout": 30,
            "retry_attempts": 3
        },
        "cache": {
            "backend": "redis",
            "ttl": 3600,
            "max_entries": 10000
        },
        "logging": {
            "level": "INFO",
            "format": "json",
            "destinations": ["stdout", "file"]
        }
    }
EOF

Simdi bu endpoint’leri snapshot test edelim:

cat > myproject/tests/test_api_snapshots.py << 'EOF'
import pytest
from fastapi.testclient import TestClient
from syrupy.assertion import SnapshotAssertion
import sys
import os

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from api.main import app

client = TestClient(app)

def test_health_endpoint_snapshot(snapshot: SnapshotAssertion):
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json() == snapshot

def test_user_endpoint_admin_snapshot(snapshot: SnapshotAssertion):
    response = client.get("/users/1")
    assert response.json() == snapshot

def test_user_endpoint_not_found_snapshot(snapshot: SnapshotAssertion):
    response = client.get("/users/999")
    assert response.json() == snapshot

def test_config_endpoint_snapshot(snapshot: SnapshotAssertion):
    response = client.get("/config")
    assert response.json() == snapshot
EOF

Ilk kez calistirdigınızda snapshot’ları olusturmak için --snapshot-update flag’ini kullanmanız gerekir:

cd myproject
python -m pytest tests/test_api_snapshots.py --snapshot-update -v

Bu komut tests/__snapshots__/test_api_snapshots.ambr dosyasını olusturur. Bir sonraki calistirmada artık --snapshot-update olmadan calistırabilirsiniz ve karsilastirma otomatik yapılır:

python -m pytest tests/test_api_snapshots.py -v

Snapshot Dosyasını Anlamak

Syrupy’nin .ambr formatı insan tarafından okunabilir bir formattır. Üretilen snapshot dosyasına bakalım:

cat tests/__snapshots__/test_api_snapshots.ambr

Çıktı soyle görünür:

# serializer version: 1
# name: test_health_endpoint_snapshot
  {
    'features': list([
      'auth',
      'logging',
      'rate-limiting',
    ]),
    'service': 'myproject-api',
    'status': 'healthy',
    'version': '1.2.0',
  }
---
# name: test_config_endpoint_snapshot
  {
    'cache': {
      'backend': 'redis',
      'max_entries': 10000,
      'ttl': 3600,
    },
    'database': {
      'pool_size': 10,
      'retry_attempts': 3,
      'timeout': 30,
    },
    ...
  }
---

Bu dosyayı mutlaka git’e commit etmelisiniz. Snapshot dosyaları kodunuzun bir parcasıdır, gitignore’a eklemeyin. Bir developer snapshot’ı degistirmek istediginde kod review sürecinde bu degisiklik gözükür ve bilinçli bir karar verilmis olur.

CLI Araçlarının Cıktısını Test Etmek

Gerçek hayatta sysadmin’lerin yazdıgı script’lerin çıktısını test etmek sık karşılaşılan bir senaryodur. Bir log analiz aracı yazalım:

cat > myproject/cli_tools.py << 'EOF'
import subprocess
import json
from typing import Optional

def get_disk_usage_report(path: str = "/") -> dict:
    """
    df komutunun cıktısını parse ederek yapilandirilmis rapor döner.
    Gercek ortamda bu fonksiyon production sunucularda calisir.
    """
    # Test edilebilirlik icin hardcoded ornek
    # Gercek implementasyonda: subprocess.run(["df", "-h", path])
    return {
        "filesystem": "/dev/sda1",
        "size": "100G",
        "used": "45G",
        "available": "55G",
        "use_percent": "45%",
        "mount_point": path,
        "status": "healthy",
        "warnings": []
    }

def parse_nginx_config(config_text: str) -> dict:
    """Basit nginx konfigürasyonunu parse eder"""
    result = {
        "worker_processes": None,
        "server_blocks": [],
        "upstream_blocks": []
    }
    
    for line in config_text.strip().split('n'):
        line = line.strip()
        if line.startswith('worker_processes'):
            result["worker_processes"] = line.split()[1].rstrip(';')
        elif 'server_name' in line:
            server_name = line.split()[1].rstrip(';')
            result["server_blocks"].append({"server_name": server_name})
    
    return result

def generate_ansible_inventory(hosts: list) -> str:
    """Host listesinden Ansible inventory YAML üretir"""
    lines = ["[webservers]"]
    for host in hosts:
        line = f"{host['hostname']} ansible_host={host['ip']}"
        if host.get('port'):
            line += f" ansible_port={host['port']}"
        if host.get('user'):
            line += f" ansible_user={host['user']}"
        lines.append(line)
    
    lines.append("")
    lines.append("[webservers:vars]")
    lines.append("ansible_python_interpreter=/usr/bin/python3")
    
    return 'n'.join(lines)
EOF

Bu araçların snapshot testlerini yazalım:

cat > myproject/tests/test_cli_snapshots.py << 'EOF'
import pytest
from syrupy.assertion import SnapshotAssertion
import sys
import os

sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from cli_tools import get_disk_usage_report, parse_nginx_config, generate_ansible_inventory

def test_disk_usage_report_snapshot(snapshot: SnapshotAssertion):
    """Disk raporu yapısının degismedigini dogrular"""
    report = get_disk_usage_report("/var/log")
    assert report == snapshot

def test_nginx_config_parsing_snapshot(snapshot: SnapshotAssertion):
    """Nginx config parse cıktısının tutarlılıgını dogrular"""
    nginx_config = """
    worker_processes 4;
    
    http {
        server {
            server_name example.com;
            listen 80;
        }
        server {
            server_name api.example.com;
            listen 80;
        }
    }
    """
    result = parse_nginx_config(nginx_config)
    assert result == snapshot

def test_ansible_inventory_generation_snapshot(snapshot: SnapshotAssertion):
    """Üretilen Ansible inventory formatının regresyon testi"""
    hosts = [
        {"hostname": "web01", "ip": "192.168.1.10", "port": 22, "user": "deploy"},
        {"hostname": "web02", "ip": "192.168.1.11", "port": 22, "user": "deploy"},
        {"hostname": "web03", "ip": "192.168.1.12", "port": None, "user": "ansible"},
    ]
    inventory = generate_ansible_inventory(hosts)
    assert inventory == snapshot

def test_multiple_disk_paths_snapshot(snapshot: SnapshotAssertion):
    """Birden fazla path için disk raporu"""
    paths = ["/", "/var", "/tmp", "/home"]
    reports = {path: get_disk_usage_report(path) for path in paths}
    assert reports == snapshot
EOF

Dinamik Verileri Snapshot’lardan Dıslamak

Snapshot testing’in en büyük düsmanı zamana baglı veya rastgele verilerdir. Bir created_at timestamp’i veya UUID her calistirmada farklı olacagından snapshot testi basarisiz olur. Syrupy’de bu durumu matcher mekanizmasiyla çözebilirsiniz:

cat > myproject/tests/test_dynamic_data_snapshots.py << 'EOF'
import pytest
from syrupy.assertion import SnapshotAssertion
from syrupy.matchers import path_type
from datetime import datetime
import uuid

def create_deployment_event(service_name: str, version: str) -> dict:
    """Her cagırıldıgında yeni timestamp ve UUID üretir"""
    return {
        "event_id": str(uuid.uuid4()),
        "service": service_name,
        "version": version,
        "deployed_at": datetime.now().isoformat(),
        "status": "success",
        "rollback_available": True,
        "config": {
            "replicas": 3,
            "resources": {
                "cpu": "500m",
                "memory": "256Mi"
            }
        }
    }

def test_deployment_event_snapshot(snapshot: SnapshotAssertion):
    """
    Dinamik alanları (event_id, deployed_at) dıslarken
    yapısal tutarlılıgı dogrular
    """
    event = create_deployment_event("auth-service", "v2.1.0")
    
    # path_type matcher: belirli path'lerdeki degerlerin
    # tipini kontrol eder, degerini degil
    assert event == snapshot(
        matcher=path_type({
            "event_id": (str,),
            "deployed_at": (str,),
        })
    )

def test_multiple_services_snapshot(snapshot: SnapshotAssertion):
    """Birden fazla servisin deployment event yapısını dogrular"""
    services = ["auth-service", "api-gateway", "worker-service"]
    events = [create_deployment_event(svc, "v1.0.0") for svc in services]
    
    assert events == snapshot(
        matcher=path_type({
            ".*\.event_id": (str,),
            ".*\.deployed_at": (str,),
        })
    )
EOF

Snapshot’ları güncellemek:

python -m pytest tests/test_dynamic_data_snapshots.py --snapshot-update -v

Snapshot Güncelleme Stratejisi

Takım ortamında çalisirken snapshot yönetimi kritik önem tasır. Kullanisli bazı komutlar:

# Sadece baskisiz olan snapshot'ları güncelle
python -m pytest --snapshot-update --snapshot-warn-unused

# Hangi snapshot'ların artık kullanılmadıgını göster
python -m pytest --snapshot-details

# Kullanılmayan (orphan) snapshot'ları temizle
python -m pytest --snapshot-update --snapshot-prune-unused

# Sadece belirli bir test dosyasının snapshot'larını güncelle
python -m pytest tests/test_api_snapshots.py --snapshot-update -v

# Fark nerede oldugunu görmek icin verbose mod
python -m pytest -v --tb=short

CI/CD pipeline’ınızda snapshot güncellemesinin otomatik yapılmaması gerekir. .gitlab-ci.yml veya GitHub Actions’da bunu zorunlu kilabilirsiniz:

cat > .github/workflows/test.yml << 'EOF'
name: Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'
      - name: Install dependencies
        run: pip install -r requirements.txt
      - name: Run snapshot tests (no update allowed)
        run: python -m pytest tests/ -v --tb=short
      # --snapshot-update FLAG ASLA BURADA OLMAMALI
EOF

Özel Serializer Yazmak

Bazen varsayılan serializer ihtiyacınızı karsilamaz. Örnegin log satırlarını normalize etmek veya binary veriyi is edilebilir formata dökmek isteyebilirsiniz:

cat > myproject/tests/conftest.py << 'EOF'
import pytest
from syrupy.extensions.amber import AmberSnapshotSerializer

class NormalizedLogSerializer(AmberSnapshotSerializer):
    """
    Log cıktılarındaki IP adreslerini ve timestamp'leri
    normalize ederek snapshot'a kaydeder
    """
    
    @classmethod
    def serialize(cls, data, **kwargs):
        if isinstance(data, str):
            import re
            # IP adreslerini maskele
            data = re.sub(
                r'bd{1,3}.d{1,3}.d{1,3}.d{1,3}b',
                '<IP_ADDR>',
                data
            )
            # Timestamp'leri normalize et
            data = re.sub(
                r'd{4}-d{2}-d{2}Td{2}:d{2}:d{2}',
                '<TIMESTAMP>',
                data
            )
        return super().serialize(data, **kwargs)

@pytest.fixture
def normalized_snapshot(snapshot):
    return snapshot.use_extension(NormalizedLogSerializer)
EOF

Bu özel serializer’ı kullanan bir test:

cat >> myproject/tests/test_cli_snapshots.py << 'EOF'

def test_log_output_with_normalized_snapshot(normalized_snapshot):
    """
    Log cıktısındaki dinamik IP ve timestamp degerlerini
    normalize ederek snapshot karsilastirması yapar
    """
    sample_log = """
2024-01-15T14:32:11 INFO Request from 192.168.1.45 to /api/health
2024-01-15T14:32:12 INFO Response 200 sent to 192.168.1.45 in 12ms
2024-01-15T14:32:15 WARN Slow query detected from 10.0.0.5
    """.strip()
    
    assert sample_log == normalized_snapshot
EOF

Gercek Dunya Senaryosu: Monitoring Script Regresyon Testi

Production’da kullandıgım bir monitoring scriptinin snapshot testiyle nasıl koruma altına alındıgına bakalım:

cat > myproject/tests/test_monitoring_snapshot.py << 'EOF'
import pytest
from syrupy.assertion import SnapshotAssertion
from syrupy.matchers import path_type

# Monitoring aggregator fonksiyonu
def aggregate_server_metrics(raw_metrics: list) -> dict:
    """
    Ham metrik listesini is edilebilir rapora dönüstürür.
    Bu fonksiyonun cıktı formatı değişirse alerting sistemi bozulur.
    """
    total_cpu = sum(m["cpu_percent"] for m in raw_metrics)
    total_memory = sum(m["memory_used_gb"] for m in raw_metrics)
    
    alerts = []
    for metric in raw_metrics:
        if metric["cpu_percent"] > 80:
            alerts.append({
                "type": "HIGH_CPU",
                "host": metric["hostname"],
                "value": metric["cpu_percent"]
            })
        if metric["memory_used_gb"] / metric["memory_total_gb"] > 0.9:
            alerts.append({
                "type": "HIGH_MEMORY",
                "host": metric["hostname"],
                "value": round(metric["memory_used_gb"] / metric["memory_total_gb"] * 100, 1)
            })
    
    return {
        "summary": {
            "total_servers": len(raw_metrics),
            "avg_cpu": round(total_cpu / len(raw_metrics), 2),
            "total_memory_used_gb": round(total_memory, 2),
            "alert_count": len(alerts)
        },
        "alerts": alerts,
        "healthy_servers": [
            m["hostname"] for m in raw_metrics
            if m["cpu_percent"] < 80
        ]
    }

SAMPLE_METRICS = [
    {"hostname": "web01", "cpu_percent": 45.2, "memory_used_gb": 6.1, "memory_total_gb": 8.0},
    {"hostname": "web02", "cpu_percent": 87.5, "memory_used_gb": 7.8, "memory_total_gb": 8.0},
    {"hostname": "db01", "cpu_percent": 23.1, "memory_used_gb": 28.5, "memory_total_gb": 32.0},
    {"hostname": "cache01", "cpu_percent": 12.4, "memory_used_gb": 14.2, "memory_total_gb": 16.0},
]

def test_metric_aggregation_snapshot(snapshot: SnapshotAssertion):
    """
    Metrik aggregation cıktısının yapısal tutarlılıgını dogrular.
    Bu test baskısız olursa alerting pipeline'ı risk altındadır.
    """
    result = aggregate_server_metrics(SAMPLE_METRICS)
    assert result == snapshot

def test_metric_aggregation_no_alerts_snapshot(snapshot: SnapshotAssertion):
    """Hepsinin saglikli oldugu senaryonun snapshot'ı"""
    healthy_metrics = [
        {"hostname": f"web0{i}", "cpu_percent": 20.0, 
         "memory_used_gb": 4.0, "memory_total_gb": 8.0}
        for i in range(1, 4)
    ]
    result = aggregate_server_metrics(healthy_metrics)
    assert result == snapshot
EOF

Tüm testleri calistırip sonuclara bakalım:

# Ilk calistirma - snapshot'ları olustur
python -m pytest myproject/tests/ --snapshot-update -v

# Ikinci calistirma - karsilastir
python -m pytest myproject/tests/ -v --tb=short

# Ozet rapor
python -m pytest myproject/tests/ -v --snapshot-details

Snapshot Testing’i Geleneksel Testlerle Birlestirmek

Snapshot testing, klasik birim testlerin yerini almaz. Ikisi birbirini tamamlar. Bir endpoint’in hem dogru HTTP status döndürdüsünü hem de yanıt yapısının degismedigini birlikte test edebilirsiniz:

def test_user_endpoint_combined(snapshot: SnapshotAssertion):
    """Klasik assert + snapshot birlikteliği"""
    response = client.get("/users/1")
    
    # Klasik dogrulama: durum kodunu kesin olarak kontrol et
    assert response.status_code == 200
    
    data = response.json()
    
    # Klasik dogrulama: kritik alanların varlıgını kontrol et
    assert "id" in data
    assert "username" in data
    assert data["id"] == 1
    
    # Snapshot dogrulama: tüm yapının tutarlılıgını dogrula
    assert data == snapshot

Bu yaklasimdaki mantık sudur: kritik is kurallarını (status code, zorunlu alanlar) klasik assert ile test edin, cıktının genel yapısal tutarlılıgını ise snapshot ile koruyun.

Sonuc

Snapshot testing, özellikle sık degisen API’lar, kompleks veri dönüsümleri veya formatted metin cıktısı üreten araçlar söz konusu oldugunda ciddi bir güvenlik agi olusturur. Syrupy ile birlikte kullandıgınızda pytest ekosistemiyle mükemmel uyum saglar.

Uygulamada dikkat etmeniz gereken birkaç nokta var. Snapshot dosyalarını mutlaka git’e ekleyin, bunlar kodunuzun belgelemesinin bir parcasıdır. --snapshot-update flagini CI’da asla otomatik calistırmayın, sadece bilinçli bir degisiklikten sonra manuel olarak calistırın. Dinamik verileri (timestamp, UUID) matcher mekanizmasiyla dislayın, aksi takdirde testleriniz sürekli baskısız olur ve gürültüye dönüsür. Son olarak her snapshot testini anlamli bir isimlendirmeyle belgelendirin ki bir yıl sonra o snapshot’ın neden orada oldugunu anlasinlar.

Tecrübelerime göre snapshot testing en büyük degerini refactoring dönemlerinde gösteriyor. Büyük bir kodu yeniden yazdıgınızda fonksiyonaliteyi bozmadıgınızı kanıtlamanın en pratik yolu mevcut snapshot’ların tamamının gecmesi. Bu hem size güven verir hem de kod reviewerlarına “bak, cıktı degismedi” diyerek somut kanıt sunar.

Bir yanıt yazın

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