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.
