Ana içeriğe geç
Kesit Python
Orta · Test

pytest ile test: fixture, parametrize ve gerçek proje düzeni

pytest'in test keşfini, düz assert ve assert yeniden yazımını, pytest.raises ve pytest.approx'u, fixture (scope, yield ile temizlik), parametrize, conftest.py, skip/xfail işaretlerini, tmp_path, monkeypatch ve capsys'i, coverage ölçümünü ve komut satırı seçeneklerini öğrenirsin.

Uygulamada aç, 19 alıştırmayı çözÜcretsiz, hesap gerekmez, kurulum yok.

Bu sayfa dersin okuma sürümüdür. Metin bir kutuyu çalıştırmanı ya da adıma geçmeni söylüyorsa dersi uygulamada aç.

Neden pytest?

Test yazma dersinde unittest ile test sınıfı kurdun, pytest tarzına da kısaca göz attın. Bugün Python ekosisteminde yeni projelerin büyük çoğunluğu pytest kullanır: test bir sınıf değil sıradan bir fonksiyondur, karşılaştırma için self.assertEqual yerine düz assert yazılır ve fixture sistemi test verisini hazırlamayı çok kolaylaştırır. Ilgaz Bank'ta faiz hesabını değiştiren bir geliştirici, 800 testlik paketi bir komutla çalıştırır; kırmızı çıkan test ona hangi hesaplamayı bozduğunu dakikalar içinde söyler.

Ama asıl ders şudur: iyi test, bozuk kodda başarısız olan testtir. Her zaman yeşil yanan bir test güven vermez, sadece süs olur. Bu dersin alıştırmalarında yazdığın testler önce doğru koda, sonra kasıtlı bozulmuş sürümlere karşı çalıştırılacak; bozuk sürümleri yakalayamayan test "geçmiş" sayılmayacak.

Tarayıcıda pytestpytest bir komut satırı aracıdır: dosyaları tarar, testleri toplar ve raporlar. Tarayıcıda dosya taraması yerine test fonksiyonlarını doğrudan çağıracağız; pytest.raises, pytest.approx, pytest.fixture ve pytest.mark ise gerçek pytest modülünden gelir. Komut satırı örnekleri kendi bilgisayarında çalıştırman içindir.

Kurulum ve test keşfi

# Kendi bilgisayarında (sanal ortam etkinken)
pip install pytest pytest-cov
pytest                 # bulunduğun klasörden itibaren tüm testleri bul ve çalıştır

pytest şu kurallarla test toplar (test discovery):

ÖğeKuralÖrnek
Dosyatest_*.py ya da *_test.pytest_fiyat.py
Fonksiyontest ile başlayandef test_kdv_ekle():
SınıfTest ile başlayan, __init__ içermeyenclass TestSepet:
MetotTest sınıfında test ile başlayandef test_toplam(self):

Tipik proje düzeni:

anadolu_market/
    src/anadolu/fiyat.py
    tests/
        conftest.py          # ortak fixture'lar
        test_fiyat.py
        test_sepet.py
    pyproject.toml           # [tool.pytest.ini_options]

Düz assert ve assert yeniden yazımı

pytest, test dosyalarını import ederken içlerindeki assert ifadelerini yeniden yazar (assertion rewriting). Bu sayede başarısız bir assert toplam == 120 satırı yalnızca "AssertionError" demez; sol ve sağ tarafın değerlerini, listelerde farklı elemanı, sözlüklerde eksik anahtarı gösterir.

# tests/test_fiyat.py
from anadolu.fiyat import kdv_ekle

def test_kdv_ekle():
    assert kdv_ekle(100) == 120.0
    assert kdv_ekle(9.99) == 11.99          # yuvarlama da test edilir
    assert kdv_ekle(100, oran=0.10) == 110.0
    def test_kdv_ekle():
>       assert kdv_ekle(9.99) == 11.99
E       assert 11.988 == 11.99
E        +  where 11.988 = kdv_ekle(9.99)
FAILED tests/test_fiyat.py::test_kdv_ekle - assert 11.988 == 11.99

Tarayıcıda aynı test fonksiyonunu doğrudan çağırabiliriz. Doğru kodda sessizce geçer; bozuk kodda AssertionError fırlatır:

def kdv_ekle(tutar, oran=0.20):
    return round(tutar * (1 + oran), 2)

def test_kdv_ekle():
    assert kdv_ekle(100) == 120.0
    assert kdv_ekle(9.99) == 11.99
    assert kdv_ekle(100, oran=0.10) == 110.0

test_kdv_ekle()
print("doğru sürüm: geçti")

def kdv_ekle(tutar, oran=0.20):          # yuvarlamayı unutan sürüm
    return tutar * (1 + oran)
try:
    test_kdv_ekle()
except AssertionError:
    print("bozuk sürüm: test yakaladı")
assert'e virgülle beklenen değer yazmaassert indirim(1000, 0.25), 750 "sonuç 750 mi?" diye sormaz; "sonuç doğru mu (sıfır değil mi)?" diye sorar, 750 ise hata mesajıdır. Bu test, sonuç sıfır olmadığı sürece hep geçer. Doğrusu assert indirim(1000, 0.25) == 750.

pytest.raises ve pytest.approx

import pytest

class YetersizBakiye(Exception):
    pass

def para_cek(bakiye, tutar):
    if tutar <= 0:
        raise ValueError("tutar negatif ya da sıfır olamaz")
    if tutar > bakiye:
        raise YetersizBakiye(f"bakiye {bakiye} TL, istenen {tutar} TL")
    return bakiye - tutar

with pytest.raises(ValueError, match="negatif"):
    para_cek(100, -5)
with pytest.raises(YetersizBakiye) as bilgi:
    para_cek(100, 250)
print("yakalanan:", bilgi.type.__name__, "|", bilgi.value)

print(0.1 + 0.2 == 0.3)                        # False: kayan nokta
print(0.1 + 0.2 == pytest.approx(0.3))         # True
print(1000 * 1.1 ** 2, 1000 * 1.1 ** 2 == pytest.approx(1210))
print([0.1 + 0.2, 1.0] == pytest.approx([0.3, 1.0]))
print(101 == pytest.approx(100, rel=0.02))     # yüzde 2 tolerans

match bir düzenli ifadedir ve hata mesajında re.search ile aranır. with bloğunda hata fırlatan satırdan sonra yazılan kod asla çalışmaz; bloğa yalnızca hatayı beklediğin tek çağrıyı koy. Hata hiç fırlatılmazsa pytest.raises testi Failed: DID NOT RAISE ile düşürür.

Fixture: test verisini hazırlayan fonksiyon

Birçok testin aynı hazırlığa ihtiyacı varsa (dolu bir sepet, örnek müşteri listesi, veritabanı bağlantısı) bunu fixture olarak yazarsın. Test fonksiyonu fixture'ın adını parametre olarak ister; pytest fixture'ı çalıştırıp sonucunu verir. Buna bağımlılık enjeksiyonu (dependency injection) denir.

import pytest
from anadolu.sepet import Sepet

@pytest.fixture
def dolu_sepet():
    s = Sepet()
    s.ekle("Çay", 50, adet=2)
    s.ekle("Şeker", 35.5)
    return s

def test_toplam(dolu_sepet):          # pytest dolu_sepet() sonucunu verir
    assert dolu_sepet.toplam() == 135.5

def test_gecersiz_adet(dolu_sepet):   # her test TAZE bir sepet alır
    with pytest.raises(ValueError):
        dolu_sepet.ekle("Kahve", 120, adet=0)

yield ile temizlik: fixture return yerine yield kullanırsa, yield sonrası kod test bittikten sonra (test başarısız olsa bile) çalışır. Geçici veritabanını kapatmak, dosya silmek için idealdir. scope fixture'ın ne sıklıkla yeniden oluşturulacağını belirler:

scopeNe zaman oluşturulur?Örnek kullanım
"function" (varsayılan)Her test için yenidenDeğiştirilebilir sepet, sözlük
"class"Test sınıfı başına bir kezSınıftaki testlerin ortak verisi
"module"Test dosyası başına bir kezPahalı ama salt okunur veri
"session"Tüm çalıştırmada bir kezTest veritabanı, Docker konteyneri

Bir fixture'ın yaşam döngüsünü tarayıcıda üreteçle (generator) taklit edebiliriz; pytest de yield fixture'ı tam olarak böyle çalıştırır:

def baglanti():                      # @pytest.fixture olsaydı
    print("kur: test veritabanı açıldı")
    db = {"musteriler": ["Ayşe", "Can"]}
    yield db
    db.clear()
    print("temizle: veritabanı kapatıldı")

def test_musteri_sayisi(db):
    assert len(db["musteriler"]) == 2
    print("test çalıştı")

uretec = baglanti()
db = next(uretec)                    # yield'e kadar: hazırlık
try:
    test_musteri_sayisi(db)
finally:
    next(uretec, None)               # yield sonrası: temizlik

conftest.py: Birden çok test dosyasının kullandığı fixture'lar tests/conftest.py dosyasına yazılır. pytest bu dosyayı otomatik yükler; import etmene gerek yoktur. Alt klasörlerde ayrı conftest.py dosyaları olabilir, en yakındaki öncelik kazanır.

parametrize: tek test, çok durum

import pytest

def kargo_ucreti(tutar):
    if tutar < 0:
        raise ValueError("tutar negatif olamaz")
    return 0.0 if tutar >= 500 else 49.9

@pytest.mark.parametrize("tutar, beklenen", [
    (0, 49.9),
    (499.99, 49.9),      # sınırın hemen altı
    (500, 0.0),          # tam sınır
    (1250, 0.0),
])
def test_kargo(tutar, beklenen):
    assert kargo_ucreti(tutar) == beklenen

# pytest her satırı ayrı test sayar: test_kargo[0-49.9], test_kargo[499.99-49.9] ...
isaret = test_kargo.pytestmark[0]
print(isaret.name, isaret.args[0])
for durum in isaret.args[1]:
    test_kargo(*durum)
print(len(isaret.args[1]), "durum geçti")
Sınır değerlerini test etHataların çoğu sınırlardadır: >= yerine >, boş liste, sıfır, negatif sayı, tam eşik. Parametre listesine her zaman "sınırın kendisi" ve "hemen altı/üstü" durumlarını koy.

İşaretler: skip, skipif, xfail

import sys, pytest

@pytest.mark.skip(reason="Kur servisi bakımda")
def test_canli_kur(): ...

@pytest.mark.skipif(sys.platform == "win32", reason="Yalnızca Linux sunucuda")
def test_dosya_izinleri(): ...

@pytest.mark.xfail(reason="Hata #142: yuvarlama düzeltilecek")
def test_kusurat_yuvarlama(): ...

@pytest.mark.yavas              # kendi işaretin: pyproject'te tanımla
def test_buyuk_rapor(): ...

xfail "bu testin şu an başarısız olmasını bekliyorum" demektir; geçerse XPASS olarak raporlanır, böylece hata düzeldiğinde haberin olur. Kendi işaretlerini pytest -m "not yavas" ile süzersin.

Yerleşik fixture'lar: tmp_path, monkeypatch, capsys

FixtureNe verir?Ne zaman?
tmp_pathHer teste özel, boş bir pathlib.Path klasörüDosya yazan/okuyan kod
monkeypatchsetattr, setenv, delenv, setitem; test bitince her şeyi geri alırDış servis, saat, ortam değişkeni
capsysreadouterr() ile o ana kadarki stdout/stderrprint eden kod
caploglogging kayıtlarıLog mesajı denetimi
import os, pytest

class KurServisi:
    @staticmethod
    def usd_kuru():
        raise ConnectionError("testte gerçek servise bağlanma")

def tl_karsiligi(usd):
    kur = KurServisi.usd_kuru()
    komisyon = float(os.environ.get("AM_KOMISYON", "0"))
    return round(usd * kur * (1 + komisyon), 2)

def test_tl_karsiligi(monkeypatch):
    monkeypatch.setattr(KurServisi, "usd_kuru", lambda: 32.5)
    monkeypatch.setenv("AM_KOMISYON", "0.01")
    assert tl_karsiligi(10) == 328.25

mp = pytest.MonkeyPatch()           # pytest bunu fixture olarak verir
try:
    test_tl_karsiligi(mp)
    print("test geçti")
finally:
    mp.undo()                       # test sonrası otomatik geri alma
print("AM_KOMISYON" in os.environ)  # False: ortam eski hâline döndü
try:
    KurServisi.usd_kuru()
except ConnectionError:
    print("asıl servis geri geldi")

Coverage ve komut satırı

Kapsam (coverage), testler çalışırken kodun hangi satırlarının çalıştığını ölçer: pytest --cov=anadolu --cov-report=term-missing her dosya için yüzde ve çalışmayan satır numaralarını gösterir. Yüzde 100 kapsam hatasız kod demek değildir (satır çalışmış ama sonucu kontrol edilmemiş olabilir); ama yüzde 40 kapsam, kodun yarısından fazlasının hiç denenmediğini kesin olarak söyler.

KomutNe yapar?
pytest -vHer testi adıyla ve sonucuyla listeler
pytest -qKısa çıktı
pytest -xİlk başarısızlıkta durur
pytest -k "kdv and not yavas"Adı ifadeye uyan testleri seçer
pytest --lf / --ffYalnızca son başarısızları / önce başarısızları çalıştırır
pytest tests/test_fiyat.py::test_kdv_ekleTek bir testi çalıştırır
pytest -m yavasİşarete göre seçer
pytest -sprint çıktısını yakalamadan gösterir
pytest --pdbBaşarısızlıkta hata ayıklayıcıyı açar
pytest --cov=paketKapsam ölçer (pytest-cov)
pytest -n autoTestleri paralel çalıştırır (pytest-xdist)
Gerçek işteKurumsal projelerde testler her pull request'te CI (GitHub Actions, GitLab CI) tarafından çalıştırılır; kırmızı test birleştirmeyi engeller. Kod incelemesinde (code review) "bu değişikliğin testi nerede?" sorusu standarttır. Hata bildirimi geldiğinde iyi ekipler önce hatayı yeniden üreten başarısız bir test yazar, sonra kodu düzeltir; böylece aynı hata bir daha geri gelmez (regresyon testi). Dış servisler (ödeme, kur, SMS) testte asla gerçek çağrılmaz; monkeypatch ya da mock ile taklit edilir.
Uzmanlaş

Mutasyon testi: testlerini test et

Kapsam yüzdesi "satır çalıştı mı" sorusunu yanıtlar, "test bir hatayı yakalar mıydı" sorusunu yanıtlamaz. Mutasyon testi araçları (mutmut, cosmic-ray) kodda küçük değişiklikler (mutant) yapar: >= yerine >, + yerine -, return değerini değiştirme. Testlerin bu mutantı "öldürmesi", yani başarısız olması gerekir. Bu dersin alıştırmaları tam olarak bu fikirle denetleniyor.

İyi testin özellikleri

  • Bağımsız: Sırası değişince sonucu değişmez; global durum paylaşmaz.
  • Hızlı: Ağ, disk ve uyku yok; binlerce test saniyeler içinde biter.
  • Belirleyici: Rastgelelik ve saat sabitlenir; bazen geçip bazen düşen (flaky) test güveni yok eder.
  • Okunur: Hazırla, çalıştır, doğrula (Arrange, Act, Assert) düzeni; adı neyi test ettiğini söyler: test_uye_olmayan_1000_tl_ustu_50_indirim_alir.

Özellik tabanlı test

hypothesis kütüphanesi senin yerine yüzlerce rastgele girdi üretip "her tutar için indirimli fiyat tutardan büyük olamaz" gibi genel özellikleri dener ve hatayı bulunca en küçük örneğe indirger. Parametrize ile seçtiğin durumların kaçırdığı köşeleri bulmakta çok güçlüdür.

Alıştırmalara geç (19)