pytest Parametrize ile Çoklu Senaryo Testi

Üretim ortamında bir servis çöktüğünde, genellikle o ana kadar kimsenin düşünmediği bir edge case yüzünden çöker. “Bunu zaten test ettik” dersin, ama baktığında testlerin sadece mutlu yolu kapsadığını görürsün. İşte pytest.mark.parametrize tam bu noktada devreye giriyor: aynı test mantığını onlarca farklı girdi ve senaryo için tekrar yazmak yerine, tek bir test fonksiyonuyla tüm kombinasyonları kapsıyorsun. Yıllar içinde öğrendiğim en değerli pytest özelliği bu oldu desem yanlış olmaz.

Temel Mantık: Neden Parametrize?

Klasik yaklaşımda şöyle bir şey görürsün:

def test_pozitif_sayi():
    assert is_valid_port(8080) == True

def test_negatif_sayi():
    assert is_valid_port(-1) == False

def test_sifir():
    assert is_valid_port(0) == False

def test_max_port():
    assert is_valid_port(65535) == True

Bu yaklaşımın sorunu açık: her yeni senaryo için yeni bir fonksiyon, kod tekrarı, bakımı zorlaşan bir test dosyası. Bir gün is_valid_port fonksiyonunun imzası değişirse, dört ayrı yeri güncelliyorsun. Parametrize ile aynı şeyi şöyle yaparsın:

import pytest

@pytest.mark.parametrize("port, beklenen", [
    (8080, True),
    (-1, False),
    (0, False),
    (65535, True),
    (65536, False),
    (1, True),
    (443, True),
])
def test_port_gecerliligi(port, beklenen):
    assert is_valid_port(port) == beklenen

Yedi senaryo, tek fonksiyon. Test çıktısında her satır ayrı ayrı görünür, herhangi biri fail olduğunda tam olarak hangi girdiyle sorun çıktığını anlık görürsün.

Gerçek Dünya Senaryosu: Konfigürasyon Dosyası Ayrıştırıcı

Bir sysadmin aracı yazıyorsun diyelim. YAML veya INI formatındaki konfigürasyon dosyalarını okuyup doğrulayan bir modül. Bu tür araçlarda en çok karşılaştığım problem: geliştirme ortamında her şey çalışıyor, müşteri ortamında dosya farklı biçimlendirilmiş olduğu için sistem patlıyor.

Önce test edilecek fonksiyonu yazalım:

# config_parser.py
import re
from typing import Optional

def parse_memory_value(value: str) -> Optional[int]:
    """
    '512M', '2G', '1024K' gibi değerleri byte'a çevirir.
    Geçersiz format için None döner.
    """
    if not isinstance(value, str):
        return None
    
    value = value.strip().upper()
    pattern = r'^(d+(?:.d+)?)(K|M|G|T)?B?$'
    match = re.match(pattern, value)
    
    if not match:
        return None
    
    sayi = float(match.group(1))
    birim = match.group(2) or ''
    
    carpanlar = {'K': 1024, 'M': 1024**2, 'G': 1024**3, 'T': 1024**4}
    return int(sayi * carpanlar.get(birim, 1))

Şimdi bu fonksiyonu parametrize ile test edelim:

# test_config_parser.py
import pytest
from config_parser import parse_memory_value

@pytest.mark.parametrize("girdi, beklenen", [
    # Normal durumlar
    ("512M", 512 * 1024**2),
    ("2G", 2 * 1024**3),
    ("1024K", 1024 * 1024),
    ("1T", 1024**4),
    # Boşluk ve büyük/küçük harf toleransı
    ("  512M  ", 512 * 1024**2),
    ("2g", 2 * 1024**3),
    ("512mb", 512 * 1024**2),
    # Ondalık sayılar
    ("1.5G", int(1.5 * 1024**3)),
    # Sadece byte
    ("1024", 1024),
    # Geçersiz formatlar
    ("", None),
    ("abc", None),
    ("-512M", None),
    ("512X", None),
    (None, None),
    (123, None),  # String değil
])
def test_parse_memory_value(girdi, beklenen):
    assert parse_memory_value(girdi) == beklenen

Bunu çalıştırdığında pytest -v ile her satırın ayrı ayrı geçip geçmediğini görürsün. 15 senaryo, tek fonksiyon.

ids Parametresi ile Okunabilir Test İsimleri

Varsayılan olarak pytest parametreleri test_parse_memory_value[512M-536870912] gibi isimlendirir. Bunu insan okunabilir hale getirmek için ids kullanırsın:

@pytest.mark.parametrize("girdi, beklenen", [
    ("512M", 512 * 1024**2),
    ("", None),
    (None, None),
    ("abc", None),
], ids=[
    "gecerli_megabyte",
    "bos_string",
    "none_degeri",
    "gecersiz_format",
])
def test_parse_memory_value_ids(girdi, beklenen):
    assert parse_memory_value(girdi) == beklenen

Artık pytest çıktısında test_parse_memory_value_ids[gecerli_megabyte] gibi anlamlı isimler görürsün. CI/CD pipeline’ında test raporlarını incelerken bu küçük detay büyük fark yaratır.

Çoklu Parametrize Dekoratörü: Kombinasyon Patlaması

Bazen iki bağımsız boyutu test etmen gerekir. Örneğin bir HTTP istemcisi yazıyorsun ve hem farklı HTTP metodlarını hem de farklı timeout değerlerini test etmek istiyorsun. İki ayrı @pytest.mark.parametrize dekoratörü üst üste koyduğunda pytest bunların kartezyen çarpımını alır:

import pytest
import requests
from unittest.mock import patch, MagicMock

@pytest.mark.parametrize("timeout", [5, 30, 60])
@pytest.mark.parametrize("method", ["GET", "POST", "PUT", "DELETE"])
def test_http_istemci_metodlar(method, timeout):
    """
    4 metod x 3 timeout = 12 test senaryosu otomatik olarak oluşur.
    """
    with patch('requests.request') as mock_req:
        mock_response = MagicMock()
        mock_response.status_code = 200
        mock_req.return_value = mock_response
        
        # Kendi HTTP wrapper'ınızı test ettiğinizi varsayalım
        from http_client import make_request
        result = make_request(method, "https://api.example.com/test", timeout=timeout)
        
        mock_req.assert_called_once_with(
            method, 
            "https://api.example.com/test", 
            timeout=timeout
        )
        assert result.status_code == 200

Bu yaklaşımı dikkatli kullan. 4×3=12 test makul, ama 5x5x5=125 test seti kombinasyon patlamasına yol açar ve test suite’in gereksiz yere yavaşlar. Kartezyen çarpım kullan, ama seçici ol.

pytest.param ile Gelişmiş Kontrol

pytest.param kullandığında her bir test senaryosu için ayrı ayrı işaretleme yapabilir, bazılarını atlayabilir veya beklenen başarısızlık olarak işaretleyebilirsin:

import pytest
import sys

@pytest.mark.parametrize("isletim_sistemi, komut, beklenen_cikti", [
    pytest.param(
        "linux", "ls -la", "total",
        id="linux_ls_komutu"
    ),
    pytest.param(
        "windows", "dir", "Directory",
        id="windows_dir_komutu",
        marks=pytest.mark.skipif(
            sys.platform != "win32",
            reason="Sadece Windows'ta çalışır"
        )
    ),
    pytest.param(
        "linux", "nonexistent_command", None,
        id="gecersiz_komut",
        marks=pytest.mark.xfail(reason="Komut bulunamadı hatası bekleniyor")
    ),
])
def test_sistem_komutu(isletim_sistemi, komut, beklenen_cikti):
    import subprocess
    result = subprocess.run(komut.split(), capture_output=True, text=True)
    if beklenen_cikti:
        assert beklened_cikti in result.stdout

xfail işaretli test başarısız olursa, pytest bunu bir hata olarak değil beklenen bir durum olarak raporlar. CI pipeline’ında bilinen bir bug için geçici çözüm olarak çok işe yarar.

Fixture ile Parametrize Kombinasyonu

Gerçek sysadmin araçlarında genellikle veritabanı bağlantısı, dosya sistemi veya ağ bağlantısı gibi altyapıya ihtiyaç duyarsın. Fixture’ları parametrize ile birlikte kullanmak burada güçlü bir pattern oluşturur:

import pytest
import tempfile
import os

@pytest.fixture
def gecici_dizin():
    """Her test için temiz bir geçici dizin oluşturur."""
    with tempfile.TemporaryDirectory() as tmpdir:
        yield tmpdir

@pytest.mark.parametrize("dosya_adi, icerik, beklenen_boyut", [
    ("test.log", "INFO: sistem başlatıldın" * 100, None),  # boyutu hesapla
    ("bos.txt", "", 0),
    ("config.yaml", "server:n  port: 8080n", None),
    ("binary_sim.dat", "x" * 1024, 1024),
])
def test_dosya_yazma_okuma(gecici_dizin, dosya_adi, icerik, beklenen_boyut):
    dosya_yolu = os.path.join(gecici_dizin, dosya_adi)
    
    # Yaz
    with open(dosya_yolu, 'w') as f:
        f.write(icerik)
    
    # Doğrula
    assert os.path.exists(dosya_yolu)
    
    gercek_boyut = os.path.getsize(dosya_yolu)
    if beklened_boyut is not None:
        assert gercek_boyut == beklened_boyut
    
    # Oku ve içeriği doğrula
    with open(dosya_yolu, 'r') as f:
        okunan = f.read()
    assert okunan == icerik

Fixture parametrize ile birlikte çalışır ve her parametrize senaryosu için fixture yeniden çalışır. Böylece testler birbirini kirletmez.

Gerçek Senaryo: Log Analiz Aracı

Bir log analiz scripti yazıyorsun. Nginx, Apache, Syslog ve özel uygulama loglarını parse edeceksin. Her format farklı, hepsini tek bir test suite’iyle kapsamak istiyorsun:

# log_parser.py
import re
from datetime import datetime
from typing import Optional, Dict

def parse_log_satiri(satir: str, format_tipi: str) -> Optional[Dict]:
    formatlar = {
        "nginx": r'(?P<ip>S+) S+ S+ [(?P<zaman>[^]]+)] "(?P<metod>S+) (?P<yol>S+) S+" (?P<durum>d+) (?P<boyut>d+)',
        "syslog": r'(?P<ay>w+)s+(?P<gun>d+) (?P<saat>d+:d+:d+) (?P<host>S+) (?P<servis>S+): (?P<mesaj>.+)',
        "apache": r'(?P<ip>S+) S+ S+ [(?P<zaman>[^]]+)] "(?P<istek>[^"]+)" (?P<durum>d+) (?P<boyut>S+)',
    }
    
    pattern = formatlar.get(format_tipi)
    if not pattern:
        return None
    
    match = re.match(pattern, satir)
    return match.groupdict() if match else None
# test_log_parser.py
import pytest
from log_parser import parse_log_satiri

NGINX_SATIRI = '192.168.1.100 - admin [15/Jan/2024:10:30:45 +0300] "GET /api/status HTTP/1.1" 200 1234'
SYSLOG_SATIRI = 'Jan 15 10:30:45 web-server01 sshd: Accepted password for deploy from 10.0.0.5'
APACHE_SATIRI = '10.0.0.1 - - [15/Jan/2024:10:30:45 +0300] "POST /upload HTTP/2" 201 5678'

@pytest.mark.parametrize("satir, format_tipi, beklenen_alanlar", [
    # Nginx testleri
    (NGINX_SATIRI, "nginx", {"ip": "192.168.1.100", "metod": "GET", "durum": "200"}),
    ("", "nginx", None),  # Boş satır
    ("Gecersiz log satiri", "nginx", None),  # Yanlış format
    
    # Syslog testleri  
    (SYSLOG_SATIRI, "syslog", {"host": "web-server01", "servis": "sshd"}),
    ("", "syslog", None),
    
    # Apache testleri
    (APACHE_SATIRI, "apache", {"ip": "10.0.0.1", "durum": "201"}),
    
    # Bilinmeyen format
    (NGINX_SATIRI, "unknown_format", None),
    (NGINX_SATIRI, "", None),
])
def test_log_satiri_parse(satir, format_tipi, beklenen_alanlar):
    sonuc = parse_log_satiri(satir, format_tipi)
    
    if beklenen_alanlar is None:
        assert sonuc is None
    else:
        assert sonuc is not None
        for alan, deger in beklened_alanlar.items():
            assert sonuc[alan] == deger, f"{alan} alanı beklenen '{deger}' değil, '{sonuc[alan]}' bulundu"

Bu test yapısında dikkat etmeni istediğim bir şey var: assert mesajına ek bilgi eklemek. assert sonuc[alan] == deger, f"..." şeklinde verilen mesaj, test başarısız olduğunda neyin yanlış gittiğini hemen gösterir. Production’da bir log format değişikliği kırdığında, bu mesaj seni doğrudan sorunun köküne götürür.

Parametrize ile Performans Testi

Sysadmin araçlarında performans kritik. Bir fonksiyonun farklı girdi boyutlarında kabul edilebilir sürede çalışıp çalışmadığını test edebilirsin:

import pytest
import time

def toplu_ip_dogrula(ip_listesi: list) -> list:
    """Bir IP listesini doğrular, geçerli olanları döner."""
    import ipaddress
    gecerli = []
    for ip in ip_listesi:
        try:
            ipaddress.ip_address(ip)
            gecerli.append(ip)
        except ValueError:
            pass
    return gecerli

@pytest.mark.parametrize("liste_boyutu, max_sure_saniye", [
    (100, 0.1),
    (1000, 0.5),
    (10000, 5.0),
])
def test_ip_dogrulama_performans(liste_boyutu, max_sure_saniye):
    # Test verisi oluştur
    ip_listesi = [f"192.168.{i // 256}.{i % 256}" for i in range(liste_boyutu)]
    ip_listesi.extend(["gecersiz_ip", "999.999.999.999"] * (liste_boyutu // 10))
    
    baslangic = time.perf_counter()
    sonuc = toplu_ip_dogrula(ip_listesi)
    bitis = time.perf_counter()
    
    gecen_sure = bitis - baslangic
    
    assert len(sonuc) == liste_boyutu, "Geçerli IP sayısı yanlış"
    assert gecen_sure < max_sure_saniye, (
        f"{liste_boyutu} IP için {gecen_sure:.3f}s geçti, "
        f"limit {max_sure_saniye}s"
    )

conftest.py ile Merkezi Parametrize Verisi

Birden fazla test dosyasında aynı test verilerini kullanıyorsan, bunları conftest.py içinde merkezi olarak tanımlayabilirsin:

# conftest.py
import pytest

SUNUCU_ADRESLERI = [
    ("web-01.prod", 80, True),
    ("db-01.prod", 5432, True),
    ("cache-01.prod", 6379, True),
    ("192.168.1.999", 8080, False),  # Geçersiz IP
    ("", 443, False),               # Boş hostname
    ("web-01.prod", 0, False),      # Geçersiz port
    ("web-01.prod", 70000, False),  # Port aralık dışı
]

def pytest_configure(config):
    """Özel marker'ları kaydet."""
    config.addinivalue_line(
        "markers", "altyapi: Altyapı testleri (ağ erişimi gerektirir)"
    )

Ardından bu veriyi test dosyalarında kullanırsın:

# test_baglanti.py
import pytest
from conftest import SUNUCU_ADRESLERI
from baglanti import validate_endpoint

@pytest.mark.parametrize("host, port, beklenen", SUNUCU_ADRESLERI)
def test_endpoint_dogrulama(host, port, beklenen):
    assert validate_endpoint(host, port) == beklenen

Yaygın Hatalar ve Kaçınma Yolları

Birkaç yıllık deneyimde insanların parametrize ile düştüğü yaygın tuzakları görmüşümdür.

Değişebilir varsayılan argümanlar: Parametrize listesine mutable objeler koyma. Bir test diğerini etkiler.

# YANLIS - liste mutable, testler arası durum sızabilir
@pytest.mark.parametrize("config", [
    {"host": "localhost", "port": 5432},
    {"host": "production", "port": 5432},
])
def test_veritabani_config(config):
    config["test_edildi"] = True  # Bu mutation diğer testi etkiler!
    # ...

# DOGRU - kopya al
def test_veritabani_config(config):
    config = config.copy()
    config["test_edildi"] = True
    # ...

Çok fazla kombinasyon: İki parametrize dekoratörü çarpar, üç tanesi küpünü alır. Yüzlerce test çalıştırmak yerine, gerçekten önemli kombinasyonları seç. Her şeyi test etmek değil, doğru şeyleri test etmek önemlidir.

Anlamlı test isimleri: Test adı test_x[1-True-foo-bar-None] olduğunda hiçbir şey anlaşılmaz. ids kullan, ya da pytest.param ile anlamlı id ver. CI pipelineında test raporunu inceleyen meslektaşın sana teşekkür eder.

Sonuç

pytest.mark.parametrize basit görünür, ama iyi kullanıldığında test suite’ini hem daha kapsamlı hem de daha bakımı kolay hale getirir. Özellikle sysadmin araçlarında, konfigürasyon ayrıştırıcılarında, log işleme modüllerinde ve ağ doğrulama kodlarında fark yaratır.

Birkaç pratik öneri ile bitirelim: Önce mutlu yolu test et, sonra edge case’leri ekle. ids kullanmayı alışkanlık haline getir. Fixture ile parametrize kombinasyonunu öğren, gerçek gücü orada yatıyor. Performans testleri için parametrize mükemmel uyar, farklı veri boyutlarını tek seferde kapsarsın.

En önemlisi: production’da patlayan şeyler genellikle kimsenin düşünmediği input kombinasyonlarıdır. Parametrize, o kombinasyonları düşünmeni ve sistematik olarak test etmeni sağlar. Bir sonraki gece yarısı incident’inde seni kurtaran şey o “bir satır daha ekleyeyim” dediğin parametrize testi olabilir.

Bir yanıt yazın

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