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.
