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.
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):
| Öğe | Kural | Örnek |
|---|---|---|
| Dosya | test_*.py ya da *_test.py | test_fiyat.py |
| Fonksiyon | test ile başlayan | def test_kdv_ekle(): |
| Sınıf | Test ile başlayan, __init__ içermeyen | class TestSepet: |
| Metot | Test sınıfında test ile başlayan | def 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 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:
| scope | Ne zaman oluşturulur? | Örnek kullanım |
|---|---|---|
"function" (varsayılan) | Her test için yeniden | Değiştirilebilir sepet, sözlük |
"class" | Test sınıfı başına bir kez | Sınıftaki testlerin ortak verisi |
"module" | Test dosyası başına bir kez | Pahalı ama salt okunur veri |
"session" | Tüm çalıştırmada bir kez | Test 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")
>= 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
| Fixture | Ne verir? | Ne zaman? |
|---|---|---|
tmp_path | Her teste özel, boş bir pathlib.Path klasörü | Dosya yazan/okuyan kod |
monkeypatch | setattr, setenv, delenv, setitem; test bitince her şeyi geri alır | Dış servis, saat, ortam değişkeni |
capsys | readouterr() ile o ana kadarki stdout/stderr | print eden kod |
caplog | logging 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.
| Komut | Ne yapar? |
|---|---|
pytest -v | Her testi adıyla ve sonucuyla listeler |
pytest -q | Kı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 / --ff | Yalnızca son başarısızları / önce başarısızları çalıştırır |
pytest tests/test_fiyat.py::test_kdv_ekle | Tek bir testi çalıştırır |
pytest -m yavas | İşarete göre seçer |
pytest -s | print çıktısını yakalamadan gösterir |
pytest --pdb | Başarısızlıkta hata ayıklayıcıyı açar |
pytest --cov=paket | Kapsam ölçer (pytest-cov) |
pytest -n auto | Testleri paralel çalıştırır (pytest-xdist) |
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.